fix(supply-chain): verify CI and production inputs
Pin external actions, images, toolchains, native archives, and tvOS engine artifacts; enforce fail-closed CI checks and keep website privacy disclosures aligned with shipped behavior.
This commit is contained in:
+29
-15
@@ -506,8 +506,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Dart
|
||||
uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260 # v1
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
shell: pwsh
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Download x64 build
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
@@ -537,18 +547,22 @@ jobs:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
mkdir _signer | Out-Null
|
||||
@{name="signer"; environment=@{sdk=">=3.0.0 <4.0.0"}; dependencies=@{cryptography="2.9.0"}} | ConvertTo-Json -Depth 3 | Out-File _signer/pubspec.yaml
|
||||
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/edde746/auto_updater/9e150f71e17495b7361aedbe6df22e89ad52c254/packages/auto_updater/bin/sign_update.dart" -OutFile _signer/sign.dart
|
||||
Push-Location _signer
|
||||
dart pub get
|
||||
Set-Content -Path ed25519_key.pem -Value $env:SPARKLE_PRIVATE_KEY -Encoding ascii -NoNewline
|
||||
$output = dart run sign.dart ../plezy-windows-installer.exe ed25519_key.pem
|
||||
Pop-Location
|
||||
Remove-Item _signer -Recurse -Force
|
||||
$sig = [regex]::Match($output, 'edSignature="([^"]*)"').Groups[1].Value
|
||||
Set-Content -Path win-ed-signature.txt -Value $sig -Encoding ascii -NoNewline
|
||||
Set-Content -Path win-installer-size.txt -Value (Get-Item plezy-windows-installer.exe).Length.ToString() -Encoding ascii -NoNewline
|
||||
$keyPath = Join-Path $env:RUNNER_TEMP "plezy-winsparkle-ed25519.pem"
|
||||
try {
|
||||
Set-Content -Path $keyPath -Value $env:SPARKLE_PRIVATE_KEY -Encoding ascii -NoNewline
|
||||
$output = & dart run auto_updater:sign_update plezy-windows-installer.exe $keyPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WinSparkle signer failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
$match = [regex]::Match($output, 'edSignature="([^"]*)"')
|
||||
if (-not $match.Success) {
|
||||
throw "WinSparkle signer returned no EdDSA signature"
|
||||
}
|
||||
Set-Content -Path win-ed-signature.txt -Value $match.Groups[1].Value -Encoding ascii -NoNewline
|
||||
Set-Content -Path win-installer-size.txt -Value (Get-Item plezy-windows-installer.exe).Length.ToString() -Encoding ascii -NoNewline
|
||||
} finally {
|
||||
Remove-Item -Path $keyPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
- name: Attest Windows artifacts
|
||||
uses: actions/attest-build-provenance@78e6cbd37d0ac1a40113c04f2037dacf1ea3f12e # v4
|
||||
@@ -649,7 +663,7 @@ jobs:
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: libmpv-prefix
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh') }}
|
||||
key: ${{ env.TRUSTED_BUILD_CACHE_VERSION }}-libmpv-${{ runner.arch }}-${{ hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json') }}
|
||||
|
||||
- name: Build libmpv
|
||||
if: steps.libmpv-cache.outputs.cache-hit != 'true'
|
||||
|
||||
+350
-1
@@ -51,16 +51,24 @@ jobs:
|
||||
- name: Verify workflow and script guards
|
||||
run: |
|
||||
python3 scripts/check_build_workflow.py
|
||||
python3 scripts/test_check_build_workflow.py
|
||||
python3 scripts/check_apple_spm_locks.py
|
||||
python3 scripts/test_check_apple_spm_locks.py
|
||||
python3 scripts/verify_runtime_inputs.py
|
||||
python3 scripts/test_verify_runtime_inputs.py
|
||||
python3 scripts/check_workflow_security.py
|
||||
python3 scripts/test_check_workflow_security.py
|
||||
python3 scripts/check_workflow_action_pins.py
|
||||
python3 scripts/test_check_workflow_action_pins.py
|
||||
python3 scripts/check_container_image_pins.py
|
||||
python3 scripts/test_check_container_image_pins.py
|
||||
python3 scripts/test_fetch_tvos_engine.py
|
||||
python3 scripts/test_check_codegen.py
|
||||
python3 scripts/test_generate_relay_protocol.py
|
||||
python3 scripts/test_format_native.py
|
||||
python3 scripts/test_run_maestro.py
|
||||
python3 scripts/test_maestro_flow_contracts.py
|
||||
python3 scripts/test_maestro_jellyfin_proxy.py
|
||||
python3 scripts/check_update_packages_workflow.py
|
||||
python3 scripts/test_pubspec_version.py
|
||||
python3 scripts/test_clean_translations.py
|
||||
@@ -142,8 +150,20 @@ jobs:
|
||||
echo "No tests found, skipping test execution"
|
||||
fi
|
||||
|
||||
- name: Install wakelock_plus test dependencies
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter pub get --enforce-lockfile
|
||||
|
||||
- name: Run wakelock_plus VM tests
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter test test/wakelock_plus_linux_plugin_test.dart
|
||||
|
||||
- name: Run wakelock_plus Chrome tests
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter test --platform chrome --dart-define=WEB_PLUGIN_TESTS=true test/wakelock_plus_web_plugin_test.dart
|
||||
|
||||
android-test:
|
||||
name: Android JVM Unit Tests
|
||||
name: Android JVM and Native Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -190,6 +210,19 @@ jobs:
|
||||
- name: Configure Android local properties
|
||||
run: printf 'flutter.sdk=%s\nsdk.dir=%s\n' "$FLUTTER_ROOT" "$ANDROID_HOME" > android/local.properties
|
||||
|
||||
- name: Configure Android host native tests
|
||||
run: |
|
||||
cmake -S android/app/src/test/cpp -B build/android-host-tests \
|
||||
-DCMAKE_BUILD_TYPE=Debug
|
||||
|
||||
- name: Build Android host native tests
|
||||
run: cmake --build build/android-host-tests --parallel 2
|
||||
|
||||
- name: Run Android host native tests
|
||||
run: |
|
||||
ctest --test-dir build/android-host-tests \
|
||||
--output-on-failure --no-tests=error
|
||||
|
||||
- name: Run Android JVM unit tests
|
||||
working-directory: android
|
||||
run: ./gradlew :app:testDebugUnitTest :saf_util:testDebugUnitTest :libass:testDebugUnitTest -x :app:compileFlutterBuildDebug --continue
|
||||
@@ -218,6 +251,282 @@ jobs:
|
||||
- name: Verify native formatting
|
||||
run: scripts/format_native.sh --check
|
||||
|
||||
- name: Verify Linux native acquisition integrity
|
||||
run: bash linux/packaging/build-libmpv_test.sh
|
||||
|
||||
linux-native-test:
|
||||
name: Linux native reliability (${{ matrix.sanitizer }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- sanitizer: address
|
||||
lifecycle_sanitizers: ON
|
||||
- sanitizer: thread
|
||||
lifecycle_sanitizers: OFF
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Install Linux native test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev \
|
||||
libstdc++-12-dev libmpv-dev libepoxy-dev
|
||||
|
||||
- name: Prepare Flutter Linux configuration
|
||||
run: |
|
||||
flutter pub get --enforce-lockfile --no-example
|
||||
flutter build linux --debug --config-only --no-pub
|
||||
|
||||
- name: Configure Linux native reliability tests
|
||||
run: |
|
||||
cmake -S linux -B build/linux-native-${{ matrix.sanitizer }} -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DPLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS=ON \
|
||||
-DPLEZY_MPV_LIFECYCLE_SANITIZERS=${{ matrix.lifecycle_sanitizers }} \
|
||||
-DPLEZY_BUILD_MPV_RELIABILITY_TESTS=ON \
|
||||
-DPLEZY_MPV_RELIABILITY_SANITIZER=${{ matrix.sanitizer }}
|
||||
|
||||
- name: Build Linux native reliability tests
|
||||
run: |
|
||||
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
|
||||
mpv_player_lifecycle_test \
|
||||
mpv_property_result_contract_test \
|
||||
mpv_gpu_bootstrap_test
|
||||
|
||||
- name: Run Linux native reliability tests
|
||||
run: |
|
||||
ctest --test-dir build/linux-native-${{ matrix.sanitizer }} \
|
||||
--output-on-failure --no-tests=error
|
||||
|
||||
apple-native-test:
|
||||
name: Apple native reliability (${{ matrix.platform }})
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: iOS
|
||||
project_directory: ios
|
||||
workspace: ios/Runner.xcworkspace
|
||||
simulator_runtime: iOS
|
||||
simulator_platform: iOS
|
||||
static_destination: ""
|
||||
- platform: macOS
|
||||
project_directory: macos
|
||||
workspace: macos/Runner.xcworkspace
|
||||
simulator_runtime: ""
|
||||
simulator_platform: ""
|
||||
static_destination: platform=macOS
|
||||
- platform: tvOS
|
||||
project_directory: tvos
|
||||
workspace: tvos/Runner.xcworkspace
|
||||
simulator_runtime: tvOS
|
||||
simulator_platform: tvOS
|
||||
static_destination: ""
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Install locked Dart dependencies
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Record committed CocoaPods lockfile
|
||||
if: matrix.platform != 'tvOS'
|
||||
env:
|
||||
PODFILE_LOCK: ${{ matrix.project_directory }}/Podfile.lock
|
||||
run: |
|
||||
test -f "$PODFILE_LOCK"
|
||||
shasum -a 256 "$PODFILE_LOCK" > "$RUNNER_TEMP/plezy-podfile-lock.sha256"
|
||||
|
||||
- name: Prepare iOS Flutter build settings
|
||||
if: matrix.platform == 'iOS'
|
||||
run: flutter build ios --config-only --simulator --debug --no-pub
|
||||
|
||||
- name: Prepare macOS Flutter build settings
|
||||
if: matrix.platform == 'macOS'
|
||||
run: flutter build macos --config-only --debug --no-pub
|
||||
|
||||
- name: Prepare tvOS Flutter engine
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: tvos/scripts/fetch_engine.sh
|
||||
|
||||
- name: Verify Flutter configuration preserved CocoaPods lockfile
|
||||
if: matrix.platform != 'tvOS'
|
||||
run: shasum -a 256 --check "$RUNNER_TEMP/plezy-podfile-lock.sha256"
|
||||
|
||||
- name: Install locked CocoaPods dependencies
|
||||
if: matrix.platform != 'tvOS'
|
||||
working-directory: ${{ matrix.project_directory }}
|
||||
run: pod install --deployment
|
||||
|
||||
- name: Install tvOS CocoaPods dependencies
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: tvos/scripts/pod_install.sh
|
||||
|
||||
- name: Verify tvOS project wiring
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: ruby tvos/scripts/test_wire_mpv.rb
|
||||
|
||||
- name: Select Apple test destination
|
||||
env:
|
||||
SIMULATOR_RUNTIME: ${{ matrix.simulator_runtime }}
|
||||
SIMULATOR_PLATFORM: ${{ matrix.simulator_platform }}
|
||||
STATIC_DESTINATION: ${{ matrix.static_destination }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
destination = os.environ["STATIC_DESTINATION"]
|
||||
if not destination:
|
||||
runtime_name = os.environ["SIMULATOR_RUNTIME"]
|
||||
payload = json.loads(
|
||||
subprocess.check_output(
|
||||
["xcrun", "simctl", "list", "devices", "available", "-j"],
|
||||
text=True,
|
||||
)
|
||||
)
|
||||
devices = [
|
||||
device
|
||||
for runtime, candidates in payload["devices"].items()
|
||||
if f".{runtime_name}-" in runtime
|
||||
for device in candidates
|
||||
if device.get("isAvailable", False)
|
||||
]
|
||||
if not devices:
|
||||
raise SystemExit(f"no available {runtime_name} simulator")
|
||||
destination = (
|
||||
f"platform={os.environ['SIMULATOR_PLATFORM']} Simulator,"
|
||||
f"id={devices[0]['udid']}"
|
||||
)
|
||||
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output:
|
||||
output.write(f"APPLE_TEST_DESTINATION={destination}\n")
|
||||
PY
|
||||
|
||||
- name: Run Apple native reliability tests
|
||||
run: |
|
||||
xcodebuild test \
|
||||
-workspace "${{ matrix.workspace }}" \
|
||||
-scheme Runner \
|
||||
-configuration Debug \
|
||||
-destination "$APPLE_TEST_DESTINATION" \
|
||||
-disableAutomaticPackageResolution \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
COMPILER_INDEX_STORE_ENABLE=NO
|
||||
|
||||
windows-native-test:
|
||||
name: Windows native reliability (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: windows-latest
|
||||
flutter_setup: action
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
flutter_setup: git
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install 7-Zip
|
||||
if: matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: choco install 7zip -y
|
||||
|
||||
- name: Setup Flutter
|
||||
if: matrix.flutter_setup == 'action'
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Setup Flutter 3.44.0 from its immutable commit
|
||||
if: matrix.flutter_setup == 'git'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = "$env:RUNNER_TEMP\flutter"
|
||||
git init $root
|
||||
git -C $root remote add origin https://github.com/flutter/flutter.git
|
||||
git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
||||
git -C $root checkout --detach FETCH_HEAD
|
||||
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
& "$root\bin\flutter.bat" --version
|
||||
|
||||
- name: Install locked Dart dependencies
|
||||
shell: pwsh
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Install patched Flutter engine
|
||||
shell: pwsh
|
||||
run: |
|
||||
flutter precache --windows
|
||||
.\windows\tool\install-patched-engine.ps1
|
||||
|
||||
- name: Prepare Flutter Windows configuration
|
||||
shell: pwsh
|
||||
run: flutter build windows --debug --config-only --no-pub
|
||||
|
||||
- name: Configure Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
cmake -S windows -B $buildDir `
|
||||
-DPLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS=ON `
|
||||
-DPLEZY_BUILD_DISPLAY_RECOVERY_TESTS=ON
|
||||
|
||||
- name: Build Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
cmake --build $buildDir --config Debug --parallel 2 --target `
|
||||
mpv_property_result_contract_test `
|
||||
mpv_player_property_contract_test `
|
||||
display_mode_manager_test
|
||||
|
||||
- name: Run Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
ctest --test-dir "$buildDir/runner" -C Debug --output-on-failure --no-tests=error
|
||||
|
||||
dependency-check:
|
||||
name: Dependency Validation
|
||||
runs-on: ubuntu-latest
|
||||
@@ -249,3 +558,43 @@ jobs:
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub outdated
|
||||
|
||||
server:
|
||||
name: Server checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run server checks
|
||||
run: scripts/ci_server_checks.sh
|
||||
|
||||
|
||||
website:
|
||||
name: Website checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: Run website checks
|
||||
run: scripts/ci_website_checks.sh
|
||||
|
||||
@@ -66,6 +66,32 @@ Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`,
|
||||
`.maestro/regression_flows/`. CI runs the same suites from `.github/workflows/e2e.yml` and uploads diagnostics on
|
||||
failure.
|
||||
|
||||
### Production container image updates
|
||||
|
||||
Production images in `server/Dockerfile` and `server/docker-compose.yml` use a readable version or source-revision tag
|
||||
plus an authoritative multi-platform index digest. The adjacent `Platforms` declaration records the supported
|
||||
`linux/amd64` and `linux/arm64` variants. Never replace these references with a mutable tag or a single-platform child
|
||||
manifest.
|
||||
|
||||
Update a production image only through a reviewed change:
|
||||
|
||||
1. For the Bugs service, first record the running container's image ID, repository digest, platform, and OCI source
|
||||
revision without printing its environment. Prefer that reviewed running identity; selecting anything else is a
|
||||
service upgrade, not a routine pin refresh.
|
||||
2. Review the upstream source revision and changelog, provenance, vulnerability results, and manifest contents. Resolve
|
||||
the readable tag and digest-qualified reference independently and confirm they identify the same OCI index in two
|
||||
clean caches. The index must contain both declared platforms; provenance/attestation descriptors do not count as
|
||||
runnable platforms.
|
||||
3. Change the readable tag, full `sha256` index digest, and adjacent platform declaration together. Include the old and
|
||||
new identities, manifest/platform evidence, review findings, smoke results, and rollback notes in the change.
|
||||
4. Before changing the Bugs digest, exercise it with non-production configuration and a disposable volume. Review
|
||||
migrations, take a restorable `bugs_data` backup, then validate a cloned volume. A forward-only migration rolls back
|
||||
with the prior digest and pre-change backup, not by changing the image reference alone.
|
||||
5. Run `python3 scripts/check_container_image_pins.py`, `python3 scripts/test_check_container_image_pins.py`, and
|
||||
`(cd server && go test ./...)`. Inspect the rendered Compose configuration and rebuilt images locally without
|
||||
exposing configuration values. Do not publish or deploy from a review checkout, and never fall back to `latest` when
|
||||
a digest is unavailable.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses `slang` for internationalization with JSON files.
|
||||
|
||||
@@ -74,15 +74,20 @@ class SystemShelfService {
|
||||
}
|
||||
|
||||
Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||
if (call.method == 'onWatchNextTap' || call.method == 'onShelfItemTap') {
|
||||
final args = call.arguments;
|
||||
final contentId = args is Map ? args['contentId'] as String? : null;
|
||||
if (contentId != null) {
|
||||
onShelfItemTap?.call(contentId);
|
||||
}
|
||||
if (call.method != 'onWatchNextTap' && call.method != 'onShelfItemTap') {
|
||||
return null;
|
||||
}
|
||||
final args = call.arguments;
|
||||
final contentId = args is Map ? args['contentId'] as String? : null;
|
||||
final callback = onShelfItemTap;
|
||||
if (contentId == null || callback == null) return false;
|
||||
callback(contentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<dynamic> handleMethodCallForTesting(MethodCall call) => _handleMethodCall(call);
|
||||
|
||||
/// Establishes the only owner allowed to publish launcher shelf state.
|
||||
///
|
||||
/// Ownership changes are synchronous. Native mutations remain serialized
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
|
||||
class TvosSystemNavigationService {
|
||||
@@ -8,13 +10,58 @@ class TvosSystemNavigationService {
|
||||
JSONMessageCodec(),
|
||||
);
|
||||
|
||||
static bool? _menuPassthroughEnabled;
|
||||
static bool? _latestDesiredValue;
|
||||
static bool? _lastAcknowledgedValue;
|
||||
static Future<void>? _inFlightUpdate;
|
||||
static bool _trailingUpdateRequested = false;
|
||||
|
||||
static Future<void> setMenuPassthroughEnabled(bool enabled) async {
|
||||
if (!PlatformDetector.isAppleTV()) return;
|
||||
if (_menuPassthroughEnabled == enabled) return;
|
||||
static Future<void> setMenuPassthroughEnabled(bool enabled) {
|
||||
if (!PlatformDetector.isAppleTV()) return Future<void>.value();
|
||||
|
||||
_menuPassthroughEnabled = enabled;
|
||||
await _channel.send({'menuPassthroughEnabled': enabled});
|
||||
_latestDesiredValue = enabled;
|
||||
final activeUpdate = _inFlightUpdate;
|
||||
if (activeUpdate != null) {
|
||||
_trailingUpdateRequested = true;
|
||||
return activeUpdate;
|
||||
}
|
||||
if (_lastAcknowledgedValue == enabled) return Future<void>.value();
|
||||
|
||||
final update = _runUpdateLoopAndClear();
|
||||
_inFlightUpdate = update;
|
||||
return update;
|
||||
}
|
||||
|
||||
static Future<void> _runUpdateLoopAndClear() async {
|
||||
try {
|
||||
await _runUpdateLoop();
|
||||
} finally {
|
||||
_inFlightUpdate = null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _runUpdateLoop() async {
|
||||
do {
|
||||
_trailingUpdateRequested = false;
|
||||
final desired = _latestDesiredValue;
|
||||
if (desired == null || desired == _lastAcknowledgedValue) continue;
|
||||
|
||||
try {
|
||||
final reply = await _channel.send({'menuPassthroughEnabled': desired});
|
||||
if (reply == true) {
|
||||
_lastAcknowledgedValue = desired;
|
||||
}
|
||||
} on PlatformException catch (error, stackTrace) {
|
||||
appLogger.w('Failed to update tvOS Menu passthrough state', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
} while (_trailingUpdateRequested);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void resetForTesting() {
|
||||
assert(_inFlightUpdate == null, 'Await the active tvOS navigation update before resetting');
|
||||
_latestDesiredValue = null;
|
||||
_lastAcknowledgedValue = null;
|
||||
_trailingUpdateRequested = false;
|
||||
_inFlightUpdate = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
simdutf
|
||||
URL https://github.com/simdutf/simdutf/releases/download/v6.4.2/singleheader.zip
|
||||
URL_HASH SHA256=9fe4d6f515724a55c8de88fee4463e0890a1abe2267cda13c4b5d245d58039e6
|
||||
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(simdutf)
|
||||
|
||||
+232
-116
@@ -1,144 +1,260 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PREFIX="$(pwd)/libmpv-prefix"
|
||||
JOBS="$(nproc)"
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NATIVE_INPUTS_MANIFEST="${NATIVE_INPUTS_MANIFEST:-$SCRIPT_DIR/native-inputs.json}"
|
||||
|
||||
FFMPEG_VERSION="7.1"
|
||||
SHADERC_VERSION="2024.4"
|
||||
LIBPLACEBO_VERSION="7.351.0"
|
||||
MPV_VERSION="0.40.0"
|
||||
manifest_value() {
|
||||
python3 - "$NATIVE_INPUTS_MANIFEST" "$1" "$2" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
PREFIX="$(realpath "$PREFIX")"
|
||||
mkdir -p "$PREFIX"
|
||||
export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:$PREFIX/lib/$(uname -m)-linux-gnu/pkgconfig:${PKG_CONFIG_PATH:-}"
|
||||
with open(sys.argv[1], encoding="utf-8") as source:
|
||||
manifest = json.load(source)
|
||||
value = manifest["inputs"][sys.argv[2]][sys.argv[3]]
|
||||
if not isinstance(value, str) or not value:
|
||||
raise SystemExit(f"invalid manifest value: {sys.argv[2]}.{sys.argv[3]}")
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
SRCDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$SRCDIR"' EXIT
|
||||
cd "$SRCDIR"
|
||||
FFMPEG_VERSION="$(manifest_value ffmpeg version)"
|
||||
FFMPEG_URL="$(manifest_value ffmpeg url)"
|
||||
FFMPEG_SHA256="$(manifest_value ffmpeg sha256)"
|
||||
SHADERC_VERSION="$(manifest_value shaderc version)"
|
||||
SHADERC_URL="$(manifest_value shaderc url)"
|
||||
SHADERC_REF="$(manifest_value shaderc ref)"
|
||||
SHADERC_COMMIT="$(manifest_value shaderc commit)"
|
||||
LIBPLACEBO_VERSION="$(manifest_value libplacebo version)"
|
||||
LIBPLACEBO_URL="$(manifest_value libplacebo url)"
|
||||
LIBPLACEBO_REF="$(manifest_value libplacebo ref)"
|
||||
LIBPLACEBO_COMMIT="$(manifest_value libplacebo commit)"
|
||||
MPV_VERSION="$(manifest_value mpv version)"
|
||||
MPV_URL="$(manifest_value mpv url)"
|
||||
MPV_SHA256="$(manifest_value mpv sha256)"
|
||||
|
||||
echo "==> Sources in $SRCDIR"
|
||||
echo "==> Install prefix: $PREFIX"
|
||||
echo ""
|
||||
sha256_file() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | cut -d ' ' -f 1
|
||||
else
|
||||
shasum -a 256 "$1" | cut -d ' ' -f 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Step 1: ffmpeg (static libraries) ───────────────────────────────────────
|
||||
download_verified() {
|
||||
local url="$1"
|
||||
local expected_sha256="$2"
|
||||
local destination="$3"
|
||||
local temporary
|
||||
local actual_sha256
|
||||
|
||||
echo "==> Building ffmpeg $FFMPEG_VERSION (static, decoder-only)..."
|
||||
curl -sL "https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" | tar xJ
|
||||
cd "ffmpeg-${FFMPEG_VERSION}"
|
||||
if [[ ! "$expected_sha256" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "Invalid SHA-256 pin for $url" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
./configure \
|
||||
--prefix="$PREFIX" \
|
||||
--enable-gpl \
|
||||
--enable-version3 \
|
||||
--enable-static \
|
||||
--disable-shared \
|
||||
--enable-pic \
|
||||
--disable-programs \
|
||||
--disable-doc \
|
||||
--disable-encoders \
|
||||
--disable-muxers \
|
||||
--enable-muxer=spdif \
|
||||
--disable-devices \
|
||||
--disable-bsfs \
|
||||
--enable-bsf=aac_adtstoasc,av1_metadata,extract_extradata,h264_metadata,h264_mp4toannexb,hevc_metadata,hevc_mp4toannexb,vp9_metadata \
|
||||
--disable-filters \
|
||||
--enable-filter=aformat,aresample,format,null,scale \
|
||||
--enable-gnutls \
|
||||
--enable-vaapi \
|
||||
--enable-vdpau \
|
||||
--disable-debug \
|
||||
--disable-stripping
|
||||
mkdir -p "$(dirname "$destination")"
|
||||
temporary="$(mktemp "${destination}.tmp.XXXXXX")"
|
||||
if ! curl \
|
||||
--fail \
|
||||
--location \
|
||||
--silent \
|
||||
--show-error \
|
||||
--proto '=https,file' \
|
||||
--tlsv1.2 \
|
||||
--output "$temporary" \
|
||||
"$url"; then
|
||||
rm -f "$temporary"
|
||||
return 1
|
||||
fi
|
||||
|
||||
make -j"$JOBS"
|
||||
make install
|
||||
cd "$SRCDIR"
|
||||
actual_sha256="$(sha256_file "$temporary")"
|
||||
if [ "$actual_sha256" != "$expected_sha256" ]; then
|
||||
echo "SHA-256 mismatch for $url" >&2
|
||||
echo "Expected: $expected_sha256" >&2
|
||||
echo "Actual: $actual_sha256" >&2
|
||||
rm -f "$temporary" "$destination"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> ffmpeg done."
|
||||
echo ""
|
||||
mv "$temporary" "$destination"
|
||||
}
|
||||
|
||||
# ─── Step 2: shaderc (static library) ─────────────────────────────────────────
|
||||
checkout_verified_ref() {
|
||||
local url="$1"
|
||||
local ref="$2"
|
||||
local expected_commit="$3"
|
||||
local destination="$4"
|
||||
local actual_commit
|
||||
|
||||
echo "==> Building shaderc $SHADERC_VERSION (static)..."
|
||||
git clone --depth 1 --branch "v${SHADERC_VERSION}" \
|
||||
https://github.com/google/shaderc.git "shaderc-v${SHADERC_VERSION}"
|
||||
cd "shaderc-v${SHADERC_VERSION}"
|
||||
./utils/git-sync-deps
|
||||
if [[ ! "$expected_commit" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Invalid Git commit pin for $url at $ref" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
cmake -S . -B build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
|
||||
-DSHADERC_SKIP_TESTS=ON \
|
||||
-DSHADERC_SKIP_EXAMPLES=ON \
|
||||
-DSHADERC_SKIP_COPYRIGHT_CHECK=ON \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
|
||||
rm -rf "$destination"
|
||||
if ! git clone --quiet --depth 1 --branch "$ref" --no-checkout \
|
||||
"$url" "$destination"; then
|
||||
rm -rf "$destination"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cmake --build build -j"$JOBS"
|
||||
cmake --install build
|
||||
actual_commit="$(git -C "$destination" rev-parse 'HEAD^{commit}')"
|
||||
if [ "$actual_commit" != "$expected_commit" ]; then
|
||||
echo "Git ref mismatch for $url at $ref" >&2
|
||||
echo "Expected: $expected_commit" >&2
|
||||
echo "Actual: $actual_commit" >&2
|
||||
rm -rf "$destination"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd "$SRCDIR"
|
||||
git -C "$destination" checkout --quiet --detach "$expected_commit"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "==> shaderc done."
|
||||
echo ""
|
||||
cleanup_srcdir=""
|
||||
|
||||
# ─── Step 3: libplacebo (static library) ─────────────────────────────────────
|
||||
cleanup() {
|
||||
if [ -n "$cleanup_srcdir" ]; then
|
||||
rm -rf -- "$cleanup_srcdir"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "==> Building libplacebo $LIBPLACEBO_VERSION (static)..."
|
||||
git clone --depth 1 --recursive --branch "v${LIBPLACEBO_VERSION}" \
|
||||
https://code.videolan.org/videolan/libplacebo.git "libplacebo-v${LIBPLACEBO_VERSION}"
|
||||
cd "libplacebo-v${LIBPLACEBO_VERSION}"
|
||||
main() {
|
||||
local prefix="${PREFIX:-$(pwd)/libmpv-prefix}"
|
||||
local jobs="${JOBS:-$(nproc)}"
|
||||
local srcdir
|
||||
|
||||
meson setup build \
|
||||
--prefix="$PREFIX" \
|
||||
--default-library=static \
|
||||
-Dvulkan=disabled \
|
||||
-Dd3d11=disabled \
|
||||
-Ddemos=false \
|
||||
-Dtests=false
|
||||
mkdir -p "$prefix"
|
||||
prefix="$(realpath "$prefix")"
|
||||
export PKG_CONFIG_PATH="$prefix/lib/pkgconfig:$prefix/lib/$(uname -m)-linux-gnu/pkgconfig:${PKG_CONFIG_PATH:-}"
|
||||
|
||||
ninja -C build -j"$JOBS"
|
||||
ninja -C build install
|
||||
cd "$SRCDIR"
|
||||
srcdir="$(mktemp -d)"
|
||||
cleanup_srcdir="$srcdir"
|
||||
trap cleanup EXIT
|
||||
cd "$srcdir"
|
||||
|
||||
echo ""
|
||||
echo "==> libplacebo done."
|
||||
echo ""
|
||||
echo "==> Sources in $srcdir"
|
||||
echo "==> Install prefix: $prefix"
|
||||
echo ""
|
||||
|
||||
# ─── Step 4: mpv (shared libmpv) ─────────────────────────────────────────────
|
||||
# ─── Step 1: ffmpeg (static libraries) ─────────────────────────────────────
|
||||
echo "==> Building ffmpeg $FFMPEG_VERSION (static, decoder-only)..."
|
||||
download_verified "$FFMPEG_URL" "$FFMPEG_SHA256" "$srcdir/ffmpeg.tar.xz"
|
||||
tar -xJf "$srcdir/ffmpeg.tar.xz"
|
||||
cd "ffmpeg-${FFMPEG_VERSION}"
|
||||
|
||||
echo "==> Building mpv $MPV_VERSION (shared libmpv only)..."
|
||||
curl -sL "https://github.com/mpv-player/mpv/archive/refs/tags/v${MPV_VERSION}.tar.gz" | tar xz
|
||||
cd "mpv-${MPV_VERSION}"
|
||||
./configure \
|
||||
--prefix="$prefix" \
|
||||
--enable-gpl \
|
||||
--enable-version3 \
|
||||
--enable-static \
|
||||
--disable-shared \
|
||||
--enable-pic \
|
||||
--disable-programs \
|
||||
--disable-doc \
|
||||
--disable-encoders \
|
||||
--disable-muxers \
|
||||
--enable-muxer=spdif \
|
||||
--disable-devices \
|
||||
--disable-bsfs \
|
||||
--enable-bsf=aac_adtstoasc,av1_metadata,extract_extradata,h264_metadata,h264_mp4toannexb,hevc_metadata,hevc_mp4toannexb,vp9_metadata \
|
||||
--disable-filters \
|
||||
--enable-filter=aformat,aresample,format,null,scale \
|
||||
--enable-gnutls \
|
||||
--enable-vaapi \
|
||||
--enable-vdpau \
|
||||
--disable-debug \
|
||||
--disable-stripping
|
||||
|
||||
meson setup build \
|
||||
--prefix="$PREFIX" \
|
||||
-Dlibmpv=true \
|
||||
-Dcplayer=false \
|
||||
-Dbuild-date=false \
|
||||
-Dlua=enabled \
|
||||
-Djavascript=enabled \
|
||||
-Dcplugins=disabled \
|
||||
-Dmanpage-build=disabled \
|
||||
-Djack=disabled \
|
||||
-Dvulkan=disabled \
|
||||
-Dd3d11=disabled \
|
||||
-Dgl=enabled \
|
||||
-Dvaapi=enabled \
|
||||
-Dvdpau=enabled \
|
||||
-Dalsa=enabled \
|
||||
-Dpulse=enabled \
|
||||
-Dpipewire=enabled \
|
||||
-Dwayland=disabled \
|
||||
-Dx11=enabled
|
||||
make -j"$jobs"
|
||||
make install
|
||||
cd "$srcdir"
|
||||
echo ""
|
||||
echo "==> ffmpeg done."
|
||||
echo ""
|
||||
|
||||
ninja -C build -j"$JOBS"
|
||||
ninja -C build install
|
||||
cd "$SRCDIR"
|
||||
# ─── Step 2: shaderc (static library) ───────────────────────────────────────
|
||||
echo "==> Building shaderc $SHADERC_VERSION (static)..."
|
||||
checkout_verified_ref \
|
||||
"$SHADERC_URL" "$SHADERC_REF" "$SHADERC_COMMIT" \
|
||||
"$srcdir/shaderc-v${SHADERC_VERSION}"
|
||||
cd "shaderc-v${SHADERC_VERSION}"
|
||||
./utils/git-sync-deps
|
||||
|
||||
echo ""
|
||||
echo "==> mpv done."
|
||||
echo ""
|
||||
echo "==> libmpv build complete. Output in $PREFIX"
|
||||
cmake -S . -B build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX="$prefix" \
|
||||
-DSHADERC_SKIP_TESTS=ON \
|
||||
-DSHADERC_SKIP_EXAMPLES=ON \
|
||||
-DSHADERC_SKIP_COPYRIGHT_CHECK=ON \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
|
||||
|
||||
cmake --build build -j"$jobs"
|
||||
cmake --install build
|
||||
cd "$srcdir"
|
||||
echo ""
|
||||
echo "==> shaderc done."
|
||||
echo ""
|
||||
|
||||
# ─── Step 3: libplacebo (static library) ───────────────────────────────────
|
||||
echo "==> Building libplacebo $LIBPLACEBO_VERSION (static)..."
|
||||
checkout_verified_ref \
|
||||
"$LIBPLACEBO_URL" "$LIBPLACEBO_REF" "$LIBPLACEBO_COMMIT" \
|
||||
"$srcdir/libplacebo-v${LIBPLACEBO_VERSION}"
|
||||
cd "libplacebo-v${LIBPLACEBO_VERSION}"
|
||||
git submodule update --init --recursive
|
||||
|
||||
meson setup build \
|
||||
--prefix="$prefix" \
|
||||
--default-library=static \
|
||||
-Dvulkan=disabled \
|
||||
-Dd3d11=disabled \
|
||||
-Ddemos=false \
|
||||
-Dtests=false
|
||||
|
||||
ninja -C build -j"$jobs"
|
||||
ninja -C build install
|
||||
cd "$srcdir"
|
||||
echo ""
|
||||
echo "==> libplacebo done."
|
||||
echo ""
|
||||
|
||||
# ─── Step 4: mpv (shared libmpv) ───────────────────────────────────────────
|
||||
echo "==> Building mpv $MPV_VERSION (shared libmpv only)..."
|
||||
download_verified "$MPV_URL" "$MPV_SHA256" "$srcdir/mpv.tar.gz"
|
||||
tar -xzf "$srcdir/mpv.tar.gz"
|
||||
cd "mpv-${MPV_VERSION}"
|
||||
|
||||
meson setup build \
|
||||
--prefix="$prefix" \
|
||||
-Dlibmpv=true \
|
||||
-Dcplayer=false \
|
||||
-Dbuild-date=false \
|
||||
-Dlua=enabled \
|
||||
-Djavascript=enabled \
|
||||
-Dcplugins=disabled \
|
||||
-Dmanpage-build=disabled \
|
||||
-Djack=disabled \
|
||||
-Dvulkan=disabled \
|
||||
-Dd3d11=disabled \
|
||||
-Dgl=enabled \
|
||||
-Dvaapi=enabled \
|
||||
-Dvdpau=enabled \
|
||||
-Dalsa=enabled \
|
||||
-Dpulse=enabled \
|
||||
-Dpipewire=enabled \
|
||||
-Dwayland=disabled \
|
||||
-Dx11=enabled
|
||||
|
||||
ninja -C build -j"$jobs"
|
||||
ninja -C build install
|
||||
echo ""
|
||||
echo "==> mpv done."
|
||||
echo ""
|
||||
echo "==> libmpv build complete. Output in $prefix"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=build-libmpv.sh
|
||||
source "$SCRIPT_DIR/build-libmpv.sh"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_absent() {
|
||||
[ ! -e "$1" ] || fail "unexpected path remains: $1"
|
||||
}
|
||||
|
||||
temporary="$(mktemp -d)"
|
||||
trap 'rm -rf "$temporary"' EXIT
|
||||
|
||||
fixture="$temporary/source.bin"
|
||||
destination="$temporary/download/output.bin"
|
||||
printf 'reviewed native input\n' >"$fixture"
|
||||
expected="$(sha256_file "$fixture")"
|
||||
download_verified "file://$fixture" "$expected" "$destination"
|
||||
cmp -s "$fixture" "$destination" || fail "verified download changed bytes"
|
||||
|
||||
printf 'reviewed native inpuu\n' >"$fixture"
|
||||
rm -f "$destination"
|
||||
if download_verified "file://$fixture" "$expected" "$destination"; then
|
||||
fail "changed archive was accepted"
|
||||
fi
|
||||
assert_absent "$destination"
|
||||
if compgen -G "$destination.tmp.*" >/dev/null; then
|
||||
fail "failed download left a temporary file"
|
||||
fi
|
||||
|
||||
repository="$temporary/repository"
|
||||
checkout="$temporary/checkout"
|
||||
mkdir -p "$repository"
|
||||
git -C "$repository" init --quiet
|
||||
git -C "$repository" config user.name "Plezy provenance test"
|
||||
git -C "$repository" config user.email "provenance-test@invalid.example"
|
||||
printf 'first\n' >"$repository/input.txt"
|
||||
git -C "$repository" add input.txt
|
||||
git -C "$repository" commit --quiet -m first
|
||||
git -C "$repository" tag release
|
||||
approved_commit="$(git -C "$repository" rev-parse HEAD)"
|
||||
checkout_verified_ref "file://$repository" release "$approved_commit" "$checkout"
|
||||
[ "$(git -C "$checkout" rev-parse HEAD)" = "$approved_commit" ] ||
|
||||
fail "verified checkout selected the wrong commit"
|
||||
|
||||
printf 'second\n' >"$repository/input.txt"
|
||||
git -C "$repository" commit --quiet -am second
|
||||
git -C "$repository" tag --force release >/dev/null
|
||||
if checkout_verified_ref "file://$repository" release "$approved_commit" "$checkout"; then
|
||||
fail "moved tag was accepted"
|
||||
fi
|
||||
assert_absent "$checkout"
|
||||
|
||||
echo "Linux native acquisition verification passed"
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"refreshContract": {
|
||||
"rules": [
|
||||
"Audit each new upstream release before changing its version, URL, ref, commit, or SHA-256.",
|
||||
"For an archive, verify upstream release evidence first, hash the complete reviewed file, and update URL and SHA-256 together.",
|
||||
"For Git, verify the upstream release-producing tag record, record the full dereferenced root commit, and review its dependency lock or gitlinks before updating.",
|
||||
"Run python3 scripts/verify_runtime_inputs.py and bash linux/packaging/build-libmpv_test.sh before a Linux build.",
|
||||
"Never derive an expected checksum from bytes inside the production build or verification command."
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"ffmpeg": {
|
||||
"kind": "archive",
|
||||
"version": "7.1",
|
||||
"url": "https://ffmpeg.org/releases/ffmpeg-7.1.tar.xz",
|
||||
"sha256": "40973d44970dbc83ef302b0609f2e74982be2d85916dd2ee7472d30678a7abe6",
|
||||
"provenance": "FFmpeg release archive verified against ffmpeg-7.1.tar.xz.asc with the official ffmpeg-devel.asc full fingerprint FCF986EA15E6E293A5644F10B4322F04D67658D8 before recording this digest."
|
||||
},
|
||||
"shaderc": {
|
||||
"kind": "git",
|
||||
"version": "2024.4",
|
||||
"url": "https://github.com/google/shaderc.git",
|
||||
"ref": "v2024.4",
|
||||
"commit": "caa54d9779d5605aca4e1a0c0c962a3d8f4aeb31",
|
||||
"provenance": "Official GitHub annotated tag object 3cd72062f297df05e6a042f2616c42bc8956c326 dereferences to this root commit; its DEPS file pins synchronized dependencies by full commit."
|
||||
},
|
||||
"libplacebo": {
|
||||
"kind": "git",
|
||||
"version": "7.351.0",
|
||||
"url": "https://code.videolan.org/videolan/libplacebo.git",
|
||||
"ref": "v7.351.0",
|
||||
"commit": "3188549fba13bbdf3a5a98de2a38c2e71f04e21e",
|
||||
"provenance": "Official VideoLAN GitLab tag record v7.351.0 contains a PGP-signed release message and dereferences to this root commit; its gitlinks pin recursive submodules."
|
||||
},
|
||||
"mpv": {
|
||||
"kind": "archive",
|
||||
"version": "0.40.0",
|
||||
"url": "https://github.com/mpv-player/mpv/archive/refs/tags/v0.40.0.tar.gz",
|
||||
"sha256": "10a0f4654f62140a6dd4d380dcf0bbdbdcf6e697556863dc499c296182f081a3",
|
||||
"commit": "e48ac7ce08462f5e33af6ef9deeac6fa87eef01e",
|
||||
"provenance": "GitHub reports annotated tag object 287d7cdb78975ae350d7c2a287eae3c2072c93f7 as a valid PGP signature over release commit e48ac7ce08462f5e33af6ef9deeac6fa87eef01e; the complete tag archive was then hashed."
|
||||
},
|
||||
"simdutf": {
|
||||
"kind": "archive",
|
||||
"version": "6.4.2",
|
||||
"url": "https://github.com/simdutf/simdutf/releases/download/v6.4.2/singleheader.zip",
|
||||
"sha256": "9fe4d6f515724a55c8de88fee4463e0890a1abe2267cda13c4b5d245d58039e6",
|
||||
"provenance": "Official GitHub release 210237657 records asset 243345703 as singleheader.zip with size 3013061 bytes; the complete release asset was independently hashed before recording this digest."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
/// Message for toggling the wakelock on the platform side.
|
||||
class ToggleMessage {
|
||||
bool? enable;
|
||||
}
|
||||
|
||||
/// Message for reporting the wakelock state from the platform side.
|
||||
class IsEnabledMessage {
|
||||
bool? enabled;
|
||||
}
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartPackageName: 'wakelock_plus_platform_interface',
|
||||
objcHeaderOut:
|
||||
'ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h',
|
||||
objcSourceOut: 'ios/wakelock_plus/Sources/wakelock_plus/messages.g.m',
|
||||
objcOptions: ObjcOptions(
|
||||
prefix: 'WAKELOCKPLUS',
|
||||
headerIncludePath: './include/wakelock_plus/messages.g.h',
|
||||
),
|
||||
kotlinOptions: KotlinOptions(
|
||||
errorClassName: 'WakelockPlusFlutterError',
|
||||
),
|
||||
kotlinOut:
|
||||
'android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt',
|
||||
),
|
||||
)
|
||||
@HostApi(dartHostTestHandler: 'TestWakelockPlusApi')
|
||||
abstract class WakelockPlusApi {
|
||||
void toggle(ToggleMessage msg);
|
||||
|
||||
IsEnabledMessage isEnabled();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"upstream": {
|
||||
"repository": "https://github.com/fluttercommunity/wakelock_plus",
|
||||
"commit": "4f4be85aafe1f8216c2fdd3376263d6f40529684",
|
||||
"packageVersion": "1.5.2"
|
||||
},
|
||||
"generator": {
|
||||
"package": "pigeon",
|
||||
"version": "26.2.3",
|
||||
"archiveSha256": "7bf372b6a1639a09a13204afc6fc6aa3d3ac123988a6e90274c847c17fae9805",
|
||||
"command": "dart run pigeon --input pigeons/messages.dart"
|
||||
},
|
||||
"externalDartClient": {
|
||||
"package": "wakelock_plus_platform_interface",
|
||||
"version": "1.4.0",
|
||||
"archiveSha256": "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03",
|
||||
"dartPackageName": "wakelock_plus_platform_interface",
|
||||
"contract": "The Dart client is external and must not be generated into a sibling package. Refreshes must verify its two message layouts, tags 129/130, error envelopes, and toggle/isEnabled channel names against all host outputs."
|
||||
},
|
||||
"plezyDeltas": [
|
||||
"tvOS deployment target and CocoaPods target",
|
||||
"explicit unawaited wrappers required by the root lint policy",
|
||||
"serialized Linux portal lifecycle",
|
||||
"web requested/effective wake-lock state",
|
||||
"host-only Pigeon schema with an external Dart-client boundary"
|
||||
],
|
||||
"artifacts": {
|
||||
"pigeons/messages.dart": "a562715ba7ce115c6f6096fb0c70e591be24c53c127eb4974bdb05dc25f202d6",
|
||||
"android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt": "7dae2bb2ff5e0c0e5e5c4c3d39500bcfb803e0e34fcaf5ba0dae47db4bd17c67",
|
||||
"ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h": "c658260df4ecebbf28ce32022e17ef620104b0c4ee400684aa6af1af86400d0a",
|
||||
"ios/wakelock_plus/Sources/wakelock_plus/messages.g.m": "0f2f741586d2d1298f1b9ba4c19f02c3fde9bac91bec7c5a71cfc5bccf854d7d"
|
||||
},
|
||||
"refreshContract": [
|
||||
"Audit a new immutable upstream commit and update it together with the package version.",
|
||||
"Reapply every Plezy delta and keep the schema host-only; never add dartOut or dartTestOut.",
|
||||
"Update exact Pigeon and platform-interface versions and their nested lock checksums together.",
|
||||
"Run flutter pub get --enforce-lockfile, regenerate all three host outputs as one set, and update artifact hashes together.",
|
||||
"Run python3 ../../scripts/verify_runtime_inputs.py, compare the external Dart client contract, and smoke Android, iOS, and tvOS before landing."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "92.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.6"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
dbus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.14"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
mocktail:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: mocktail
|
||||
sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
pigeon:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: pigeon
|
||||
sha256: "7bf372b6a1639a09a13204afc6fc6aa3d3ac123988a6e90274c847c17fae9805"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "26.2.3"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.0"
|
||||
@@ -1,9 +1,14 @@
|
||||
# Vendored from fluttercommunity/wakelock_plus at
|
||||
# 4f4be85aafe1f8216c2fdd3376263d6f40529684. Local changes are the tvOS
|
||||
# deployment target, explicit unawaited wrappers required by the root lint
|
||||
# policy, serialized Linux portal lifecycle, and web requested/effective
|
||||
# wake-lock state. Refresh from the newest upstream release compatible with
|
||||
# win32 5.x, reapply all changes, then run pub get and the tvOS pod build.
|
||||
# Vendored from fluttercommunity/wakelock_plus at immutable commit
|
||||
# 4f4be85aafe1f8216c2fdd3376263d6f40529684. Plezy retains the tvOS
|
||||
# deployment target, root-lint unawaited wrappers, serialized Linux portal
|
||||
# lifecycle, web requested/effective wake-lock state, and a host-only Pigeon
|
||||
# schema. The Dart client remains owned by wakelock_plus_platform_interface
|
||||
# 1.4.0; never generate a sibling platform-interface package.
|
||||
#
|
||||
# Refresh atomically: audit a new upstream commit, reapply every delta above,
|
||||
# update provenance.json, run `flutter pub get --enforce-lockfile`, run
|
||||
# `dart run pigeon --input pigeons/messages.dart`, then run
|
||||
# `python3 ../../scripts/verify_runtime_inputs.py` and the platform smoke tests.
|
||||
name: wakelock_plus
|
||||
description: >-2
|
||||
Plugin that allows you to keep the device screen awake, i.e. prevent the screen from sleeping on
|
||||
@@ -21,7 +26,7 @@ dependencies:
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
meta: ^1.17.0
|
||||
wakelock_plus_platform_interface: ^1.4.0
|
||||
wakelock_plus_platform_interface: 1.4.0
|
||||
|
||||
# Windows dependencies
|
||||
# win32 is compatible across v5 for Win32 only (not COM)
|
||||
@@ -38,7 +43,8 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
pigeon: ^26.2.3 # dart run pigeon --input "pigeons/messages.dart"
|
||||
# Regenerate only the three host outputs declared by pigeons/messages.dart.
|
||||
pigeon: 26.2.3
|
||||
mocktail: ^1.0.4
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
|
||||
@@ -6,7 +6,10 @@ import re
|
||||
import sys
|
||||
|
||||
|
||||
WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/build.yml"
|
||||
DEFAULT_WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/build.yml"
|
||||
if len(sys.argv) > 2:
|
||||
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [workflow-path]")
|
||||
WORKFLOW = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_WORKFLOW
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
errors: list[str] = []
|
||||
|
||||
@@ -24,6 +27,60 @@ def job(name: str) -> str:
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def named_step(block: str, name: str) -> str:
|
||||
match = re.search(
|
||||
rf"(?ms)^ - name: {re.escape(name)}\n.*?(?=^ - |\Z)",
|
||||
block,
|
||||
)
|
||||
require(match is not None, f"missing '{name}' step")
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def validate_windows_signing(block: str) -> None:
|
||||
install = named_step(block, "Install dependencies")
|
||||
signing = named_step(block, "Sign installer for WinSparkle (EdDSA)")
|
||||
require(
|
||||
block.find(" - name: Install dependencies")
|
||||
< block.find(" - name: Sign installer for WinSparkle (EdDSA)"),
|
||||
"locked root dependencies must be installed before Windows signing",
|
||||
)
|
||||
require(
|
||||
"flutter pub get --enforce-lockfile --no-example" in install,
|
||||
"Windows signing must use the enforced root dependency lock",
|
||||
)
|
||||
require(
|
||||
"dart run auto_updater:sign_update plezy-windows-installer.exe $keyPath"
|
||||
in signing,
|
||||
"Windows signing must execute the locked auto_updater package",
|
||||
)
|
||||
require(
|
||||
"$env:RUNNER_TEMP" in signing,
|
||||
"Windows signing key must live under RUNNER_TEMP",
|
||||
)
|
||||
require(
|
||||
"try {" in signing and "} finally {" in signing,
|
||||
"Windows signing key cleanup must run from a finally block",
|
||||
)
|
||||
require(
|
||||
"Remove-Item -Path $keyPath -Force -ErrorAction SilentlyContinue"
|
||||
in signing,
|
||||
"Windows signing must remove its temporary key",
|
||||
)
|
||||
lowered = signing.lower()
|
||||
for forbidden in (
|
||||
"raw.githubusercontent.com",
|
||||
"invoke-webrequest",
|
||||
"git clone",
|
||||
"_signer",
|
||||
"pubspec.yaml",
|
||||
"dart pub get",
|
||||
):
|
||||
require(
|
||||
forbidden not in lowered,
|
||||
f"Windows signing step contains mutable or ad-hoc input: {forbidden}",
|
||||
)
|
||||
|
||||
|
||||
def require_explicit_shells(name: str, block: str, shell: str) -> None:
|
||||
steps = re.findall(r"(?ms)^ - .*?(?=^ - |\Z)", block)
|
||||
run_steps = [step for step in steps if re.search(r"(?m)^ run:", step)]
|
||||
@@ -147,8 +204,16 @@ require(
|
||||
"Linux build attestation permissions changed",
|
||||
)
|
||||
require_explicit_shells("build-linux", linux, "bash")
|
||||
libmpv_cache = named_step(linux, "Cache libmpv build")
|
||||
require(
|
||||
"hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json')"
|
||||
in libmpv_cache,
|
||||
"libmpv cache identity must include its build script and native input manifest",
|
||||
)
|
||||
|
||||
|
||||
package_windows = job("package-windows")
|
||||
validate_windows_signing(package_windows)
|
||||
require("needs: build-windows" in package_windows, "Windows packaging must fan in the matrix")
|
||||
for artifact in (
|
||||
"windows-x64-build",
|
||||
@@ -251,15 +316,6 @@ require(
|
||||
text.count("persist-credentials: false") == checkout_count,
|
||||
"every build checkout must discard GitHub credentials",
|
||||
)
|
||||
require(
|
||||
"raw.githubusercontent.com/edde746/auto_updater/9e150f71e17495b7361aedbe6df22e89ad52c254/"
|
||||
in text,
|
||||
"Windows signing helper must remain pinned to the locked auto_updater commit",
|
||||
)
|
||||
require(
|
||||
'dependencies=@{cryptography="2.9.0"}' in text,
|
||||
"Windows signing dependency must remain exact",
|
||||
)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
|
||||
Executable
+362
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce an exact, expiring baseline for the website's Bun audit results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
MAX_ACCEPTANCE_DAYS = 90
|
||||
MAX_AUDIT_OUTPUT_BYTES = 5 * 1024 * 1024
|
||||
MAX_DIAGNOSTICS = 20
|
||||
MAX_ID_LENGTH = 128
|
||||
MAX_PACKAGE_LENGTH = 214
|
||||
MAX_RANGE_LENGTH = 256
|
||||
MAX_RATIONALE_LENGTH = 500
|
||||
MAX_SCANNER_DIAGNOSTIC_LENGTH = 300
|
||||
SEVERITIES = frozenset({"low", "moderate", "high", "critical"})
|
||||
ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
||||
PACKAGE_NAME = re.compile(
|
||||
r"(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
EXPECTED_BUN_BANNER = re.compile(r"bun audit v1\.3\.14 \([0-9a-f]{8}\)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Advisory:
|
||||
advisory_id: str
|
||||
package: str
|
||||
severity: str
|
||||
vulnerable_range: str
|
||||
|
||||
@property
|
||||
def identity(self) -> tuple[str, str]:
|
||||
return (self.advisory_id, self.package)
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"{self.advisory_id} ({self.package})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Acceptance:
|
||||
advisory: Advisory
|
||||
expires_on: date
|
||||
rationale: str
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"cannot read valid JSON from {path}: {error}") from error
|
||||
|
||||
|
||||
def _parse_iso_date(value: Any, field: str) -> date:
|
||||
if not isinstance(value, str) or ISO_DATE.fullmatch(value) is None:
|
||||
raise ValueError(f"{field} must be an ISO date in YYYY-MM-DD form")
|
||||
try:
|
||||
parsed = date.fromisoformat(value)
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{field} must be an ISO date in YYYY-MM-DD form") from error
|
||||
if parsed.isoformat() != value:
|
||||
raise ValueError(f"{field} must be an ISO date in YYYY-MM-DD form")
|
||||
return parsed
|
||||
|
||||
def _bounded_string(
|
||||
value: Any,
|
||||
*,
|
||||
field: str,
|
||||
maximum: int,
|
||||
pattern: re.Pattern[str] | None = None,
|
||||
) -> str:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value
|
||||
or value != value.strip()
|
||||
or len(value) > maximum
|
||||
or any(ord(character) < 32 for character in value)
|
||||
or (pattern is not None and pattern.fullmatch(value) is None)
|
||||
):
|
||||
raise ValueError(f"{field} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def load_baseline(path: Path) -> tuple[date, dict[tuple[str, str], Acceptance]]:
|
||||
payload = _load_json(path)
|
||||
if not isinstance(payload, dict) or set(payload) != {
|
||||
"schemaVersion",
|
||||
"reviewedOn",
|
||||
"accepted",
|
||||
}:
|
||||
raise ValueError("baseline must contain schemaVersion, reviewedOn, and accepted")
|
||||
if payload["schemaVersion"] != 1:
|
||||
raise ValueError("unsupported baseline schemaVersion")
|
||||
reviewed_on = _parse_iso_date(payload["reviewedOn"], "reviewedOn")
|
||||
entries = payload["accepted"]
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError("baseline accepted must be a list")
|
||||
|
||||
accepted: dict[tuple[str, str], Acceptance] = {}
|
||||
required = {
|
||||
"id",
|
||||
"package",
|
||||
"severity",
|
||||
"vulnerableRange",
|
||||
"expiresOn",
|
||||
"rationale",
|
||||
}
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict) or set(entry) != required:
|
||||
raise ValueError(f"baseline accepted[{index}] has an unknown schema")
|
||||
if not isinstance(entry["id"], (str, int)) or isinstance(entry["id"], bool):
|
||||
raise ValueError(f"baseline accepted[{index}] has an invalid advisory id")
|
||||
advisory_id = _bounded_string(
|
||||
str(entry["id"]),
|
||||
field=f"baseline accepted[{index}].id",
|
||||
maximum=MAX_ID_LENGTH,
|
||||
pattern=re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]*"),
|
||||
)
|
||||
package = _bounded_string(
|
||||
entry["package"],
|
||||
field=f"baseline accepted[{index}].package",
|
||||
maximum=MAX_PACKAGE_LENGTH,
|
||||
pattern=PACKAGE_NAME,
|
||||
)
|
||||
severity = _bounded_string(
|
||||
entry["severity"],
|
||||
field=f"baseline accepted[{index}].severity",
|
||||
maximum=16,
|
||||
)
|
||||
if severity not in SEVERITIES:
|
||||
raise ValueError(f"baseline accepted[{index}].severity is invalid")
|
||||
vulnerable_range = _bounded_string(
|
||||
entry["vulnerableRange"],
|
||||
field=f"baseline accepted[{index}].vulnerableRange",
|
||||
maximum=MAX_RANGE_LENGTH,
|
||||
)
|
||||
rationale = _bounded_string(
|
||||
entry["rationale"],
|
||||
field=f"baseline accepted[{index}].rationale",
|
||||
maximum=MAX_RATIONALE_LENGTH,
|
||||
)
|
||||
advisory = Advisory(
|
||||
advisory_id=advisory_id,
|
||||
package=package,
|
||||
severity=severity,
|
||||
vulnerable_range=vulnerable_range,
|
||||
)
|
||||
expires_on = _parse_iso_date(entry["expiresOn"], f"accepted[{index}].expiresOn")
|
||||
if expires_on < reviewed_on:
|
||||
raise ValueError(f"baseline {advisory.label} expires before its review date")
|
||||
if expires_on > reviewed_on + timedelta(days=MAX_ACCEPTANCE_DAYS):
|
||||
raise ValueError(
|
||||
f"baseline {advisory.label} expires more than {MAX_ACCEPTANCE_DAYS} days after review"
|
||||
)
|
||||
if advisory.identity in accepted:
|
||||
raise ValueError(f"duplicate baseline advisory {advisory.label}")
|
||||
accepted[advisory.identity] = Acceptance(
|
||||
advisory=advisory,
|
||||
expires_on=expires_on,
|
||||
rationale=rationale,
|
||||
)
|
||||
return reviewed_on, accepted
|
||||
|
||||
|
||||
def parse_audit_json(output: str) -> dict[tuple[str, str], Advisory]:
|
||||
if len(output.encode("utf-8")) > MAX_AUDIT_OUTPUT_BYTES:
|
||||
raise ValueError("bun audit result exceeds the policy size limit")
|
||||
try:
|
||||
payload = json.loads(output)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"bun audit returned malformed JSON: {error.msg}") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("bun audit result must be a package object")
|
||||
|
||||
advisories: dict[tuple[str, str], Advisory] = {}
|
||||
for package_value, package_entries in payload.items():
|
||||
package = _bounded_string(
|
||||
package_value,
|
||||
field="bun audit package name",
|
||||
maximum=MAX_PACKAGE_LENGTH,
|
||||
pattern=PACKAGE_NAME,
|
||||
)
|
||||
if not isinstance(package_entries, list) or not package_entries:
|
||||
raise ValueError(f"bun audit entries for {package} must be a non-empty list")
|
||||
for index, entry in enumerate(package_entries):
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(f"bun audit entry {package}[{index}] must be an object")
|
||||
required = ("id", "severity", "vulnerable_versions")
|
||||
if any(field not in entry for field in required):
|
||||
raise ValueError(f"bun audit entry {package}[{index}] has an unknown schema")
|
||||
if not isinstance(entry["id"], (str, int)) or isinstance(entry["id"], bool):
|
||||
raise ValueError(f"bun audit entry {package}[{index}] has an invalid id")
|
||||
advisory_id = _bounded_string(
|
||||
str(entry["id"]),
|
||||
field=f"bun audit entry {package}[{index}].id",
|
||||
maximum=MAX_ID_LENGTH,
|
||||
pattern=re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]*"),
|
||||
)
|
||||
severity = _bounded_string(
|
||||
entry["severity"],
|
||||
field=f"bun audit entry {package}[{index}].severity",
|
||||
maximum=16,
|
||||
)
|
||||
if severity not in SEVERITIES:
|
||||
raise ValueError(f"bun audit entry {package}[{index}].severity is invalid")
|
||||
vulnerable_range = _bounded_string(
|
||||
entry["vulnerable_versions"],
|
||||
field=f"bun audit entry {package}[{index}].vulnerable_versions",
|
||||
maximum=MAX_RANGE_LENGTH,
|
||||
)
|
||||
advisory = Advisory(
|
||||
advisory_id=advisory_id,
|
||||
package=package,
|
||||
severity=severity,
|
||||
vulnerable_range=vulnerable_range,
|
||||
)
|
||||
if advisory.identity in advisories:
|
||||
raise ValueError(f"bun audit returned duplicate advisory {advisory.label}")
|
||||
advisories[advisory.identity] = advisory
|
||||
return advisories
|
||||
|
||||
|
||||
def _has_scanner_diagnostic(stderr: str) -> bool:
|
||||
rendered = ANSI_ESCAPE.sub("", stderr).strip()
|
||||
return bool(rendered) and EXPECTED_BUN_BANNER.fullmatch(rendered) is None
|
||||
|
||||
|
||||
def evaluate(
|
||||
*,
|
||||
exit_code: int,
|
||||
output: str,
|
||||
accepted: dict[tuple[str, str], Acceptance],
|
||||
today: date,
|
||||
stderr: str = "",
|
||||
) -> list[str]:
|
||||
if exit_code not in (0, 1):
|
||||
return [f"bun audit execution failed with exit code {exit_code}"]
|
||||
if _has_scanner_diagnostic(stderr):
|
||||
return ["bun audit reported a scanner or network diagnostic"]
|
||||
try:
|
||||
current = parse_audit_json(output)
|
||||
except (UnicodeError, ValueError) as error:
|
||||
return [str(error)]
|
||||
if exit_code == 0 and current:
|
||||
return ["bun audit exited cleanly but returned advisories"]
|
||||
if exit_code == 1 and not current:
|
||||
return ["bun audit exited with advisories but returned an empty result"]
|
||||
|
||||
errors = []
|
||||
for identity, advisory in sorted(current.items()):
|
||||
acceptance = accepted.get(identity)
|
||||
if acceptance is None:
|
||||
errors.append(f"unaccepted advisory {advisory.label}")
|
||||
continue
|
||||
if acceptance.expires_on < today:
|
||||
errors.append(
|
||||
f"expired acceptance {advisory.label} ({acceptance.expires_on.isoformat()})"
|
||||
)
|
||||
if acceptance.advisory.severity != advisory.severity:
|
||||
errors.append(
|
||||
f"severity changed for {advisory.label}: "
|
||||
f"{acceptance.advisory.severity} -> {advisory.severity}"
|
||||
)
|
||||
if acceptance.advisory.vulnerable_range != advisory.vulnerable_range:
|
||||
errors.append(
|
||||
f"vulnerable range changed for {advisory.label}: "
|
||||
f"{acceptance.advisory.vulnerable_range} -> {advisory.vulnerable_range}"
|
||||
)
|
||||
for identity, acceptance in sorted(accepted.items()):
|
||||
if identity not in current:
|
||||
errors.append(f"stale baseline advisory {acceptance.advisory.label}")
|
||||
if len(errors) > MAX_DIAGNOSTICS:
|
||||
omitted = len(errors) - MAX_DIAGNOSTICS
|
||||
return errors[:MAX_DIAGNOSTICS] + [
|
||||
f"{omitted} additional policy error(s) omitted"
|
||||
]
|
||||
return errors
|
||||
|
||||
|
||||
def _run_bun_audit(project: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["bun", "audit", "--json"],
|
||||
cwd=project,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def _scanner_diagnostic(value: str) -> str:
|
||||
compact = " ".join(value.split())
|
||||
if len(compact) > MAX_SCANNER_DIAGNOSTIC_LENGTH:
|
||||
return f"{compact[:MAX_SCANNER_DIAGNOSTIC_LENGTH]}..."
|
||||
return compact
|
||||
|
||||
|
||||
def main(
|
||||
argv: list[str] | None = None,
|
||||
*,
|
||||
run_audit: Callable[[Path], subprocess.CompletedProcess[str]] = _run_bun_audit,
|
||||
today: date | None = None,
|
||||
) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--project", type=Path, required=True)
|
||||
parser.add_argument("--baseline", type=Path, required=True)
|
||||
args = parser.parse_args(argv)
|
||||
evaluation_date = date.today() if today is None else today
|
||||
|
||||
project = args.project.resolve()
|
||||
baseline_path = args.baseline
|
||||
if not baseline_path.is_absolute():
|
||||
baseline_path = project / baseline_path
|
||||
if not (project / "bun.lock").is_file():
|
||||
print(f"ERROR: missing Bun lockfile in {project}", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
reviewed_on, accepted = load_baseline(baseline_path)
|
||||
if reviewed_on > evaluation_date:
|
||||
raise ValueError("baseline reviewedOn cannot be in the future")
|
||||
except ValueError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
result = run_audit(project)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
print(f"ERROR: cannot execute bun audit: {_scanner_diagnostic(str(error))}", file=sys.stderr)
|
||||
return 1
|
||||
errors = evaluate(
|
||||
exit_code=result.returncode,
|
||||
output=result.stdout,
|
||||
stderr=result.stderr,
|
||||
accepted=accepted,
|
||||
today=evaluation_date,
|
||||
)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
if result.stderr.strip():
|
||||
print(
|
||||
f"ERROR: bun audit diagnostic: {_scanner_diagnostic(result.stderr)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"Bun advisory policy passed ({len(accepted)} reviewed acceptance(s)).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -81,7 +81,9 @@ def _copy_dependency_state(source_root: Path, target_root: Path) -> None:
|
||||
|
||||
def _create_isolated_checkout(root: Path, destination: Path) -> None:
|
||||
_run(root, "git", "worktree", "add", "--detach", "--quiet", str(destination), "HEAD")
|
||||
changed = _nul_paths(_run(root, "git", "diff", "--name-only", "-z", "HEAD", "--").stdout)
|
||||
changed = _nul_paths(
|
||||
_run(root, "git", "diff", "--no-renames", "--name-only", "-z", "HEAD", "--").stdout
|
||||
)
|
||||
untracked = _nul_paths(_run(root, "git", "ls-files", "--others", "--exclude-standard", "-z").stdout)
|
||||
for relative in sorted(set(changed + untracked)):
|
||||
_copy_overlay_path(root, destination, relative)
|
||||
|
||||
Executable
+155
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Require immutable, architecture-declared production container images."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PRODUCTION_DOCKERFILES = (ROOT / "server" / "Dockerfile",)
|
||||
PRODUCTION_COMPOSE_FILES = (ROOT / "server" / "docker-compose.yml",)
|
||||
SUPPORTED_PLATFORMS = frozenset({"linux/amd64", "linux/arm64"})
|
||||
|
||||
FROM_RE = re.compile(
|
||||
r"^\s*FROM(?:\s+--platform=(?:\S+))?\s+(?P<reference>\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
IMAGE_RE = re.compile(r"^\s*image\s*:\s*(?P<reference>[^\s#]+)\s*(?:#.*)?$")
|
||||
PLATFORMS_RE = re.compile(r"^\s*#\s*Platforms\s*:\s*(?P<platforms>.+?)\s*$", re.IGNORECASE)
|
||||
PINNED_REFERENCE_RE = re.compile(
|
||||
r"^(?P<name>[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?"
|
||||
r"(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*)"
|
||||
r":(?P<tag>[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})"
|
||||
r"@sha256:(?P<digest>[0-9a-f]{64})$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageReference:
|
||||
path: Path
|
||||
line_number: int
|
||||
reference: str
|
||||
platforms: frozenset[str] | None
|
||||
|
||||
|
||||
def _adjacent_platforms(lines: list[str], reference_index: int) -> frozenset[str] | None:
|
||||
for index in range(reference_index - 1, -1, -1):
|
||||
stripped = lines[index].strip()
|
||||
if not stripped:
|
||||
break
|
||||
if not stripped.startswith("#"):
|
||||
break
|
||||
match = PLATFORMS_RE.match(lines[index])
|
||||
if match:
|
||||
values = (value.strip() for value in match.group("platforms").split(","))
|
||||
return frozenset(value for value in values if value)
|
||||
return None
|
||||
|
||||
|
||||
def iter_dockerfile_references(path: Path):
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
match = FROM_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
reference = match.group("reference")
|
||||
if reference.lower() == "scratch":
|
||||
continue
|
||||
yield ImageReference(
|
||||
path=path,
|
||||
line_number=index + 1,
|
||||
reference=reference,
|
||||
platforms=_adjacent_platforms(lines, index),
|
||||
)
|
||||
|
||||
|
||||
def iter_compose_references(path: Path):
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
match = IMAGE_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
yield ImageReference(
|
||||
path=path,
|
||||
line_number=index + 1,
|
||||
reference=match.group("reference").strip("'\""),
|
||||
platforms=_adjacent_platforms(lines, index),
|
||||
)
|
||||
|
||||
|
||||
def validate_image(image: ImageReference) -> list[str]:
|
||||
violations = []
|
||||
match = PINNED_REFERENCE_RE.fullmatch(image.reference)
|
||||
if not match:
|
||||
violations.append(
|
||||
"external images must use a readable tag and full lowercase sha256 digest"
|
||||
)
|
||||
elif match.group("tag").lower() == "latest":
|
||||
violations.append("the readable image tag must not be latest")
|
||||
|
||||
if image.platforms is None:
|
||||
violations.append(
|
||||
"an adjacent '# Platforms:' declaration is required for each external image"
|
||||
)
|
||||
elif image.platforms != SUPPORTED_PLATFORMS:
|
||||
expected = ", ".join(sorted(SUPPORTED_PLATFORMS))
|
||||
actual = ", ".join(sorted(image.platforms)) or "none"
|
||||
violations.append(f"platforms must be exactly {expected}; found {actual}")
|
||||
return violations
|
||||
|
||||
|
||||
def _display_path(path: Path) -> Path:
|
||||
try:
|
||||
return path.resolve().relative_to(ROOT)
|
||||
except ValueError:
|
||||
return path
|
||||
|
||||
|
||||
def check_paths(dockerfiles: list[Path], compose_files: list[Path]) -> list[str]:
|
||||
images = []
|
||||
for path in dockerfiles:
|
||||
images.extend(iter_dockerfile_references(path))
|
||||
for path in compose_files:
|
||||
images.extend(iter_compose_references(path))
|
||||
|
||||
violations = []
|
||||
for image in images:
|
||||
for reason in validate_image(image):
|
||||
violations.append(
|
||||
f"{_display_path(image.path)}:{image.line_number}: "
|
||||
f"{image.reference!r}: {reason}"
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if args:
|
||||
dockerfiles = [Path(value) for value in args if Path(value).name == "Dockerfile"]
|
||||
compose_files = [Path(value) for value in args if Path(value).name != "Dockerfile"]
|
||||
else:
|
||||
dockerfiles = list(PRODUCTION_DOCKERFILES)
|
||||
compose_files = list(PRODUCTION_COMPOSE_FILES)
|
||||
|
||||
violations = check_paths(dockerfiles, compose_files)
|
||||
if violations:
|
||||
print("Mutable or malformed production container references:", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" {violation}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
image_count = sum(
|
||||
1 for path in dockerfiles for _ in iter_dockerfile_references(path)
|
||||
) + sum(1 for path in compose_files for _ in iter_compose_references(path))
|
||||
print(
|
||||
f"Production container pins verified ({image_count} external images; "
|
||||
f"platforms: {', '.join(sorted(SUPPORTED_PLATFORMS))})."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -8,8 +8,9 @@ import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WORKFLOWS = ROOT / ".github" / "workflows"
|
||||
CI_WORKFLOW = Path(".github/workflows/ci.yml")
|
||||
FULL_SHA = re.compile(r"[0-9a-f]{40}")
|
||||
PR_TRIGGER = re.compile(r"(?m)^ pull_request:\s*$")
|
||||
USES_LINE = re.compile(r"^\s*(?:-\s+)?(?:uses|'uses'|\"uses\")\s*:\s*(.*?)\s*$")
|
||||
|
||||
|
||||
def _active_text(text: str) -> str:
|
||||
@@ -18,29 +19,129 @@ def _active_text(text: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _scalar(value: str) -> str:
|
||||
"""Remove an inline YAML comment and matching scalar quotes."""
|
||||
quote: str | None = None
|
||||
escaped = False
|
||||
end = len(value)
|
||||
for index, character in enumerate(value):
|
||||
if escaped:
|
||||
escaped = False
|
||||
continue
|
||||
if quote == '"' and character == "\\":
|
||||
escaped = True
|
||||
continue
|
||||
if character in ("'", '"'):
|
||||
if quote is None:
|
||||
quote = character
|
||||
elif quote == character:
|
||||
quote = None
|
||||
elif character == "#" and quote is None and (
|
||||
index == 0 or value[index - 1].isspace()
|
||||
):
|
||||
end = index
|
||||
break
|
||||
result = value[:end].strip()
|
||||
if len(result) >= 2 and result[0] == result[-1] and result[0] in ("'", '"'):
|
||||
return result[1:-1]
|
||||
return result
|
||||
|
||||
|
||||
def _has_trigger(text: str, event: str) -> bool:
|
||||
lines = text.splitlines()
|
||||
on_key = r"""(?:on|'on'|"on")"""
|
||||
event_key = rf"""(?:{re.escape(event)}|'{re.escape(event)}'|"{re.escape(event)}")"""
|
||||
for index, line in enumerate(lines):
|
||||
if re.fullmatch(rf"{on_key}:\s*", line):
|
||||
for child in lines[index + 1 :]:
|
||||
if child.strip() and not child.startswith((" ", "\t")):
|
||||
break
|
||||
if re.match(rf"^\s+{event_key}\s*:", child):
|
||||
return True
|
||||
match = re.fullmatch(rf"{on_key}:\s*(.+?)\s*", line)
|
||||
if match is not None and re.search(
|
||||
rf"""(?:^|[\[{{,\s])['"]?{re.escape(event)}['"]?(?:$|[\]}},\s:])""",
|
||||
_scalar(match.group(1)),
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _step_block(lines: list[str], line_index: int) -> str:
|
||||
uses_indent = len(lines[line_index]) - len(lines[line_index].lstrip())
|
||||
start = line_index
|
||||
for index in range(line_index, -1, -1):
|
||||
line = lines[index]
|
||||
indent = len(line) - len(line.lstrip())
|
||||
if re.match(r"^\s*-\s+", line) and indent <= uses_indent:
|
||||
start = index
|
||||
break
|
||||
if line.strip() and indent < uses_indent:
|
||||
break
|
||||
|
||||
start_indent = len(lines[start]) - len(lines[start].lstrip())
|
||||
end = len(lines)
|
||||
for index in range(start + 1, len(lines)):
|
||||
line = lines[index]
|
||||
indent = len(line) - len(line.lstrip())
|
||||
if re.match(r"^\s*-\s+", line) and indent <= start_indent:
|
||||
end = index
|
||||
break
|
||||
if line.strip() and not line.lstrip().startswith("#") and indent < start_indent:
|
||||
end = index
|
||||
break
|
||||
return "\n".join(lines[start:end])
|
||||
|
||||
|
||||
def _check_fail_open(path: Path, text: str) -> list[str]:
|
||||
"""CI quality gates must not silently convert failures to successes."""
|
||||
if path.as_posix() != CI_WORKFLOW.as_posix():
|
||||
return []
|
||||
|
||||
errors: list[str] = []
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
match = re.match(
|
||||
r"""^\s*(?:-\s+)?(?:continue-on-error|'continue-on-error'|"continue-on-error")\s*:\s*(.*?)\s*$""",
|
||||
line,
|
||||
)
|
||||
if match is not None and _scalar(match.group(1)).lower() != "false":
|
||||
errors.append(f"{path}:{line_number}: continue-on-error must remain false")
|
||||
if re.search(r"\|\|\s*true(?:\s|$)", line):
|
||||
errors.append(f"{path}:{line_number}: command must not suppress failure with || true")
|
||||
return errors
|
||||
|
||||
|
||||
def check_workflow(path: Path, text: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
active = _active_text(text)
|
||||
|
||||
for dangerous_trigger in ("pull_request_target:", "workflow_run:"):
|
||||
if dangerous_trigger in active:
|
||||
errors.append(f"{path}: unaudited privileged trigger {dangerous_trigger[:-1]}")
|
||||
for dangerous_trigger in ("pull_request_target", "workflow_run"):
|
||||
if _has_trigger(active, dangerous_trigger):
|
||||
errors.append(f"{path}: unaudited privileged trigger {dangerous_trigger}")
|
||||
|
||||
checkout_count = 0
|
||||
for line_number, line in enumerate(active.splitlines(), start=1):
|
||||
match = re.match(r"^\s*(?:-\s+)?uses:\s+(.+?)\s*$", line)
|
||||
pull_request = _has_trigger(active, "pull_request")
|
||||
lines = active.splitlines()
|
||||
for line_index, line in enumerate(lines):
|
||||
match = USES_LINE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
reference = match.group(1).split(" #", maxsplit=1)[0].strip()
|
||||
reference = _scalar(match.group(1))
|
||||
if reference.startswith("./"):
|
||||
continue
|
||||
action, separator, ref = reference.rpartition("@")
|
||||
if not separator or not action or FULL_SHA.fullmatch(ref) is None:
|
||||
errors.append(
|
||||
f"{path}:{line_number}: external action must use a full commit SHA: {reference}"
|
||||
f"{path}:{line_index + 1}: external action must use a full commit SHA: {reference}"
|
||||
)
|
||||
if action == "actions/checkout":
|
||||
checkout_count += 1
|
||||
if pull_request and action == "actions/checkout":
|
||||
step = _step_block(lines, line_index)
|
||||
if re.search(
|
||||
r"""(?mi)^\s+(?:persist-credentials|'persist-credentials'|"persist-credentials")\s*:\s*['"]?false['"]?\s*(?:#.*)?$""",
|
||||
step,
|
||||
) is None:
|
||||
errors.append(
|
||||
f"{path}:{line_index + 1}: pull-request checkout must discard GitHub credentials"
|
||||
)
|
||||
|
||||
if re.search(
|
||||
r"https://raw\.githubusercontent\.com/[^/\s]+/[^/\s]+/(?:main|master)/",
|
||||
@@ -48,22 +149,21 @@ def check_workflow(path: Path, text: str) -> list[str]:
|
||||
):
|
||||
errors.append(f"{path}: raw GitHub downloads must use an immutable commit")
|
||||
|
||||
if PR_TRIGGER.search(active):
|
||||
if "secrets." in active:
|
||||
if pull_request:
|
||||
if re.search(r"\bsecrets\s*(?:\.|\[)", active):
|
||||
errors.append(f"{path}: pull-request workflow must not reference repository secrets")
|
||||
if re.search(r"(?m)^\s+[a-zA-Z0-9_-]+:\s+write\s*$", active):
|
||||
if re.search(r"(?m)^\s+[a-zA-Z0-9_-]+:\s*write\s*(?:#.*)?$", active) or re.search(
|
||||
r"(?m)^\s*permissions:\s*\{[^}\n]*:\s*write(?:\s*[,}])", active
|
||||
):
|
||||
errors.append(f"{path}: pull-request workflow must not request write permissions")
|
||||
if active.count("persist-credentials: false") != checkout_count:
|
||||
errors.append(
|
||||
f"{path}: every pull-request checkout must discard GitHub credentials"
|
||||
)
|
||||
|
||||
errors.extend(_check_fail_open(path, active))
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
for path in sorted(WORKFLOWS.glob("*.yml")):
|
||||
for path in sorted((*WORKFLOWS.glob("*.yml"), *WORKFLOWS.glob("*.yaml"))):
|
||||
errors.extend(check_workflow(path.relative_to(ROOT), path.read_text(encoding="utf-8")))
|
||||
|
||||
if errors:
|
||||
|
||||
@@ -83,12 +83,18 @@ fi
|
||||
# 4. Workflow and script regression guards
|
||||
section "workflow and script guards"
|
||||
if python3 scripts/check_build_workflow.py &&
|
||||
python3 scripts/test_check_build_workflow.py &&
|
||||
python3 scripts/check_apple_spm_locks.py &&
|
||||
python3 scripts/test_check_apple_spm_locks.py &&
|
||||
python3 scripts/check_workflow_security.py &&
|
||||
python3 scripts/test_check_workflow_security.py &&
|
||||
python3 scripts/check_workflow_action_pins.py &&
|
||||
python3 scripts/test_check_workflow_action_pins.py &&
|
||||
python3 scripts/check_container_image_pins.py &&
|
||||
python3 scripts/test_check_container_image_pins.py &&
|
||||
python3 scripts/verify_runtime_inputs.py &&
|
||||
python3 scripts/test_verify_runtime_inputs.py &&
|
||||
python3 scripts/test_fetch_tvos_engine.py &&
|
||||
python3 scripts/test_check_codegen.py &&
|
||||
python3 scripts/test_generate_relay_protocol.py &&
|
||||
python3 scripts/test_format_native.py &&
|
||||
@@ -96,6 +102,8 @@ if python3 scripts/check_build_workflow.py &&
|
||||
python3 scripts/test_pubspec_version.py &&
|
||||
python3 scripts/test_clean_translations.py &&
|
||||
python3 scripts/test_run_maestro.py &&
|
||||
python3 scripts/test_maestro_flow_contracts.py &&
|
||||
python3 scripts/test_maestro_jellyfin_proxy.py &&
|
||||
python3 scripts/test_check_icon_consistency.py; then
|
||||
ok "workflow and script guards passed"
|
||||
else
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR/server"
|
||||
|
||||
go mod download
|
||||
go mod verify
|
||||
git diff --exit-code -- go.mod go.sum
|
||||
go vet -mod=readonly ./...
|
||||
go test -mod=readonly -race -count=1 ./...
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||
govulncheck ./...
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR/website"
|
||||
|
||||
bun install --frozen-lockfile
|
||||
bun test
|
||||
bun run check
|
||||
bun run build
|
||||
python3 "$ROOT_DIR/scripts/test_check_bun_audit.py"
|
||||
bun run audit
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Behavior tests for the privileged build-workflow guard."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CHECKER = ROOT / "scripts/check_build_workflow.py"
|
||||
WORKFLOW = ROOT / ".github/workflows/build.yml"
|
||||
|
||||
|
||||
class BuildWorkflowGuardTest(unittest.TestCase):
|
||||
def _run(self, workflow: str) -> subprocess.CompletedProcess[str]:
|
||||
with tempfile.TemporaryDirectory(prefix="plezy-build-workflow-test-") as directory:
|
||||
fixture = Path(directory) / "build.yml"
|
||||
fixture.write_text(workflow, encoding="utf-8")
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CHECKER), str(fixture)],
|
||||
cwd=ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def _workflow(self) -> str:
|
||||
return WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
def test_locked_root_signer_passes(self) -> None:
|
||||
result = self._run(self._workflow())
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("architecture matrix checks passed", result.stdout)
|
||||
|
||||
def test_mutable_download_in_signing_step_is_rejected(self) -> None:
|
||||
workflow = self._workflow().replace(
|
||||
" try {\n",
|
||||
" Invoke-WebRequest -Uri https://raw.githubusercontent.com/example/main/sign.dart -OutFile sign.dart\n"
|
||||
" try {\n",
|
||||
1,
|
||||
)
|
||||
|
||||
result = self._run(workflow)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("mutable or ad-hoc input: raw.githubusercontent.com", result.stderr)
|
||||
self.assertIn("mutable or ad-hoc input: invoke-webrequest", result.stderr)
|
||||
|
||||
def test_inline_dependency_resolution_in_signing_step_is_rejected(self) -> None:
|
||||
workflow = self._workflow().replace(
|
||||
" try {\n",
|
||||
" Set-Content -Path pubspec.yaml -Value 'dependencies: {}'\n"
|
||||
" dart pub get\n"
|
||||
" try {\n",
|
||||
1,
|
||||
)
|
||||
|
||||
result = self._run(workflow)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("mutable or ad-hoc input: pubspec.yaml", result.stderr)
|
||||
self.assertIn("mutable or ad-hoc input: dart pub get", result.stderr)
|
||||
|
||||
def test_downloaded_signer_execution_is_rejected(self) -> None:
|
||||
workflow = self._workflow().replace(
|
||||
"dart run auto_updater:sign_update plezy-windows-installer.exe $keyPath",
|
||||
"dart run sign.dart plezy-windows-installer.exe $keyPath",
|
||||
1,
|
||||
)
|
||||
|
||||
result = self._run(workflow)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("must execute the locked auto_updater package", result.stderr)
|
||||
|
||||
def test_unlocked_install_is_rejected(self) -> None:
|
||||
prefix, package_and_after = self._workflow().split(" package-windows:\n", 1)
|
||||
workflow = prefix + " package-windows:\n" + package_and_after.replace(
|
||||
"flutter pub get --enforce-lockfile --no-example",
|
||||
"flutter pub get",
|
||||
1,
|
||||
)
|
||||
|
||||
result = self._run(workflow)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("enforced root dependency lock", result.stderr)
|
||||
|
||||
def test_missing_finally_cleanup_is_rejected(self) -> None:
|
||||
workflow = self._workflow().replace(" } finally {\n", " }\n", 1)
|
||||
|
||||
result = self._run(workflow)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("cleanup must run from a finally block", result.stderr)
|
||||
|
||||
def test_libmpv_cache_without_native_manifest_is_rejected(self) -> None:
|
||||
workflow = self._workflow().replace(
|
||||
"hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json')",
|
||||
"hashFiles('linux/packaging/build-libmpv.sh')",
|
||||
1,
|
||||
)
|
||||
|
||||
result = self._run(workflow)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("native input manifest", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+415
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for the exact Bun advisory policy."""
|
||||
|
||||
import contextlib
|
||||
from datetime import date
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from check_bun_audit import Acceptance, Advisory, evaluate, load_baseline, main
|
||||
|
||||
|
||||
TODAY = date(2026, 7, 21)
|
||||
ADVISORY = Advisory("111", "fixture-package", "high", "<2.0.0")
|
||||
ACCEPTANCE = Acceptance(ADVISORY, date(2026, 8, 1), "Not reachable in static output.")
|
||||
|
||||
|
||||
def audit_json(
|
||||
*,
|
||||
advisory_id: int = 111,
|
||||
severity: str = "high",
|
||||
vulnerable_range: str = "<2.0.0",
|
||||
) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"fixture-package": [
|
||||
{
|
||||
"id": advisory_id,
|
||||
"severity": severity,
|
||||
"vulnerable_versions": vulnerable_range,
|
||||
"url": "https://example.invalid/advisory",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BunAuditPolicyTest(unittest.TestCase):
|
||||
def test_clean_audit_with_empty_baseline_passes(self) -> None:
|
||||
self.assertEqual(evaluate(exit_code=0, output="{}", accepted={}, today=TODAY), [])
|
||||
|
||||
def test_exact_reviewed_advisory_passes(self) -> None:
|
||||
self.assertEqual(
|
||||
evaluate(
|
||||
exit_code=1,
|
||||
output=audit_json(),
|
||||
accepted={ADVISORY.identity: ACCEPTANCE},
|
||||
today=TODAY,
|
||||
stderr="\x1b[1mbun audit \x1b[0m\x1b[2mv1.3.14 (d1632b29)\x1b[0m\n",
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_new_advisory_is_rejected(self) -> None:
|
||||
errors = evaluate(exit_code=1, output=audit_json(), accepted={}, today=TODAY)
|
||||
self.assertEqual(errors, ["unaccepted advisory 111 (fixture-package)"])
|
||||
|
||||
def test_changed_severity_and_range_are_rejected(self) -> None:
|
||||
errors = evaluate(
|
||||
exit_code=1,
|
||||
output=audit_json(severity="critical", vulnerable_range="<3.0.0"),
|
||||
accepted={ADVISORY.identity: ACCEPTANCE},
|
||||
today=TODAY,
|
||||
)
|
||||
self.assertTrue(any("severity changed" in error for error in errors))
|
||||
self.assertTrue(any("vulnerable range changed" in error for error in errors))
|
||||
|
||||
def test_expired_acceptance_is_rejected(self) -> None:
|
||||
expired = Acceptance(ADVISORY, date(2026, 7, 20), "Reviewed fixture debt.")
|
||||
errors = evaluate(
|
||||
exit_code=1,
|
||||
output=audit_json(),
|
||||
accepted={ADVISORY.identity: expired},
|
||||
today=TODAY,
|
||||
)
|
||||
self.assertEqual(errors, ["expired acceptance 111 (fixture-package) (2026-07-20)"])
|
||||
|
||||
def test_stale_baseline_entry_is_rejected(self) -> None:
|
||||
errors = evaluate(
|
||||
exit_code=0,
|
||||
output="{}",
|
||||
accepted={ADVISORY.identity: ACCEPTANCE},
|
||||
today=TODAY,
|
||||
)
|
||||
self.assertEqual(errors, ["stale baseline advisory 111 (fixture-package)"])
|
||||
|
||||
def test_duplicate_and_overlong_acceptances_are_rejected(self) -> None:
|
||||
entry = {
|
||||
"id": 111,
|
||||
"package": "fixture-package",
|
||||
"severity": "high",
|
||||
"vulnerableRange": "<2.0.0",
|
||||
"expiresOn": "2026-08-01",
|
||||
"rationale": "Not reachable in static output.",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
baseline = Path(directory) / "baseline.json"
|
||||
baseline.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [entry, entry],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "duplicate baseline advisory"):
|
||||
load_baseline(baseline)
|
||||
|
||||
entry["id"] = 112
|
||||
entry["expiresOn"] = "2026-11-01"
|
||||
baseline.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [entry],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "more than 90 days"):
|
||||
load_baseline(baseline)
|
||||
|
||||
entry["expiresOn"] = "2026-08-01"
|
||||
entry["unexpected"] = True
|
||||
baseline.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [entry],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "unknown schema"):
|
||||
load_baseline(baseline)
|
||||
|
||||
def test_malformed_json_and_schema_are_rejected(self) -> None:
|
||||
malformed = evaluate(exit_code=1, output="not-json", accepted={}, today=TODAY)
|
||||
self.assertTrue(any("malformed JSON" in error for error in malformed))
|
||||
|
||||
unknown = evaluate(
|
||||
exit_code=1,
|
||||
output=json.dumps({"fixture-package": [{"id": 111}]}),
|
||||
accepted={},
|
||||
today=TODAY,
|
||||
)
|
||||
self.assertTrue(any("unknown schema" in error for error in unknown))
|
||||
|
||||
empty_package = evaluate(
|
||||
exit_code=0,
|
||||
output=json.dumps({"fixture-package": []}),
|
||||
accepted={},
|
||||
today=TODAY,
|
||||
)
|
||||
self.assertTrue(any("non-empty list" in error for error in empty_package))
|
||||
|
||||
def test_scanner_failure_and_inconsistent_exit_are_rejected(self) -> None:
|
||||
self.assertEqual(
|
||||
evaluate(exit_code=2, output="", accepted={}, today=TODAY),
|
||||
["bun audit execution failed with exit code 2"],
|
||||
)
|
||||
self.assertEqual(
|
||||
evaluate(exit_code=1, output="{}", accepted={}, today=TODAY),
|
||||
["bun audit exited with advisories but returned an empty result"],
|
||||
)
|
||||
|
||||
def test_missing_lockfile_fails_before_scanner_execution(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Path(directory)
|
||||
baseline = project / "baseline.json"
|
||||
baseline.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
status = main(["--project", str(project), "--baseline", str(baseline)])
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("missing Bun lockfile", stderr.getvalue())
|
||||
|
||||
(project / "bun.lock").write_text("", encoding="utf-8")
|
||||
|
||||
def execution_failure(_: Path) -> subprocess.CompletedProcess[str]:
|
||||
raise OSError("registry unavailable")
|
||||
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
status = main(
|
||||
["--project", str(project), "--baseline", str(baseline)],
|
||||
run_audit=execution_failure,
|
||||
today=TODAY,
|
||||
)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("cannot execute bun audit", stderr.getvalue())
|
||||
|
||||
|
||||
def test_noncanonical_dates_and_invalid_acceptance_fields_are_rejected(self) -> None:
|
||||
entry = {
|
||||
"id": 111,
|
||||
"package": "*",
|
||||
"severity": "high",
|
||||
"vulnerableRange": "<2.0.0",
|
||||
"expiresOn": "20260801",
|
||||
"rationale": "Not reachable in static output.",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
baseline = Path(directory) / "baseline.json"
|
||||
baseline.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [entry],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "package is invalid"):
|
||||
load_baseline(baseline)
|
||||
|
||||
entry["package"] = "fixture-package"
|
||||
baseline.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [entry],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "YYYY-MM-DD"):
|
||||
load_baseline(baseline)
|
||||
|
||||
entry["expiresOn"] = "2026-07-20"
|
||||
baseline.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [entry],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "expires before"):
|
||||
load_baseline(baseline)
|
||||
|
||||
def test_duplicate_audit_advisory_and_unknown_severity_are_rejected(self) -> None:
|
||||
duplicate = json.dumps(
|
||||
{
|
||||
"fixture-package": [
|
||||
{
|
||||
"id": 111,
|
||||
"severity": "high",
|
||||
"vulnerable_versions": "<2.0.0",
|
||||
},
|
||||
{
|
||||
"id": 111,
|
||||
"severity": "high",
|
||||
"vulnerable_versions": "<2.0.0",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"duplicate advisory" in error
|
||||
for error in evaluate(
|
||||
exit_code=1, output=duplicate, accepted={}, today=TODAY
|
||||
)
|
||||
)
|
||||
)
|
||||
invalid_severity = audit_json(severity="unknown")
|
||||
self.assertTrue(
|
||||
any(
|
||||
"severity is invalid" in error
|
||||
for error in evaluate(
|
||||
exit_code=1,
|
||||
output=invalid_severity,
|
||||
accepted={},
|
||||
today=TODAY,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_network_diagnostic_fails_closed_without_echoing_payload(self) -> None:
|
||||
diagnostic = "registry timeout " + ("secret-response " * 100)
|
||||
self.assertEqual(
|
||||
evaluate(
|
||||
exit_code=1,
|
||||
output=audit_json(),
|
||||
stderr=diagnostic,
|
||||
accepted={ADVISORY.identity: ACCEPTANCE},
|
||||
today=TODAY,
|
||||
),
|
||||
["bun audit reported a scanner or network diagnostic"],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Path(directory)
|
||||
(project / "bun.lock").write_text("", encoding="utf-8")
|
||||
(project / "baseline.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
stderr = io.StringIO()
|
||||
result = subprocess.CompletedProcess(
|
||||
args=["bun", "audit", "--json"],
|
||||
returncode=2,
|
||||
stdout="",
|
||||
stderr=diagnostic,
|
||||
)
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
status = main(
|
||||
["--project", str(project), "--baseline", "baseline.json"],
|
||||
run_audit=lambda _: result,
|
||||
today=TODAY,
|
||||
)
|
||||
rendered = stderr.getvalue()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("execution failed with exit code 2", rendered)
|
||||
self.assertLess(len(rendered), 500)
|
||||
self.assertNotIn(diagnostic, rendered)
|
||||
|
||||
def test_main_uses_injected_audit_result_and_exact_baseline(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Path(directory)
|
||||
(project / "bun.lock").write_text("", encoding="utf-8")
|
||||
(project / "baseline.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [
|
||||
{
|
||||
"id": 111,
|
||||
"package": "fixture-package",
|
||||
"severity": "high",
|
||||
"vulnerableRange": "<2.0.0",
|
||||
"expiresOn": "2026-08-01",
|
||||
"rationale": "Not reachable in static output.",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
seen = []
|
||||
|
||||
def run_audit(path: Path) -> subprocess.CompletedProcess[str]:
|
||||
seen.append(path)
|
||||
return subprocess.CompletedProcess(
|
||||
args=["bun", "audit", "--json"],
|
||||
returncode=1,
|
||||
stdout=audit_json(),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
status = main(
|
||||
["--project", str(project), "--baseline", "baseline.json"],
|
||||
run_audit=run_audit,
|
||||
today=TODAY,
|
||||
)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(seen, [project.resolve()])
|
||||
self.assertIn("1 reviewed acceptance", stdout.getvalue())
|
||||
|
||||
def test_diagnostics_are_count_bounded(self) -> None:
|
||||
payload = {
|
||||
f"fixture-{index}": [
|
||||
{
|
||||
"id": index,
|
||||
"severity": "high",
|
||||
"vulnerable_versions": "<2.0.0",
|
||||
}
|
||||
]
|
||||
for index in range(30)
|
||||
}
|
||||
errors = evaluate(
|
||||
exit_code=1,
|
||||
output=json.dumps(payload),
|
||||
accepted={},
|
||||
today=TODAY,
|
||||
)
|
||||
self.assertEqual(len(errors), 21)
|
||||
self.assertEqual(errors[-1], "10 additional policy error(s) omitted")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -193,6 +193,25 @@ esac
|
||||
self.assertEqual(incorrect.read_text(encoding="utf-8"), "incorrect staged output\n")
|
||||
self.assertEqual(self.git_status(), status_before)
|
||||
|
||||
def test_rename_overlay_removes_source_before_running_generators(self) -> None:
|
||||
renamed = self.root / "renamed-source.txt"
|
||||
subprocess.run(["git", "mv", "source.txt", renamed.name], cwd=self.root, check=True)
|
||||
for command in (self.bin / "python3", self.bin / "dart"):
|
||||
contents = command.read_text(encoding="utf-8").replace(
|
||||
"source.txt", renamed.name
|
||||
)
|
||||
contents = contents.replace(
|
||||
"\n", "\nif [ -e source.txt ]; then exit 23; fi\n", 1
|
||||
)
|
||||
executable(command, contents)
|
||||
|
||||
result = self.run_codegen("--check")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((self.root / "source.txt").exists())
|
||||
self.assertEqual(renamed.read_text(encoding="utf-8"), "version one\n")
|
||||
self.assert_isolation_cleaned_up()
|
||||
|
||||
def test_deleted_and_untracked_outputs_are_reported_without_repair(self) -> None:
|
||||
deleted = self.root / "lib" / "models" / "model.g.dart"
|
||||
deleted.unlink()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import contextlib
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from check_container_image_pins import (
|
||||
ImageReference,
|
||||
SUPPORTED_PLATFORMS,
|
||||
check_paths,
|
||||
iter_compose_references,
|
||||
iter_dockerfile_references,
|
||||
main,
|
||||
validate_image,
|
||||
)
|
||||
|
||||
DIGEST = "0123456789abcdef" * 4
|
||||
PLATFORMS = frozenset({"linux/amd64", "linux/arm64"})
|
||||
|
||||
|
||||
class ContainerImagePinsTest(unittest.TestCase):
|
||||
def test_accepts_readable_digest_pins_for_supported_platforms(self) -> None:
|
||||
references = [
|
||||
f"golang:1.22.12-alpine3.21@sha256:{DIGEST}",
|
||||
f"ghcr.io/owner/image:sha-0123456@sha256:{DIGEST}",
|
||||
]
|
||||
for reference in references:
|
||||
with self.subTest(reference=reference):
|
||||
image = ImageReference(Path("fixture"), 1, reference, PLATFORMS)
|
||||
self.assertEqual(validate_image(image), [])
|
||||
|
||||
def test_rejects_mutable_or_malformed_external_images(self) -> None:
|
||||
references = [
|
||||
"golang:1.22-alpine",
|
||||
"golang:latest",
|
||||
"golang@sha256:" + DIGEST,
|
||||
"${BUILDER_IMAGE}",
|
||||
f"golang:1.22@sha256:{DIGEST.upper()}",
|
||||
"golang:1.22@sha256:0123456",
|
||||
]
|
||||
for reference in references:
|
||||
with self.subTest(reference=reference):
|
||||
image = ImageReference(Path("fixture"), 1, reference, PLATFORMS)
|
||||
self.assertTrue(validate_image(image))
|
||||
|
||||
latest = ImageReference(
|
||||
Path("fixture"),
|
||||
1,
|
||||
f"ghcr.io/owner/image:latest@sha256:{DIGEST}",
|
||||
PLATFORMS,
|
||||
)
|
||||
self.assertIn("must not be latest", " ".join(validate_image(latest)))
|
||||
|
||||
def test_requires_exact_supported_platform_declaration(self) -> None:
|
||||
reference = f"registry.example/image:v1@sha256:{DIGEST}"
|
||||
cases = [
|
||||
(None, "declaration is required"),
|
||||
(frozenset({"linux/amd64"}), "linux/arm64"),
|
||||
(
|
||||
frozenset({"linux/amd64", "linux/arm64", "linux/s390x"}),
|
||||
"linux/s390x",
|
||||
),
|
||||
]
|
||||
for platforms, expected in cases:
|
||||
with self.subTest(platforms=platforms):
|
||||
image = ImageReference(Path("fixture"), 1, reference, platforms)
|
||||
self.assertIn(expected, " ".join(validate_image(image)))
|
||||
|
||||
def test_parses_production_sources_and_ignores_scratch_and_comments(self) -> None:
|
||||
dockerfile = f'''\
|
||||
# FROM alpine:latest
|
||||
# Review update details.
|
||||
# Platforms: linux/amd64, linux/arm64
|
||||
FROM --platform=$BUILDPLATFORM golang:1.22.12-alpine3.21@sha256:{DIGEST} AS build
|
||||
FROM scratch
|
||||
'''
|
||||
compose = f'''\
|
||||
services:
|
||||
service:
|
||||
# image: alpine:latest
|
||||
# Review update details.
|
||||
# Platforms: linux/amd64, linux/arm64
|
||||
image: "ghcr.io/owner/service:sha-0123456@sha256:{DIGEST}"
|
||||
'''
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
docker_path = root / "Dockerfile"
|
||||
compose_path = root / "docker-compose.yml"
|
||||
docker_path.write_text(dockerfile, encoding="utf-8")
|
||||
compose_path.write_text(compose, encoding="utf-8")
|
||||
docker_images = list(iter_dockerfile_references(docker_path))
|
||||
compose_images = list(iter_compose_references(compose_path))
|
||||
|
||||
self.assertEqual(len(docker_images), 1)
|
||||
self.assertEqual(len(compose_images), 1)
|
||||
self.assertEqual(docker_images[0].platforms, SUPPORTED_PLATFORMS)
|
||||
self.assertEqual(compose_images[0].platforms, SUPPORTED_PLATFORMS)
|
||||
self.assertEqual(validate_image(docker_images[0]), [])
|
||||
self.assertEqual(validate_image(compose_images[0]), [])
|
||||
|
||||
def test_checker_reports_each_source_location(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
docker_path = root / "Dockerfile"
|
||||
compose_path = root / "docker-compose.yml"
|
||||
docker_path.write_text("FROM golang:1.22-alpine AS build\n", encoding="utf-8")
|
||||
compose_path.write_text(
|
||||
"services:\n bugs:\n image: ghcr.io/owner/bugs:latest\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
violations = check_paths([docker_path], [compose_path])
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
status = main([str(docker_path), str(compose_path)])
|
||||
|
||||
self.assertEqual(status, 1)
|
||||
self.assertGreaterEqual(len(violations), 4)
|
||||
output = stderr.getvalue()
|
||||
self.assertIn("Dockerfile:1", output)
|
||||
self.assertIn("docker-compose.yml:3", output)
|
||||
self.assertIn("golang:1.22-alpine", output)
|
||||
self.assertIn("ghcr.io/owner/bugs:latest", output)
|
||||
|
||||
def test_repository_production_references_pass(self) -> None:
|
||||
self.assertEqual(main([]), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,48 +7,87 @@ from check_workflow_security import check_workflow
|
||||
|
||||
|
||||
SAFE_SHA = "a" * 40
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CI_PATH = Path(".github/workflows/ci.yml")
|
||||
|
||||
|
||||
class WorkflowSecurityTests(unittest.TestCase):
|
||||
def check(self, text: str) -> list[str]:
|
||||
return check_workflow(Path(".github/workflows/test.yml"), text)
|
||||
def check(self, text: str, path: Path | None = None) -> list[str]:
|
||||
return check_workflow(path or Path(".github/workflows/test.yml"), text)
|
||||
|
||||
def test_accepts_read_only_pull_request_workflow_with_pinned_action(self) -> None:
|
||||
errors = self.check(
|
||||
f"""name: Test
|
||||
on:
|
||||
pull_request:
|
||||
pull_request: {{}}
|
||||
jobs:
|
||||
test:
|
||||
permissions:
|
||||
contents: read
|
||||
permissions: {{contents: read}}
|
||||
steps:
|
||||
- uses: actions/checkout@{SAFE_SHA} # v7
|
||||
- name: Checkout
|
||||
uses: "actions/checkout@{SAFE_SHA}" # reviewed pin
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: "false"
|
||||
"""
|
||||
)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_accepts_benign_ci_names_runners_matrices_and_commands(self) -> None:
|
||||
workflow = (ROOT / CI_PATH).read_text(encoding="utf-8")
|
||||
changed = (
|
||||
workflow.replace("name: CI - Sanity Checks", "name: Continuous integration")
|
||||
.replace(" analyze:\n", " static-analysis:\n", 1)
|
||||
.replace("name: Code Analysis", "name: Repository checks", 1)
|
||||
.replace("runs-on: ubuntu-latest", "runs-on: internal-linux", 1)
|
||||
.replace("- sanitizer: address", "- sanitizer: memory", 1)
|
||||
.replace("dart run scripts/check_analyzer.dart", "dart run tool/check.dart", 1)
|
||||
)
|
||||
|
||||
self.assertEqual(self.check(changed, CI_PATH), [])
|
||||
|
||||
def test_accepts_a_different_immutable_action_pin(self) -> None:
|
||||
workflow = f"jobs:\n test:\n steps:\n - uses: actions/setup-go@{'b' * 40}\n"
|
||||
self.assertEqual(self.check(workflow), [])
|
||||
|
||||
def test_rejects_mutable_action_reference(self) -> None:
|
||||
errors = self.check("jobs:\n test:\n steps:\n - uses: actions/checkout@v7\n")
|
||||
self.assertTrue(any("full commit SHA" in error for error in errors))
|
||||
|
||||
def test_rejects_missing_checkout_credential_guard_on_pull_requests(self) -> None:
|
||||
errors = self.check(
|
||||
f"""on: [pull_request]
|
||||
jobs:
|
||||
test:
|
||||
steps:
|
||||
- uses: actions/checkout@{SAFE_SHA}
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- run: echo 'persist-credentials: false elsewhere is not enough'
|
||||
"""
|
||||
)
|
||||
self.assertTrue(any("discard GitHub credentials" in error for error in errors))
|
||||
|
||||
def test_rejects_secrets_in_pull_request_workflow(self) -> None:
|
||||
errors = self.check(
|
||||
"on:\n pull_request:\njobs:\n test:\n env:\n TOKEN: ${{ secrets.TOKEN }}\n"
|
||||
)
|
||||
self.assertTrue(any("must not reference repository secrets" in error for error in errors))
|
||||
|
||||
def test_rejects_write_permission_in_pull_request_workflow(self) -> None:
|
||||
errors = self.check(
|
||||
def test_rejects_block_or_flow_write_permission_on_pull_requests(self) -> None:
|
||||
block_errors = self.check(
|
||||
"on:\n pull_request:\njobs:\n test:\n permissions:\n contents: write\n"
|
||||
)
|
||||
self.assertTrue(any("must not request write permissions" in error for error in errors))
|
||||
flow_errors = self.check(
|
||||
"on: {pull_request: {}}\npermissions: {contents: write}\n"
|
||||
)
|
||||
self.assertTrue(any("must not request write permissions" in error for error in block_errors))
|
||||
self.assertTrue(any("must not request write permissions" in error for error in flow_errors))
|
||||
|
||||
def test_rejects_privileged_untrusted_trigger(self) -> None:
|
||||
errors = self.check("on:\n pull_request_target:\n")
|
||||
self.assertTrue(any("unaudited privileged trigger" in error for error in errors))
|
||||
def test_rejects_privileged_untrusted_triggers(self) -> None:
|
||||
target_errors = self.check("on:\n pull_request_target:\n")
|
||||
run_errors = self.check("on: [push, workflow_run]\n")
|
||||
self.assertTrue(any("pull_request_target" in error for error in target_errors))
|
||||
self.assertTrue(any("workflow_run" in error for error in run_errors))
|
||||
|
||||
def test_rejects_mutable_raw_github_download(self) -> None:
|
||||
errors = self.check(
|
||||
@@ -56,6 +95,37 @@ jobs:
|
||||
)
|
||||
self.assertTrue(any("immutable commit" in error for error in errors))
|
||||
|
||||
def test_rejects_ci_fail_open_constructs(self) -> None:
|
||||
continued = self.check(
|
||||
"jobs:\n test:\n steps:\n - continue-on-error: ${{ github.event_name == 'push' }}\n run: ./check\n",
|
||||
CI_PATH,
|
||||
)
|
||||
suppressed = self.check(
|
||||
"jobs:\n test:\n steps:\n - run: ./check || true\n",
|
||||
CI_PATH,
|
||||
)
|
||||
explicit_false = self.check(
|
||||
"jobs:\n test:\n steps:\n - continue-on-error: false\n run: ./check\n",
|
||||
CI_PATH,
|
||||
)
|
||||
self.assertTrue(any("continue-on-error" in error for error in continued))
|
||||
self.assertTrue(any("suppress failure" in error for error in suppressed))
|
||||
self.assertEqual(explicit_false, [])
|
||||
|
||||
def test_comments_do_not_create_security_findings(self) -> None:
|
||||
errors = self.check(
|
||||
"""on:
|
||||
push:
|
||||
# pull_request_target:
|
||||
jobs:
|
||||
test:
|
||||
steps:
|
||||
# uses: actions/checkout@main
|
||||
- run: echo safe
|
||||
"""
|
||||
)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for verified tvOS engine provisioning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FETCH_ENGINE = ROOT / "tvos/scripts/fetch_engine.sh"
|
||||
|
||||
|
||||
class FetchTvosEngineTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="plezy-tvos-engine-test-")
|
||||
self.root = Path(self.temporary.name)
|
||||
self.tvos = self.root / "tvos"
|
||||
(self.tvos / "scripts").mkdir(parents=True)
|
||||
shutil.copy2(FETCH_ENGINE, self.tvos / "scripts/fetch_engine.sh")
|
||||
(self.tvos / "engine.version").write_text("fixture-1\n", encoding="utf-8")
|
||||
(self.root / "pubspec.yaml").write_text("version: 1.2.3+4\n", encoding="utf-8")
|
||||
self.cache = self.root / "cache"
|
||||
self.archive = self.root / "engine.tar.gz"
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
curl = self.bin / "curl"
|
||||
curl.write_text(
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
output=
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
-o) output="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
cp "$FIXTURE_ARCHIVE" "$output"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
curl.chmod(curl.stat().st_mode | stat.S_IXUSR)
|
||||
self.env = os.environ | {
|
||||
"PATH": f"{self.bin}:{os.environ['PATH']}",
|
||||
"FIXTURE_ARCHIVE": str(self.archive),
|
||||
"FLUTTER_ROOT": str(self.root / "flutter"),
|
||||
"FLUTTER_TVOS_ENGINE_CACHE": str(self.cache),
|
||||
"FLUTTER_TVOS_RELEASES_URL": "https://example.invalid/flutter-tvos",
|
||||
}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def _write_archive(self, marker: str) -> str:
|
||||
source = self.root / "marker.txt"
|
||||
source.write_text(marker, encoding="utf-8")
|
||||
with tarfile.open(self.archive, "w:gz") as archive:
|
||||
archive.add(source, arcname="out/tvos_debug_sim_unopt_arm64/marker.txt")
|
||||
return hashlib.sha256(self.archive.read_bytes()).hexdigest()
|
||||
|
||||
def _run(self) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["bash", "tvos/scripts/fetch_engine.sh"],
|
||||
cwd=self.root,
|
||||
env=self.env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def test_verified_archive_installs_and_reuses_matching_cache(self) -> None:
|
||||
digest = self._write_archive("reviewed engine")
|
||||
(self.tvos / "engine.sha256").write_text(f"{digest}\n", encoding="utf-8")
|
||||
|
||||
first = self._run()
|
||||
|
||||
self.assertEqual(first.returncode, 0, first.stderr)
|
||||
engine = self.cache / "vfixture-1"
|
||||
self.assertEqual(
|
||||
(engine / "out/tvos_debug_sim_unopt_arm64/marker.txt").read_text(encoding="utf-8"),
|
||||
"reviewed engine",
|
||||
)
|
||||
self.archive.unlink()
|
||||
|
||||
second = self._run()
|
||||
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
self.assertIn("using verified cached engine", second.stdout)
|
||||
|
||||
def test_checksum_mismatch_leaves_no_partial_engine(self) -> None:
|
||||
self._write_archive("unreviewed engine")
|
||||
(self.tvos / "engine.sha256").write_text(f"{'0' * 64}\n", encoding="utf-8")
|
||||
|
||||
result = self._run()
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.cache / "vfixture-1").exists())
|
||||
self.assertEqual(list(self.cache.glob(".engine.*")), [])
|
||||
|
||||
def test_checksum_change_replaces_same_version_without_stale_files(self) -> None:
|
||||
first_digest = self._write_archive("first engine")
|
||||
checksum = self.tvos / "engine.sha256"
|
||||
checksum.write_text(f"{first_digest}\n", encoding="utf-8")
|
||||
self.assertEqual(self._run().returncode, 0)
|
||||
stale = self.cache / "vfixture-1/stale-from-previous-archive"
|
||||
stale.write_text("stale", encoding="utf-8")
|
||||
|
||||
second_digest = self._write_archive("second engine")
|
||||
checksum.write_text(f"{second_digest}\n", encoding="utf-8")
|
||||
|
||||
result = self._run()
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
marker = self.cache / "vfixture-1/out/tvos_debug_sim_unopt_arm64/marker.txt"
|
||||
self.assertEqual(marker.read_text(encoding="utf-8"), "second engine")
|
||||
self.assertFalse(stale.exists())
|
||||
self.assertEqual(
|
||||
(self.cache / "vfixture-1/.installed").read_text(encoding="utf-8").strip(),
|
||||
f"fixture-1 {second_digest}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).with_name("verify_runtime_inputs.py")
|
||||
SPEC = importlib.util.spec_from_file_location("verify_runtime_inputs", SCRIPT)
|
||||
CHECKER = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(CHECKER)
|
||||
REPOSITORY = Path(__file__).resolve().parents[1]
|
||||
|
||||
FIXTURES = (
|
||||
"pubspec.lock",
|
||||
"linux/CMakeLists.txt",
|
||||
"linux/packaging/build-libmpv.sh",
|
||||
"linux/packaging/native-inputs.json",
|
||||
"packages/wakelock_plus/pubspec.yaml",
|
||||
"packages/wakelock_plus/pubspec.lock",
|
||||
"packages/wakelock_plus/provenance.json",
|
||||
"packages/wakelock_plus/pigeons/messages.dart",
|
||||
"packages/wakelock_plus/android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt",
|
||||
"packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h",
|
||||
"packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/messages.g.m",
|
||||
)
|
||||
|
||||
|
||||
class RuntimeInputVerifierTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
for relative in FIXTURES:
|
||||
source = REPOSITORY / relative
|
||||
destination = self.root / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def _json(self, relative: str) -> dict:
|
||||
return json.loads((self.root / relative).read_text(encoding="utf-8"))
|
||||
|
||||
def _write_json(self, relative: str, payload: dict) -> None:
|
||||
(self.root / relative).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
def test_reviewed_inputs_pass_library_and_offline_cli(self) -> None:
|
||||
self.assertEqual([], CHECKER.validate(self.root))
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--root", str(self.root)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(0, completed.returncode, completed.stdout + completed.stderr)
|
||||
self.assertIn("verified offline", completed.stdout)
|
||||
|
||||
def test_rejects_linux_cmake_checksum_drift(self) -> None:
|
||||
path = self.root / "linux/CMakeLists.txt"
|
||||
path.write_text(path.read_text(encoding="utf-8").replace("9fe4d6f5", "0fe4d6f5"), encoding="utf-8")
|
||||
|
||||
errors = CHECKER.validate(self.root)
|
||||
|
||||
self.assertTrue(any("simdutf SHA-256 differs" in error for error in errors))
|
||||
|
||||
def test_rejects_malformed_native_pin_and_version_url_drift(self) -> None:
|
||||
manifest = self._json("linux/packaging/native-inputs.json")
|
||||
manifest["inputs"]["ffmpeg"]["sha256"] = "not-a-digest"
|
||||
manifest["inputs"]["mpv"]["url"] = "https://example.invalid/mpv-current.tar.gz"
|
||||
self._write_json("linux/packaging/native-inputs.json", manifest)
|
||||
|
||||
errors = CHECKER.validate(self.root)
|
||||
|
||||
self.assertTrue(any("ffmpeg.sha256" in error for error in errors))
|
||||
self.assertTrue(any("mpv.url" in error and "declared version" in error for error in errors))
|
||||
|
||||
def test_reports_missing_simdutf_fields_without_crashing(self) -> None:
|
||||
manifest = self._json("linux/packaging/native-inputs.json")
|
||||
simdutf = manifest["inputs"]["simdutf"]
|
||||
simdutf.pop("url")
|
||||
simdutf.pop("sha256")
|
||||
self._write_json("linux/packaging/native-inputs.json", manifest)
|
||||
|
||||
errors = CHECKER.validate(self.root)
|
||||
|
||||
self.assertTrue(any("simdutf.url" in error and "non-empty text" in error for error in errors))
|
||||
self.assertTrue(any("simdutf.sha256" in error and "lowercase full SHA-256" in error for error in errors))
|
||||
|
||||
def test_rejects_disconnected_production_acquisition(self) -> None:
|
||||
path = self.root / "linux/packaging/build-libmpv.sh"
|
||||
path.write_text(
|
||||
path.read_text(encoding="utf-8").replace(
|
||||
'download_verified "$MPV_URL" "$MPV_SHA256"',
|
||||
'curl "$MPV_URL"',
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
errors = CHECKER.validate(self.root)
|
||||
|
||||
self.assertTrue(any("MPV_URL" in error and "manifest-backed" in error for error in errors))
|
||||
|
||||
def test_rejects_binding_source_or_output_drift(self) -> None:
|
||||
schema = self.root / "packages/wakelock_plus/pigeons/messages.dart"
|
||||
schema.write_text(schema.read_text(encoding="utf-8") + "// changed\n", encoding="utf-8")
|
||||
kotlin = self.root / (
|
||||
"packages/wakelock_plus/android/src/main/kotlin/"
|
||||
"dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt"
|
||||
)
|
||||
kotlin.write_bytes(kotlin.read_bytes() + b"\n")
|
||||
|
||||
errors = CHECKER.validate(self.root)
|
||||
|
||||
self.assertGreaterEqual(sum("SHA-256 drift" in error for error in errors), 2)
|
||||
|
||||
def test_rejects_generator_and_external_client_lock_drift(self) -> None:
|
||||
pubspec = self.root / "packages/wakelock_plus/pubspec.yaml"
|
||||
pubspec.write_text(pubspec.read_text(encoding="utf-8").replace("pigeon: 26.2.3", "pigeon: ^26.2.3"), encoding="utf-8")
|
||||
lock = self.root / "packages/wakelock_plus/pubspec.lock"
|
||||
lock.write_text(
|
||||
lock.read_text(encoding="utf-8").replace(
|
||||
"24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03",
|
||||
"04b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
root_lock = self.root / "pubspec.lock"
|
||||
root_lock.write_text(
|
||||
root_lock.read_text(encoding="utf-8").replace(
|
||||
"24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03",
|
||||
"14b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
errors = CHECKER.validate(self.root)
|
||||
|
||||
self.assertTrue(any("Pigeon must be pinned exactly" in error for error in errors))
|
||||
self.assertTrue(any("platform-interface version/checksum differs" in error for error in errors))
|
||||
self.assertTrue(any("runtime platform-interface" in error for error in errors))
|
||||
|
||||
def test_missing_binding_reports_error_without_discarding_earlier_errors(self) -> None:
|
||||
manifest = self._json("linux/packaging/native-inputs.json")
|
||||
manifest["inputs"]["simdutf"].pop("url")
|
||||
self._write_json("linux/packaging/native-inputs.json", manifest)
|
||||
kotlin = self.root / (
|
||||
"packages/wakelock_plus/android/src/main/kotlin/"
|
||||
"dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt"
|
||||
)
|
||||
kotlin.unlink()
|
||||
|
||||
errors = CHECKER.validate(self.root)
|
||||
|
||||
self.assertTrue(any("simdutf.url" in error for error in errors))
|
||||
self.assertTrue(any(str(kotlin) in error and "cannot read generated binding" in error for error in errors))
|
||||
|
||||
def test_accepts_benign_prose_contract_edits(self) -> None:
|
||||
native = self._json("linux/packaging/native-inputs.json")
|
||||
native["refreshContract"] = {"rules": ["Reworded maintainer guidance."]}
|
||||
native["inputs"]["ffmpeg"]["provenance"] = "Reviewed release evidence."
|
||||
self._write_json("linux/packaging/native-inputs.json", native)
|
||||
|
||||
provenance = self._json("packages/wakelock_plus/provenance.json")
|
||||
provenance["plezyDeltas"] = ["Reworded local-change notes."]
|
||||
provenance["refreshContract"] = ["Reworded refresh guidance."]
|
||||
provenance["externalDartClient"]["contract"] = "Reworded client guidance."
|
||||
self._write_json("packages/wakelock_plus/provenance.json", provenance)
|
||||
|
||||
self.assertEqual([], CHECKER.validate(self.root))
|
||||
|
||||
def test_rejects_dart_output_from_host_only_schema(self) -> None:
|
||||
schema = self.root / "packages/wakelock_plus/pigeons/messages.dart"
|
||||
schema.write_text(
|
||||
schema.read_text(encoding="utf-8").replace(
|
||||
"PigeonOptions(",
|
||||
"PigeonOptions(\n dartOut: '../other/lib/messages.g.dart',",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
errors = CHECKER.validate(self.root)
|
||||
|
||||
self.assertTrue(any("must not generate Dart outputs" in error for error in errors))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+256
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline verification for reviewed Linux native and vendored binding inputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HEX_256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
HEX_COMMIT = re.compile(r"^[0-9a-f]{40}$")
|
||||
NATIVE_NAMES = {"ffmpeg", "shaderc", "libplacebo", "mpv", "simdutf"}
|
||||
BINDING_ARTIFACTS = {
|
||||
"pigeons/messages.dart",
|
||||
"android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt",
|
||||
"ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h",
|
||||
"ios/wakelock_plus/Sources/wakelock_plus/messages.g.m",
|
||||
}
|
||||
|
||||
|
||||
def _load_json(path: Path, errors: list[str]) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
errors.append(f"{path}: cannot load JSON: {error}")
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"{path}: top-level value must be an object")
|
||||
return {}
|
||||
return value
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _require_text(value: Any, label: str, errors: list[str]) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
errors.append(f"{label}: must be non-empty text")
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def _locked_package(lock_text: str, name: str) -> tuple[str, str] | None:
|
||||
pattern = re.compile(
|
||||
rf"^ {re.escape(name)}:\n(?P<body>(?: .*\n| .*\n)+?)(?=^ [a-zA-Z0-9_]+:|\Z)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
match = pattern.search(lock_text)
|
||||
if match is None:
|
||||
return None
|
||||
body = match.group("body")
|
||||
version = re.search(r'^ version: "([^\"]+)"$', body, re.MULTILINE)
|
||||
checksum = re.search(r'^ sha256: "?([0-9a-f]{64})"?$', body, re.MULTILINE)
|
||||
if version is None or checksum is None:
|
||||
return None
|
||||
return version.group(1), checksum.group(1)
|
||||
|
||||
|
||||
def _validate_native(root: Path, errors: list[str]) -> None:
|
||||
manifest_path = root / "linux/packaging/native-inputs.json"
|
||||
manifest = _load_json(manifest_path, errors)
|
||||
if manifest.get("formatVersion") != 1:
|
||||
errors.append(f"{manifest_path}: formatVersion must be 1")
|
||||
|
||||
|
||||
inputs = manifest.get("inputs")
|
||||
if not isinstance(inputs, dict) or set(inputs) != NATIVE_NAMES:
|
||||
errors.append(f"{manifest_path}: inputs must be exactly {sorted(NATIVE_NAMES)}")
|
||||
return
|
||||
|
||||
for name, value in inputs.items():
|
||||
label = f"{manifest_path}: inputs.{name}"
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"{label}: must be an object")
|
||||
continue
|
||||
kind = value.get("kind")
|
||||
version = _require_text(value.get("version"), f"{label}.version", errors)
|
||||
url = _require_text(value.get("url"), f"{label}.url", errors)
|
||||
_require_text(value.get("provenance"), f"{label}.provenance", errors)
|
||||
if url and not url.startswith("https://"):
|
||||
errors.append(f"{label}.url: production source must use HTTPS")
|
||||
if version and url and name in {"ffmpeg", "mpv", "simdutf"} and version not in url:
|
||||
errors.append(f"{label}.url: must identify declared version {version}")
|
||||
if kind == "archive":
|
||||
checksum = value.get("sha256")
|
||||
if not isinstance(checksum, str) or HEX_256.fullmatch(checksum) is None:
|
||||
errors.append(f"{label}.sha256: must be a lowercase full SHA-256")
|
||||
elif kind == "git":
|
||||
ref = value.get("ref")
|
||||
commit = value.get("commit")
|
||||
if not isinstance(ref, str) or ref != f"v{version}":
|
||||
errors.append(f"{label}.ref: must be v{version}")
|
||||
if not isinstance(commit, str) or HEX_COMMIT.fullmatch(commit) is None:
|
||||
errors.append(f"{label}.commit: must be a lowercase full Git commit")
|
||||
else:
|
||||
errors.append(f"{label}.kind: must be archive or git")
|
||||
|
||||
cmake_path = root / "linux/CMakeLists.txt"
|
||||
try:
|
||||
cmake = cmake_path.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
errors.append(f"{cmake_path}: cannot read: {error}")
|
||||
cmake = ""
|
||||
simdutf = inputs.get("simdutf")
|
||||
if isinstance(simdutf, dict):
|
||||
simdutf_url = simdutf.get("url")
|
||||
simdutf_sha256 = simdutf.get("sha256")
|
||||
if isinstance(simdutf_url, str) and simdutf_url and f"URL {simdutf_url}" not in cmake:
|
||||
errors.append(f"{cmake_path}: simdutf URL differs from native-inputs.json")
|
||||
if (
|
||||
isinstance(simdutf_sha256, str)
|
||||
and HEX_256.fullmatch(simdutf_sha256) is not None
|
||||
and f"URL_HASH SHA256={simdutf_sha256}" not in cmake
|
||||
):
|
||||
errors.append(f"{cmake_path}: simdutf SHA-256 differs from native-inputs.json")
|
||||
|
||||
builder_path = root / "linux/packaging/build-libmpv.sh"
|
||||
try:
|
||||
builder = builder_path.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
errors.append(f"{builder_path}: cannot read: {error}")
|
||||
return
|
||||
required_builder_contracts = (
|
||||
"native-inputs.json",
|
||||
'download_verified "$FFMPEG_URL" "$FFMPEG_SHA256"',
|
||||
'download_verified "$MPV_URL" "$MPV_SHA256"',
|
||||
'"$SHADERC_URL" "$SHADERC_REF" "$SHADERC_COMMIT"',
|
||||
'"$LIBPLACEBO_URL" "$LIBPLACEBO_REF" "$LIBPLACEBO_COMMIT"',
|
||||
'git submodule update --init --recursive',
|
||||
)
|
||||
for contract_text in required_builder_contracts:
|
||||
if contract_text not in builder:
|
||||
errors.append(f"{builder_path}: missing manifest-backed acquisition contract {contract_text!r}")
|
||||
if re.search(r"curl[^\n]*\|[^\n]*tar", builder):
|
||||
errors.append(f"{builder_path}: archive extraction must not consume a curl stream")
|
||||
|
||||
|
||||
def _validate_wakelock(root: Path, errors: list[str]) -> None:
|
||||
package = root / "packages/wakelock_plus"
|
||||
provenance_path = package / "provenance.json"
|
||||
provenance = _load_json(provenance_path, errors)
|
||||
if provenance.get("formatVersion") != 1:
|
||||
errors.append(f"{provenance_path}: formatVersion must be 1")
|
||||
|
||||
upstream = provenance.get("upstream")
|
||||
if not isinstance(upstream, dict) or HEX_COMMIT.fullmatch(str(upstream.get("commit", ""))) is None:
|
||||
errors.append(f"{provenance_path}: upstream.commit must be a full Git commit")
|
||||
|
||||
|
||||
artifacts = provenance.get("artifacts")
|
||||
if not isinstance(artifacts, dict) or set(artifacts) != BINDING_ARTIFACTS:
|
||||
errors.append(f"{provenance_path}: artifacts must be exactly the schema and three host outputs")
|
||||
else:
|
||||
for relative, expected in artifacts.items():
|
||||
path = package / relative
|
||||
if not isinstance(expected, str) or HEX_256.fullmatch(expected) is None:
|
||||
errors.append(f"{provenance_path}: invalid artifact SHA-256 for {relative}")
|
||||
elif not path.is_file():
|
||||
errors.append(f"{path}: required binding artifact is missing")
|
||||
else:
|
||||
actual = _sha256(path)
|
||||
if actual != expected:
|
||||
errors.append(f"{path}: SHA-256 drift (expected {expected}, got {actual})")
|
||||
|
||||
try:
|
||||
pubspec = (package / "pubspec.yaml").read_text(encoding="utf-8")
|
||||
lock = (package / "pubspec.lock").read_text(encoding="utf-8")
|
||||
schema = (package / "pigeons/messages.dart").read_text(encoding="utf-8")
|
||||
root_lock = (root / "pubspec.lock").read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
errors.append(f"{package}: cannot read package provenance input: {error}")
|
||||
return
|
||||
|
||||
generator = provenance.get("generator") if isinstance(provenance.get("generator"), dict) else {}
|
||||
client = provenance.get("externalDartClient") if isinstance(provenance.get("externalDartClient"), dict) else {}
|
||||
expected_pigeon = (str(generator.get("version", "")), str(generator.get("archiveSha256", "")))
|
||||
expected_client = (str(client.get("version", "")), str(client.get("archiveSha256", "")))
|
||||
if not re.search(rf"^ pigeon: {re.escape(expected_pigeon[0])}$", pubspec, re.MULTILINE):
|
||||
errors.append(f"{package / 'pubspec.yaml'}: Pigeon must be pinned exactly to {expected_pigeon[0]}")
|
||||
if not re.search(
|
||||
rf"^ wakelock_plus_platform_interface: {re.escape(expected_client[0])}$", pubspec, re.MULTILINE
|
||||
):
|
||||
errors.append(
|
||||
f"{package / 'pubspec.yaml'}: wakelock_plus_platform_interface must be pinned exactly to {expected_client[0]}"
|
||||
)
|
||||
if _locked_package(lock, "pigeon") != expected_pigeon:
|
||||
errors.append(f"{package / 'pubspec.lock'}: Pigeon version/checksum differs from provenance.json")
|
||||
if _locked_package(lock, "wakelock_plus_platform_interface") != expected_client:
|
||||
errors.append(
|
||||
f"{package / 'pubspec.lock'}: platform-interface version/checksum differs from provenance.json"
|
||||
)
|
||||
if _locked_package(root_lock, "wakelock_plus_platform_interface") != expected_client:
|
||||
errors.append(
|
||||
f"{root / 'pubspec.lock'}: runtime platform-interface version/checksum differs from provenance.json"
|
||||
)
|
||||
|
||||
if "dartPackageName: 'wakelock_plus_platform_interface'" not in schema:
|
||||
errors.append(f"{package / 'pigeons/messages.dart'}: external Dart package name is not explicit")
|
||||
if re.search(r"\bdart(?:Test)?Out\s*:", schema):
|
||||
errors.append(f"{package / 'pigeons/messages.dart'}: host-only schema must not generate Dart outputs")
|
||||
for relative in BINDING_ARTIFACTS - {"pigeons/messages.dart"}:
|
||||
if relative not in schema:
|
||||
errors.append(f"{package / 'pigeons/messages.dart'}: missing owned output {relative}")
|
||||
|
||||
binding_sources = (
|
||||
("Kotlin", package / "android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt"),
|
||||
("Objective-C", package / "ios/wakelock_plus/Sources/wakelock_plus/messages.g.m"),
|
||||
)
|
||||
for generated_name, generated_path in binding_sources:
|
||||
try:
|
||||
generated = generated_path.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
errors.append(f"{generated_path}: cannot read generated binding: {error}")
|
||||
continue
|
||||
if "26.2.3" not in generated:
|
||||
errors.append(f"{generated_name} binding was not generated by Pigeon 26.2.3")
|
||||
for method in ("WakelockPlusApi.toggle", "WakelockPlusApi.isEnabled"):
|
||||
if method not in generated:
|
||||
errors.append(f"{generated_name} binding is missing channel suffix {method}")
|
||||
for tag in ("129", "130"):
|
||||
if tag not in generated:
|
||||
errors.append(f"{generated_name} binding is missing codec tag {tag}")
|
||||
|
||||
|
||||
def validate(root: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
_validate_native(root, errors)
|
||||
_validate_wakelock(root, errors)
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
arguments = parser.parse_args()
|
||||
errors = validate(arguments.root.resolve())
|
||||
if errors:
|
||||
print("Runtime input provenance verification failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
return 1
|
||||
print("Runtime input provenance verified offline")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
FROM golang:1.22-alpine AS build
|
||||
# Update the readable tag, index digest, and platforms together using the controlled process in CONTRIBUTING.md.
|
||||
# Platforms: linux/amd64, linux/arm64
|
||||
FROM golang:1.22.12-alpine3.21@sha256:1699c10032ca2582ec89a24a1312d986a3f094aed3d5c1147b19880afe40e052 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
- "127.0.0.1:8080:8080"
|
||||
environment:
|
||||
OAUTH_BASE_URL: https://ice.plezy.app
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:?Set TRUSTED_PROXY_CIDRS to the reverse proxy network CIDR}
|
||||
MAL_CLIENT_ID: ${MAL_CLIENT_ID:-}
|
||||
ANILIST_CLIENT_ID: ${ANILIST_CLIENT_ID:-}
|
||||
ANILIST_CLIENT_SECRET: ${ANILIST_CLIENT_SECRET:-}
|
||||
@@ -21,7 +21,9 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
bugs:
|
||||
image: ghcr.io/edde746/bugs:latest
|
||||
# Update the source-revision tag, index digest, and platforms together using CONTRIBUTING.md.
|
||||
# Platforms: linux/amd64, linux/arm64
|
||||
image: ghcr.io/edde746/bugs:sha-319e0eb@sha256:1e5a2d8ab80e703de4a8a8b15d858ce931609e2226bf1e6d04979c0ca52a3005
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
volumes:
|
||||
|
||||
@@ -48,6 +48,18 @@ void main() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('live shelf tap acknowledges only an attached consumer', () async {
|
||||
final service = SystemShelfService.forTesting(channel: channel);
|
||||
const call = MethodCall('onShelfItemTap', {'contentId': 'server:item'});
|
||||
|
||||
expect(await service.handleMethodCallForTesting(call), isFalse);
|
||||
|
||||
final received = <String>[];
|
||||
service.onShelfItemTap = received.add;
|
||||
expect(await service.handleMethodCallForTesting(call), isTrue);
|
||||
expect(received, ['server:item']);
|
||||
});
|
||||
|
||||
test('delayed support result is dropped after synchronous owner invalidation', () async {
|
||||
final support = Completer<bool>();
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/tvos_system_navigation_service.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const channel = BasicMessageChannel<Object?>('flutter/tvos_system_navigation', JSONMessageCodec());
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
setUp(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
TvosSystemNavigationService.resetForTesting();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
messenger.setMockDecodedMessageHandler<Object?>(channel, null);
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
TvosSystemNavigationService.resetForTesting();
|
||||
});
|
||||
|
||||
test('false and null acknowledgements remain retryable until true', () async {
|
||||
final messages = <Object?>[];
|
||||
final replies = <Object?>[false, null, true];
|
||||
messenger.setMockDecodedMessageHandler<Object?>(channel, (message) async {
|
||||
messages.add(message);
|
||||
return replies.removeAt(0);
|
||||
});
|
||||
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(true);
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(true);
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(true);
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(true);
|
||||
|
||||
expect(messages, [
|
||||
{'menuPassthroughEnabled': true},
|
||||
{'menuPassthroughEnabled': true},
|
||||
{'menuPassthroughEnabled': true},
|
||||
]);
|
||||
});
|
||||
|
||||
test('serializes an opposing trailing value and latest desired state wins', () async {
|
||||
final messages = <Object?>[];
|
||||
var activeSends = 0;
|
||||
var maximumActiveSends = 0;
|
||||
final trueStarted = Completer<void>();
|
||||
final releaseTrue = Completer<Object?>();
|
||||
|
||||
messenger.setMockDecodedMessageHandler<Object?>(channel, (message) async {
|
||||
messages.add(message);
|
||||
activeSends++;
|
||||
maximumActiveSends = activeSends > maximumActiveSends ? activeSends : maximumActiveSends;
|
||||
try {
|
||||
final enabled = (message as Map<Object?, Object?>)['menuPassthroughEnabled'];
|
||||
if (enabled == true) {
|
||||
trueStarted.complete();
|
||||
return await releaseTrue.future;
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
activeSends--;
|
||||
}
|
||||
});
|
||||
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(false);
|
||||
final enable = TvosSystemNavigationService.setMenuPassthroughEnabled(true);
|
||||
await trueStarted.future;
|
||||
final disable = TvosSystemNavigationService.setMenuPassthroughEnabled(false);
|
||||
releaseTrue.complete(true);
|
||||
await Future.wait([enable, disable]);
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(false);
|
||||
|
||||
expect(maximumActiveSends, 1);
|
||||
expect(messages, [
|
||||
{'menuPassthroughEnabled': false},
|
||||
{'menuPassthroughEnabled': true},
|
||||
{'menuPassthroughEnabled': false},
|
||||
]);
|
||||
});
|
||||
|
||||
test('platform exception does not poison an identical retry', () async {
|
||||
final messages = <Object?>[];
|
||||
var fail = true;
|
||||
messenger.setMockDecodedMessageHandler<Object?>(channel, (message) async {
|
||||
messages.add(message);
|
||||
if (fail) {
|
||||
fail = false;
|
||||
throw PlatformException(code: 'controller_unavailable');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(true);
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(true);
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(true);
|
||||
|
||||
expect(messages, [
|
||||
{'menuPassthroughEnabled': true},
|
||||
{'menuPassthroughEnabled': true},
|
||||
]);
|
||||
});
|
||||
|
||||
test('non-tvOS calls never reach the platform channel', () async {
|
||||
final messages = <Object?>[];
|
||||
messenger.setMockDecodedMessageHandler<Object?>(channel, (message) async {
|
||||
messages.add(message);
|
||||
return true;
|
||||
});
|
||||
TvDetectionService.debugSetAppleTVOverride(false);
|
||||
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(true);
|
||||
await TvosSystemNavigationService.setMenuPassthroughEnabled(false);
|
||||
|
||||
expect(messages, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -13,30 +13,39 @@
|
||||
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
|
||||
7A11A7C50113007825B00A01 /* AtmosProbePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */; };
|
||||
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
|
||||
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */; };
|
||||
B1D51A6A2F00110000000016 /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */; };
|
||||
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7A11A7C50113007825B00A01 /* AtmosProbePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */; };
|
||||
81325C1CD13794375A81AC02 /* messages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CD411CCD84E381C4BF4C1B /* messages.g.swift */; };
|
||||
81F08E404EBEB19B00FF148D /* ConnectivityPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */; };
|
||||
89CF031971F407719E4B5DD8 /* TvosEventDeliveryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */; };
|
||||
8E5EED3DDAC9455D4DAA9776 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
A7596F780E93AF0BE3838A26 /* ConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */; };
|
||||
AA34E3E5872B3792D6959EAA /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2635E12EB9322B151EE5127 /* MpvPipController.swift */; };
|
||||
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */; };
|
||||
B1D51A6A2F00110000000016 /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */; };
|
||||
BDD87DA7F5C435F9DBB8F9BE /* TopShelfExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 035F0D5A5E54BE7AD9AFA23C /* TopShelfExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C6A15611158B29B0FF43A960 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 824D2C68D1F1206932E118C2 /* Pods_Runner.framework */; };
|
||||
CD1C0534948840272E58248E /* PathMonitorConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AEF79DFBD1D80F00AB39CDA /* PathMonitorConnectivityProvider.swift */; };
|
||||
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */; };
|
||||
D2A548D9DE1A0F319B30B74C /* SystemShelfPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0760C00EDC55A4D718BCB406 /* SystemShelfPlugin.swift */; };
|
||||
EA0F4264E7B912702C490109 /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA0F4263E7B912702C490108 /* MPVKit */; };
|
||||
E3DEAD2AAFC347A2E55AC0F7 /* SharedPreferencesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */; };
|
||||
E79A4474D308631AFA59CAE7 /* DeviceInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */; };
|
||||
EA0F4264E7B912702C490109 /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA0F4263E7B912702C490108 /* MPVKit */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
312B21E3C26DC9F7E1AC1F8C /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
3E4E59C5E29F44BA64BEB559 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
@@ -75,18 +84,20 @@
|
||||
04DD35536DEE7C27FFA53862 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
0760C00EDC55A4D718BCB406 /* SystemShelfPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SystemShelfPlugin.swift; sourceTree = "<group>"; };
|
||||
0C25F4F2367A30B47E945AD6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
1B17916D7270A141E3AC7B5D /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = System/Library/Frameworks/TVServices.framework; sourceTree = SDKROOT; };
|
||||
34CD411CCD84E381C4BF4C1B /* messages.g.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = messages.g.swift; sourceTree = "<group>"; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = "<group>"; };
|
||||
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = "<group>"; };
|
||||
66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = "<group>"; };
|
||||
67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPlugin.swift; sourceTree = "<group>"; };
|
||||
6AEF79DFBD1D80F00AB39CDA /* PathMonitorConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathMonitorConnectivityProvider.swift; sourceTree = "<group>"; };
|
||||
73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = "<source_root>"; };
|
||||
7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AtmosProbePlugin.swift; path = ../shared/apple/AtmosProbe/AtmosProbePlugin.swift; sourceTree = "<source_root>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AtmosProbePlugin.swift; path = ../shared/apple/AtmosProbe/AtmosProbePlugin.swift; sourceTree = "<source_root>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
824D2C68D1F1206932E118C2 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9165AF55B967D8845D042FE7 /* TopShelfExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = TopShelfExtension.entitlements; sourceTree = "<group>"; };
|
||||
@@ -101,14 +112,15 @@
|
||||
9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerPlugin.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerPlugin.swift; sourceTree = "<source_root>"; };
|
||||
A12B8610AE5D580077264851 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCore.swift; path = ../ios/Runner/MpvPlayer/MpvPlayerCore.swift; sourceTree = "<source_root>"; };
|
||||
A2635E12EB9322B151EE5127 /* MpvPipController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPipController.swift; path = ../ios/Runner/MpvPlayer/MpvPipController.swift; sourceTree = "<source_root>"; };
|
||||
B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = "<source_root>"; };
|
||||
B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = "<source_root>"; };
|
||||
BBCB49C8AE9E90DEF97A87CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = "<group>"; };
|
||||
D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = "<group>"; };
|
||||
B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = "<source_root>"; };
|
||||
B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = "<source_root>"; };
|
||||
D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = "<source_root>"; };
|
||||
F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = "<group>"; };
|
||||
F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = "<group>"; };
|
||||
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -120,6 +132,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5E27C9DB84FD0E86AD0F4664 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -188,6 +207,7 @@
|
||||
children = (
|
||||
824D2C68D1F1206932E118C2 /* Pods_Runner.framework */,
|
||||
25B7925EFD8A2C1C7EB667D5 /* TVServices.framework */,
|
||||
6D9369A1A01A73D85CAEC2A6 /* tvOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -201,6 +221,13 @@
|
||||
path = device_info_plus;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6D9369A1A01A73D85CAEC2A6 /* tvOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = tvOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -221,6 +248,7 @@
|
||||
53CDD29681161166962B9FA5 /* Pods */,
|
||||
5833EC5B503BBB4E370BA1B7 /* Frameworks */,
|
||||
47C2D3F50A23DE33DE92F592 /* TopShelfExtension */,
|
||||
F9BDF6E09E3B9347861D2A50 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -229,6 +257,7 @@
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
035F0D5A5E54BE7AD9AFA23C /* TopShelfExtension.appex */,
|
||||
1B17916D7270A141E3AC7B5D /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -246,6 +275,7 @@
|
||||
D58B788B11920D95BC7D750C /* Plugins */,
|
||||
0760C00EDC55A4D718BCB406 /* SystemShelfPlugin.swift */,
|
||||
937C0D45D6114EF1E957F5F6 /* Runner.entitlements */,
|
||||
66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -288,6 +318,15 @@
|
||||
path = path_provider;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -336,6 +375,24 @@
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
B03B26B6860DAA4B4861ABE8 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E0F018DBFAD5E036F8B3DED3 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
877FBDFAE7193C414BFC7581 /* Sources */,
|
||||
5E27C9DB84FD0E86AD0F4664 /* Frameworks */,
|
||||
BA52311F6E3823811234FBB7 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B904E5D4A048ADFF0B26C3B3 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 1B17916D7270A141E3AC7B5D /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -369,6 +426,7 @@
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
63C63C245D7F38BFF091CEC3 /* TopShelfExtension */,
|
||||
B03B26B6860DAA4B4861ABE8 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -392,6 +450,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BA52311F6E3823811234FBB7 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
@@ -448,20 +513,6 @@
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
E2B5A6D75C9F4D1B9E8C7A63 /* Sync Version */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Sync Version";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/bash \"$SOURCE_ROOT/scripts/xcode_appletv.sh\" sync_version\n";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -476,9 +527,31 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "#/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n/bin/bash \"$SOURCE_ROOT/scripts/xcode_appletv.sh\" build\n";
|
||||
};
|
||||
E2B5A6D75C9F4D1B9E8C7A63 /* Sync Version */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Sync Version";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/bash \"$SOURCE_ROOT/scripts/xcode_appletv.sh\" sync_version\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
877FBDFAE7193C414BFC7581 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -501,6 +574,7 @@
|
||||
A7596F780E93AF0BE3838A26 /* ConnectivityProvider.swift in Sources */,
|
||||
CD1C0534948840272E58248E /* PathMonitorConnectivityProvider.swift in Sources */,
|
||||
D2A548D9DE1A0F319B30B74C /* SystemShelfPlugin.swift in Sources */,
|
||||
89CF031971F407719E4B5DD8 /* TvosEventDeliveryCoordinator.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -515,6 +589,12 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
B904E5D4A048ADFF0B26C3B3 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = Runner;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 312B21E3C26DC9F7E1AC1F8C /* PBXContainerItemProxy */;
|
||||
};
|
||||
F0399FC35D3A6ED67E74810D /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = TopShelfExtension;
|
||||
@@ -692,6 +772,22 @@
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
4F937E75F619E4ABE22B1F17 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
||||
SDKROOT = appletvos;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner";
|
||||
TVOS_DEPLOYMENT_TARGET = 14.0;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
65F6020FE0DF48006BADE49C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */;
|
||||
@@ -907,6 +1003,37 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
AE855FD4D5DF4B132727705D /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
||||
SDKROOT = appletvos;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner";
|
||||
TVOS_DEPLOYMENT_TARGET = 14.0;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
CD5AF092D8A44891EB23CECC /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy.RunnerTests;
|
||||
SDKROOT = appletvos;
|
||||
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner";
|
||||
TVOS_DEPLOYMENT_TARGET = 14.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -940,6 +1067,16 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
E0F018DBFAD5E036F8B3DED3 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
4F937E75F619E4ABE22B1F17 /* Release */,
|
||||
CD5AF092D8A44891EB23CECC /* Debug */,
|
||||
AE855FD4D5DF4B132727705D /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -51,6 +51,16 @@
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B03B26B6860DAA4B4861ABE8"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
/// Keeps tvOS launcher events single-shot without suppressing a deliberate
|
||||
/// second tap forever. All access is confined to the main thread by the host.
|
||||
final class TvosDeepLinkDeliveryCoordinator {
|
||||
struct Event: Equatable {
|
||||
let contentId: String
|
||||
let receivedAt: TimeInterval
|
||||
var flutterArguments: [String: String] { ["contentId": contentId] }
|
||||
fileprivate let sequence: UInt64
|
||||
}
|
||||
|
||||
enum Receipt: Equatable {
|
||||
case retained
|
||||
case duplicate
|
||||
case deliver(Event)
|
||||
}
|
||||
|
||||
private let deduplicationWindow: TimeInterval
|
||||
private let now: () -> TimeInterval
|
||||
private var sequence: UInt64 = 0
|
||||
private var liveDeliveryEnabled = false
|
||||
private var pending: Event?
|
||||
private var inFlight: Event?
|
||||
private var lastDelivered: Event?
|
||||
|
||||
init(
|
||||
deduplicationWindow: TimeInterval = 2,
|
||||
now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
|
||||
) {
|
||||
precondition(deduplicationWindow >= 0)
|
||||
self.deduplicationWindow = deduplicationWindow
|
||||
self.now = now
|
||||
}
|
||||
|
||||
/// A replacement Flutter engine must perform its initial read before live
|
||||
/// delivery resumes. A launch event captured before registration is retained.
|
||||
func bindEngine() {
|
||||
liveDeliveryEnabled = false
|
||||
inFlight = nil
|
||||
}
|
||||
|
||||
func receive(contentId: String) -> Receipt {
|
||||
let timestamp = now()
|
||||
|
||||
if let inFlight, isDuplicate(contentId: contentId, at: timestamp, of: inFlight) {
|
||||
return .duplicate
|
||||
}
|
||||
if let pending, isDuplicate(contentId: contentId, at: timestamp, of: pending) {
|
||||
// A retained failed delivery is retryable when no send is active.
|
||||
return liveDeliveryEnabled && inFlight == nil ? beginDelivery(pending) : .duplicate
|
||||
}
|
||||
if let lastDelivered, isDuplicate(contentId: contentId, at: timestamp, of: lastDelivered) {
|
||||
return .duplicate
|
||||
}
|
||||
|
||||
sequence &+= 1
|
||||
let event = Event(contentId: contentId, receivedAt: timestamp, sequence: sequence)
|
||||
pending = event
|
||||
guard liveDeliveryEnabled, inFlight == nil else { return .retained }
|
||||
return beginDelivery(event)
|
||||
}
|
||||
|
||||
/// Atomically transitions an engine from retained launch delivery to live
|
||||
/// delivery and returns the one retained event, if any.
|
||||
func beginLiveDelivery() -> Event? {
|
||||
liveDeliveryEnabled = true
|
||||
guard inFlight == nil, let pending else { return nil }
|
||||
inFlight = pending
|
||||
return pending
|
||||
}
|
||||
|
||||
/// Clears a retained event only after Flutter reports successful delivery.
|
||||
/// A newer retained event is returned so callers can deliver it next.
|
||||
func complete(_ event: Event, succeeded: Bool) -> Event? {
|
||||
guard inFlight == event else { return nil }
|
||||
inFlight = nil
|
||||
guard succeeded else { return nil }
|
||||
|
||||
lastDelivered = event
|
||||
if pending == event {
|
||||
pending = nil
|
||||
}
|
||||
guard liveDeliveryEnabled, let pending else { return nil }
|
||||
return beginDelivery(pending).event
|
||||
}
|
||||
|
||||
var retainedContentId: String? { pending?.contentId }
|
||||
|
||||
private func beginDelivery(_ event: Event) -> Receipt {
|
||||
inFlight = event
|
||||
return .deliver(event)
|
||||
}
|
||||
|
||||
private func isDuplicate(contentId: String, at timestamp: TimeInterval, of event: Event) -> Bool {
|
||||
contentId == event.contentId && timestamp - event.receivedAt <= deduplicationWindow
|
||||
}
|
||||
}
|
||||
|
||||
private extension TvosDeepLinkDeliveryCoordinator.Receipt {
|
||||
var event: TvosDeepLinkDeliveryCoordinator.Event? {
|
||||
if case let .deliver(event) = self { return event }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import XCTest
|
||||
@testable import Runner
|
||||
|
||||
final class TvosEventDeliveryCoordinatorTests: XCTestCase {
|
||||
func testColdLaunchTransitionsToLiveWithoutChangingFlutterEnvelope() {
|
||||
var now: TimeInterval = 10
|
||||
let coordinator = TvosDeepLinkDeliveryCoordinator(now: { now })
|
||||
|
||||
XCTAssertEqual(coordinator.receive(contentId: "server:item"), .retained)
|
||||
let launchEvent = try! XCTUnwrap(coordinator.beginLiveDelivery())
|
||||
XCTAssertEqual(launchEvent.contentId, "server:item")
|
||||
XCTAssertEqual(launchEvent.flutterArguments, ["contentId": "server:item"])
|
||||
XCTAssertNil(coordinator.complete(launchEvent, succeeded: true))
|
||||
XCTAssertNil(coordinator.retainedContentId)
|
||||
|
||||
now += 0.5
|
||||
XCTAssertEqual(coordinator.receive(contentId: "server:item"), .duplicate)
|
||||
}
|
||||
|
||||
func testFailedLiveDeliveryRemainsRetainedAndRetryable() {
|
||||
var now: TimeInterval = 20
|
||||
let coordinator = TvosDeepLinkDeliveryCoordinator(now: { now })
|
||||
XCTAssertNil(coordinator.beginLiveDelivery())
|
||||
|
||||
guard case let .deliver(first) = coordinator.receive(contentId: "server:item") else {
|
||||
return XCTFail("Expected live delivery")
|
||||
}
|
||||
XCTAssertNil(coordinator.complete(first, succeeded: false))
|
||||
XCTAssertEqual(coordinator.retainedContentId, "server:item")
|
||||
|
||||
now += 0.1
|
||||
guard case let .deliver(retry) = coordinator.receive(contentId: "server:item") else {
|
||||
return XCTFail("Expected failed delivery to retry")
|
||||
}
|
||||
XCTAssertEqual(retry, first)
|
||||
XCTAssertNil(coordinator.complete(retry, succeeded: true))
|
||||
XCTAssertNil(coordinator.retainedContentId)
|
||||
}
|
||||
|
||||
func testDeduplicationWindowExpiresForASecondDeliberateTap() {
|
||||
var now: TimeInterval = 30
|
||||
let coordinator = TvosDeepLinkDeliveryCoordinator(deduplicationWindow: 2, now: { now })
|
||||
XCTAssertNil(coordinator.beginLiveDelivery())
|
||||
|
||||
guard case let .deliver(first) = coordinator.receive(contentId: "server:item") else {
|
||||
return XCTFail("Expected first delivery")
|
||||
}
|
||||
_ = coordinator.complete(first, succeeded: true)
|
||||
|
||||
now += 1.99
|
||||
XCTAssertEqual(coordinator.receive(contentId: "server:item"), .duplicate)
|
||||
now += 0.02
|
||||
guard case let .deliver(second) = coordinator.receive(contentId: "server:item") else {
|
||||
return XCTFail("Expected delivery after bounded window")
|
||||
}
|
||||
XCTAssertNotEqual(second, first)
|
||||
}
|
||||
|
||||
func testInFlightDeliveryCoalescesDuplicateAndThenDeliversNewestEvent() {
|
||||
var now: TimeInterval = 40
|
||||
let coordinator = TvosDeepLinkDeliveryCoordinator(now: { now })
|
||||
XCTAssertNil(coordinator.beginLiveDelivery())
|
||||
|
||||
guard case let .deliver(first) = coordinator.receive(contentId: "server:first") else {
|
||||
return XCTFail("Expected first delivery")
|
||||
}
|
||||
now += 0.1
|
||||
XCTAssertEqual(coordinator.receive(contentId: "server:first"), .duplicate)
|
||||
XCTAssertEqual(coordinator.receive(contentId: "server:second"), .retained)
|
||||
|
||||
let second = try! XCTUnwrap(coordinator.complete(first, succeeded: true))
|
||||
XCTAssertEqual(second.contentId, "server:second")
|
||||
XCTAssertNil(coordinator.complete(second, succeeded: true))
|
||||
XCTAssertNil(coordinator.retainedContentId)
|
||||
}
|
||||
|
||||
func testEngineRebindRetainsLaunchEventAndRequiresInitialReadAgain() {
|
||||
let coordinator = TvosDeepLinkDeliveryCoordinator(now: { 50 })
|
||||
XCTAssertNil(coordinator.beginLiveDelivery())
|
||||
guard case let .deliver(event) = coordinator.receive(contentId: "server:item") else {
|
||||
return XCTFail("Expected live delivery")
|
||||
}
|
||||
XCTAssertNil(coordinator.complete(event, succeeded: false))
|
||||
|
||||
coordinator.bindEngine()
|
||||
XCTAssertEqual(coordinator.receive(contentId: "server:item"), .duplicate)
|
||||
XCTAssertEqual(coordinator.beginLiveDelivery()?.contentId, "server:item")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
db7b9740fbc38dd7ecead775f2c04cbfced2cab95717f2ed1d00d1f0f4438a55
|
||||
@@ -3,8 +3,8 @@
|
||||
# extract it into a shared cache, and write tvos/Flutter/Generated.xcconfig
|
||||
# so Xcode picks it up via FLUTTER_LOCAL_ENGINE.
|
||||
#
|
||||
# Reads the engine version from tvos/engine.version. Re-runs are cheap —
|
||||
# skips download if the cache already has the matching version.
|
||||
# Reads the engine version and reviewed SHA-256 from tvos/engine.version and
|
||||
# tvos/engine.sha256. Re-runs are cheap —
|
||||
#
|
||||
# Usage:
|
||||
# tvos/scripts/fetch_engine.sh
|
||||
@@ -19,36 +19,54 @@ TVOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REPO_ROOT="$(cd "${TVOS_DIR}/.." && pwd)"
|
||||
|
||||
VERSION_FILE="${TVOS_DIR}/engine.version"
|
||||
if [[ ! -f "$VERSION_FILE" ]]; then
|
||||
echo "error: $VERSION_FILE missing" >&2
|
||||
SHA256_FILE="${TVOS_DIR}/engine.sha256"
|
||||
if [[ ! -f "$VERSION_FILE" || ! -f "$SHA256_FILE" ]]; then
|
||||
echo "error: tvOS engine version/checksum metadata is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")"
|
||||
EXPECTED_SHA256="$(tr -d '[:space:]' < "$SHA256_FILE")"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "error: tvos/engine.version is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "error: tvos/engine.sha256 must contain one lowercase SHA-256 digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CACHE_ROOT="${FLUTTER_TVOS_ENGINE_CACHE:-$HOME/.cache/flutter-tvos-engine}"
|
||||
RELEASES_URL="${FLUTTER_TVOS_RELEASES_URL:-https://github.com/edde746/flutter-tvos}"
|
||||
RELEASES_URL="${FLUTTER_TVOS_RELEASES_URL:-https://github.com/edde746/flutter-plezy}"
|
||||
|
||||
ENGINE_DIR="${CACHE_ROOT}/v${VERSION}"
|
||||
TARBALL_URL="${RELEASES_URL}/releases/download/v${VERSION}/flutter-tvos-${VERSION}.tar.gz"
|
||||
STAMP="${ENGINE_DIR}/.installed-${VERSION}"
|
||||
STAMP="${ENGINE_DIR}/.installed"
|
||||
INSTALL_ID="${VERSION} ${EXPECTED_SHA256}"
|
||||
|
||||
if [[ ! -f "$STAMP" ]]; then
|
||||
if [[ ! -f "$STAMP" || "$(<"$STAMP")" != "$INSTALL_ID" ]]; then
|
||||
echo "[fetch_engine] downloading ${TARBALL_URL}"
|
||||
mkdir -p "$ENGINE_DIR"
|
||||
mkdir -p "$CACHE_ROOT"
|
||||
TMP_TAR="$(mktemp -t flutter-tvos-engine.XXXXXX.tar.gz)"
|
||||
trap 'rm -f "$TMP_TAR"' EXIT
|
||||
TMP_ENGINE="$(mktemp -d "${CACHE_ROOT}/.engine.XXXXXX")"
|
||||
cleanup() {
|
||||
rm -f "${TMP_TAR:-}"
|
||||
if [[ -n "${TMP_ENGINE:-}" ]]; then
|
||||
rm -rf "$TMP_ENGINE"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
curl -fL --progress-bar -o "$TMP_TAR" "$TARBALL_URL"
|
||||
echo "[fetch_engine] extracting to $ENGINE_DIR"
|
||||
tar -xzf "$TMP_TAR" -C "$ENGINE_DIR"
|
||||
touch "$STAMP"
|
||||
printf '%s %s\n' "$EXPECTED_SHA256" "$TMP_TAR" | shasum -a 256 -c -
|
||||
echo "[fetch_engine] extracting verified archive to $ENGINE_DIR"
|
||||
tar -xzf "$TMP_TAR" -C "$TMP_ENGINE"
|
||||
printf '%s\n' "$INSTALL_ID" > "$TMP_ENGINE/.installed"
|
||||
rm -rf "$ENGINE_DIR"
|
||||
mv "$TMP_ENGINE" "$ENGINE_DIR"
|
||||
TMP_ENGINE=""
|
||||
rm -f "$TMP_TAR"
|
||||
trap - EXIT
|
||||
else
|
||||
echo "[fetch_engine] using cached engine at $ENGINE_DIR"
|
||||
echo "[fetch_engine] using verified cached engine at $ENGINE_DIR"
|
||||
fi
|
||||
|
||||
# Locate a host Flutter SDK for flutter CLI invocation during the build.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
require 'xcodeproj'
|
||||
|
||||
PROJECT_PATH = File.expand_path('../Runner.xcodeproj', __dir__)
|
||||
SCHEME_PATH = File.expand_path('../Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', __dir__)
|
||||
project = Xcodeproj::Project.open(PROJECT_PATH)
|
||||
runner = project.targets.find { |t| t.name == 'Runner' }
|
||||
raise 'Runner target not found' unless runner
|
||||
@@ -66,6 +67,53 @@ system_shelf_ref = ensure_file(runner_group, 'SystemShelfPlugin.swift')
|
||||
ensure_source(runner, system_shelf_ref)
|
||||
ensure_file(runner_group, 'Runner.entitlements')
|
||||
|
||||
tests_group = main_group['RunnerTests'] || main_group.new_group('RunnerTests', 'RunnerTests')
|
||||
test_target = project.targets.find { |target| target.name == 'RunnerTests' }
|
||||
unless test_target
|
||||
test_target = project.new_target(:unit_test_bundle, 'RunnerTests', :tvos, '14.0')
|
||||
end
|
||||
test_target.product_type = 'com.apple.product-type.bundle.unit-test'
|
||||
test_target.frameworks_build_phase.files.delete_if do |build_file|
|
||||
build_file.file_ref&.display_name == 'Foundation.framework'
|
||||
end
|
||||
project.files.select { |file| file.display_name == 'Foundation.framework' }.each do |file_ref|
|
||||
still_used = project.targets.any? do |target|
|
||||
target.frameworks_build_phase.files_references.include?(file_ref)
|
||||
end
|
||||
file_ref.remove_from_project unless still_used
|
||||
end
|
||||
runner_test_sources = %w[
|
||||
TvosEventDeliveryCoordinatorTests.swift
|
||||
]
|
||||
test_target.source_build_phase.files.delete_if do |build_file|
|
||||
file_ref = build_file.file_ref
|
||||
file_ref && !runner_test_sources.include?(file_ref.display_name)
|
||||
end
|
||||
tests_group.files.reject { |file_ref| runner_test_sources.include?(file_ref.display_name) }.each do |file_ref|
|
||||
file_ref.remove_from_project
|
||||
end
|
||||
runner_test_sources.each do |filename|
|
||||
ensure_source(test_target, ensure_file(tests_group, filename))
|
||||
end
|
||||
test_target.add_dependency(runner) unless test_target.dependencies.any? { |dependency| dependency.target == runner }
|
||||
|
||||
test_target.build_configurations.each do |config|
|
||||
settings = config.build_settings
|
||||
settings['BUNDLE_LOADER'] = '$(TEST_HOST)'
|
||||
settings.delete('CODE_SIGNING_ALLOWED')
|
||||
settings['GENERATE_INFOPLIST_FILE'] = 'YES'
|
||||
settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.edde746.plezy.RunnerTests'
|
||||
settings['SDKROOT'] = 'appletvos'
|
||||
settings['SUPPORTED_PLATFORMS'] = 'appletvos appletvsimulator'
|
||||
settings['SWIFT_VERSION'] = '5.0'
|
||||
settings['TARGETED_DEVICE_FAMILY'] = '3'
|
||||
settings['TEST_HOST'] = '$(BUILT_PRODUCTS_DIR)/Runner.app/Runner'
|
||||
settings['TVOS_DEPLOYMENT_TARGET'] = '14.0'
|
||||
end
|
||||
|
||||
event_delivery_ref = ensure_file(runner_group, 'TvosEventDeliveryCoordinator.swift')
|
||||
ensure_source(runner, event_delivery_ref)
|
||||
|
||||
extension_group = main_group['TopShelfExtension'] || main_group.new_group('TopShelfExtension', 'TopShelfExtension')
|
||||
top_shelf_ref = ensure_file(extension_group, 'TopShelfProvider.swift')
|
||||
ensure_file(extension_group, 'Info.plist')
|
||||
@@ -158,5 +206,13 @@ ensure_shell_script(
|
||||
'/bin/bash "$SOURCE_ROOT/scripts/xcode_appletv.sh" sync_version' + "\n"
|
||||
)
|
||||
|
||||
scheme = Xcodeproj::XCScheme.new(SCHEME_PATH)
|
||||
unless scheme.test_action.testables.any? do |testable|
|
||||
testable.buildable_references.any? { |reference| reference.target_name == test_target.name }
|
||||
end
|
||||
scheme.add_test_target(test_target)
|
||||
end
|
||||
scheme.save!
|
||||
|
||||
project.save
|
||||
puts 'Saved Top Shelf wiring'
|
||||
|
||||
@@ -307,6 +307,55 @@ ResolveEngineOutput() {
|
||||
return 1
|
||||
}
|
||||
|
||||
ResolveDartAotRuntime() {
|
||||
local host_tools="$1"
|
||||
local packaged_runtime="$host_tools/dart-sdk/bin/dartaotruntime"
|
||||
if [[ -x "$packaged_runtime" ]] && "$packaged_runtime" --version >/dev/null 2>&1; then
|
||||
printf '%s\n' "$packaged_runtime"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local flutter_runtime="${FLUTTER_ROOT:-}/bin/cache/dart-sdk/bin/dartaotruntime"
|
||||
local packaged_version_file="$host_tools/dart-sdk/version"
|
||||
local flutter_version_file="${FLUTTER_ROOT:-}/bin/cache/dart-sdk/version"
|
||||
if [[ ! -x "$flutter_runtime" || ! -f "$packaged_version_file" || ! -f "$flutter_version_file" ]]; then
|
||||
echo " └─ERROR: the packaged Dart runtime cannot execute on this host and no compatible Flutter runtime was found" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local packaged_version
|
||||
local flutter_version
|
||||
packaged_version="$(tr -d '[:space:]' < "$packaged_version_file")"
|
||||
flutter_version="$(tr -d '[:space:]' < "$flutter_version_file")"
|
||||
if [[ "$packaged_version" != "$flutter_version" ]]; then
|
||||
echo " └─ERROR: the packaged Dart runtime cannot execute on this host and Flutter's Dart version does not match ($flutter_version != $packaged_version)" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! "$flutter_runtime" --version >/dev/null 2>&1; then
|
||||
echo " └─ERROR: Flutter's Dart runtime cannot execute on this host" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$flutter_runtime"
|
||||
}
|
||||
|
||||
ResolveFrontendServer() {
|
||||
local host_tools="$1"
|
||||
local dart_aot_runtime="$2"
|
||||
local frontend_server="$host_tools/dart-sdk/bin/snapshots/frontend_server_aot.dart.snapshot"
|
||||
if [[ "$dart_aot_runtime" != "$host_tools/dart-sdk/bin/dartaotruntime" ]]; then
|
||||
frontend_server="$FLUTTER_ROOT/bin/cache/dart-sdk/bin/snapshots/frontend_server_aot.dart.snapshot"
|
||||
elif [[ ! -f "$frontend_server" ]]; then
|
||||
frontend_server="$host_tools/gen/frontend_server_aot.dart.snapshot"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$frontend_server" ]]; then
|
||||
echo " └─ERROR: compatible frontend_server snapshot was not found" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$frontend_server"
|
||||
}
|
||||
|
||||
BuildAppDebug() {
|
||||
# Host tools (frontend_server, patched SDK, dartaotruntime) ship in
|
||||
# host_release for both debug and release consumers — the frontend_server
|
||||
@@ -353,6 +402,8 @@ BuildAppDebug() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
DART_AOT_RUNTIME=$(ResolveDartAotRuntime "$HOST_TOOLS") || return 1
|
||||
|
||||
# flutter build bundle produces: AssetManifest, FontManifest, NOTICES,
|
||||
# shaders, fonts, assets, packages, plus a kernel_blob.bin and
|
||||
# isolate_snapshot_data compiled against the stock flutter engine. We
|
||||
@@ -369,12 +420,9 @@ BuildAppDebug() {
|
||||
return 1
|
||||
}
|
||||
|
||||
echo " └─Compiling tvOS kernel via local engine frontend_server"
|
||||
FRONTEND_SERVER="$HOST_TOOLS/dart-sdk/bin/snapshots/frontend_server_aot.dart.snapshot"
|
||||
if [ ! -f "$FRONTEND_SERVER" ]; then
|
||||
FRONTEND_SERVER="$HOST_TOOLS/gen/frontend_server_aot.dart.snapshot"
|
||||
fi
|
||||
"$HOST_TOOLS/dart-sdk/bin/dartaotruntime" \
|
||||
echo " └─Compiling tvOS kernel via compatible frontend_server"
|
||||
FRONTEND_SERVER=$(ResolveFrontendServer "$HOST_TOOLS" "$DART_AOT_RUNTIME") || return 1
|
||||
"$DART_AOT_RUNTIME" \
|
||||
"$FRONTEND_SERVER" \
|
||||
--sdk-root "$HOST_TOOLS/flutter_patched_sdk" \
|
||||
--tfa --target=flutter \
|
||||
@@ -515,6 +563,8 @@ BuildAppRelease() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
DART_AOT_RUNTIME=$(ResolveDartAotRuntime "$HOST_TOOLS") || return 1
|
||||
|
||||
echo " └─Generate flutter_assets via flutter build bundle (release)"
|
||||
mkdir -p "$OUTDIR/App.framework/flutter_assets"
|
||||
(
|
||||
@@ -535,11 +585,8 @@ BuildAppRelease() {
|
||||
echo " └─Compiling AOT kernel via local engine frontend_server"
|
||||
# The snapshot under dart-sdk/bin/snapshots/ is the actual AOT-compiled one;
|
||||
# the one under gen/ is a stale/placeholder kernel.
|
||||
FRONTEND_SERVER="$HOST_TOOLS/dart-sdk/bin/snapshots/frontend_server_aot.dart.snapshot"
|
||||
if [ ! -f "$FRONTEND_SERVER" ]; then
|
||||
FRONTEND_SERVER="$HOST_TOOLS/gen/frontend_server_aot.dart.snapshot"
|
||||
fi
|
||||
"$HOST_TOOLS/dart-sdk/bin/dartaotruntime" \
|
||||
FRONTEND_SERVER=$(ResolveFrontendServer "$HOST_TOOLS" "$DART_AOT_RUNTIME") || return 1
|
||||
"$DART_AOT_RUNTIME" \
|
||||
"$FRONTEND_SERVER" \
|
||||
--sdk-root "$HOST_TOOLS/flutter_patched_sdk" \
|
||||
--aot --tfa --target=flutter \
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"reviewedOn": "2026-07-21",
|
||||
"accepted": [
|
||||
{
|
||||
"id": 1113317,
|
||||
"package": "@sveltejs/kit",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=2.49.0 <=2.52.1",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-88qp-p4qg-rqm6: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
|
||||
},
|
||||
{
|
||||
"id": 1113318,
|
||||
"package": "@sveltejs/kit",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=2.49.0 <=2.52.1",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-vrhm-gvg7-fpcf: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
|
||||
},
|
||||
{
|
||||
"id": 1113631,
|
||||
"package": "@sveltejs/kit",
|
||||
"severity": "low",
|
||||
"vulnerableRange": ">=2.49.0 <=2.53.2",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-fpg4-jhqr-589c: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
|
||||
},
|
||||
{
|
||||
"id": 1116432,
|
||||
"package": "@sveltejs/kit",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": "<=2.57.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-3f6h-2hrp-w5wx: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
|
||||
},
|
||||
{
|
||||
"id": 1116433,
|
||||
"package": "@sveltejs/kit",
|
||||
"severity": "high",
|
||||
"vulnerableRange": "<=2.57.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-2crg-3p73-43xp: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
|
||||
},
|
||||
{
|
||||
"id": 1122155,
|
||||
"package": "@sveltejs/kit",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=2.38.0 <=2.60.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-hgv7-v322-mmgr: Static adapter/prerender site has no remote functions or forms, query.batch, hooks/redirects, or adapter-node body handling."
|
||||
},
|
||||
{
|
||||
"id": 1103907,
|
||||
"package": "cookie",
|
||||
"severity": "low",
|
||||
"vulnerableRange": "<0.7.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-pxg6-pf52-xh8x: Static site has no cookies, hooks, actions, or forms; cookie serialization is not exercised."
|
||||
},
|
||||
{
|
||||
"id": 1113319,
|
||||
"package": "devalue",
|
||||
"severity": "low",
|
||||
"vulnerableRange": "<=5.6.2",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-33hq-fvwr-56pm: Prerender serialization receives fixed price/rating/count data; the site does not call parse, unflatten, or uneval."
|
||||
},
|
||||
{
|
||||
"id": 1113320,
|
||||
"package": "devalue",
|
||||
"severity": "low",
|
||||
"vulnerableRange": "<=5.6.2",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-8qm3-746x-r74r: Prerender serialization receives fixed price/rating/count data; the site does not call parse, unflatten, or uneval."
|
||||
},
|
||||
{
|
||||
"id": 1114438,
|
||||
"package": "devalue",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": "<5.6.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-cfw5-2vxh-hr84: Prerender serialization receives fixed price/rating/count data; the site does not call parse, unflatten, or uneval."
|
||||
},
|
||||
{
|
||||
"id": 1121800,
|
||||
"package": "devalue",
|
||||
"severity": "low",
|
||||
"vulnerableRange": ">=4.0.0 <5.6.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-mwv9-gp5h-frr4: Prerender serialization receives fixed price/rating/count data; the site does not call parse, unflatten, or uneval."
|
||||
},
|
||||
{
|
||||
"id": 1115551,
|
||||
"package": "picomatch",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=4.0.0 <4.0.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-3v7f-55p6-f55p: Build-only glob tooling consumes repository-controlled patterns; no deployed runtime or untrusted glob input exists."
|
||||
},
|
||||
{
|
||||
"id": 1115554,
|
||||
"package": "picomatch",
|
||||
"severity": "high",
|
||||
"vulnerableRange": ">=4.0.0 <4.0.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-c2c7-rcm5-vvqj: Build-only glob tooling consumes repository-controlled patterns; no deployed runtime or untrusted glob input exists."
|
||||
},
|
||||
{
|
||||
"id": 1117015,
|
||||
"package": "postcss",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": "<8.5.10",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-qx2v-qp2m-jg93: Build-only CSS tooling consumes checked-in CSS; no untrusted CSS reaches stringify and the tooling is not deployed."
|
||||
},
|
||||
{
|
||||
"id": 1113515,
|
||||
"package": "rollup",
|
||||
"severity": "high",
|
||||
"vulnerableRange": ">=4.0.0 <4.59.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-mw96-cpmx-2vgc: Build-only bundling processes repository-controlled paths and is absent from the deployed static output."
|
||||
},
|
||||
{
|
||||
"id": 1113416,
|
||||
"package": "svelte",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": "<=5.51.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-crpf-4hrx-3jrp: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
|
||||
},
|
||||
{
|
||||
"id": 1113418,
|
||||
"package": "svelte",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": "<=5.51.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-m56q-vw4c-c2cp: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
|
||||
},
|
||||
{
|
||||
"id": 1113419,
|
||||
"package": "svelte",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": "<=5.51.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-f7gr-6p89-r883: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
|
||||
},
|
||||
{
|
||||
"id": 1113420,
|
||||
"package": "svelte",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=5.39.3 <5.51.5",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-h7h7-mm68-gmrc: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
|
||||
},
|
||||
{
|
||||
"id": 1114402,
|
||||
"package": "svelte",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": "<=5.53.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-phwv-c562-gvmh: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
|
||||
},
|
||||
{
|
||||
"id": 1118900,
|
||||
"package": "svelte",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=5.46.0 <=5.55.6",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-f3cj-j4f6-wq85: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
|
||||
},
|
||||
{
|
||||
"id": 1120446,
|
||||
"package": "svelte",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": "<=5.55.6",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-rcqx-6q8c-2c42: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
|
||||
},
|
||||
{
|
||||
"id": 1120449,
|
||||
"package": "svelte",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": "<=5.55.6",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-pr6f-5x2q-rwfp: Source trace found none of the affected SSR constructs; the only HTML input is a checked-in constant."
|
||||
},
|
||||
{
|
||||
"id": 1114591,
|
||||
"package": "undici",
|
||||
"severity": "high",
|
||||
"vulnerableRange": ">=7.0.0 <7.24.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-f269-vfmq-vjvj: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1114593,
|
||||
"package": "undici",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=7.0.0 <7.24.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-2mjp-6q6p-2qxm: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1114637,
|
||||
"package": "undici",
|
||||
"severity": "high",
|
||||
"vulnerableRange": ">=7.0.0 <7.24.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-vrm6-8vpv-qv8q: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1114639,
|
||||
"package": "undici",
|
||||
"severity": "high",
|
||||
"vulnerableRange": ">=7.0.0 <7.24.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-v9p9-hfj2-hcw8: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1114641,
|
||||
"package": "undici",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=7.0.0 <7.24.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-4992-7rv2-5pvq: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1114643,
|
||||
"package": "undici",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=7.17.0 <7.24.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-phc3-fgpg-7m6h: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1121241,
|
||||
"package": "undici",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=7.0.0 <7.28.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-p88m-4jfj-68fv: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1121244,
|
||||
"package": "undici",
|
||||
"severity": "high",
|
||||
"vulnerableRange": ">=7.0.0 <7.28.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-vxpw-j846-p89q: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1121249,
|
||||
"package": "undici",
|
||||
"severity": "low",
|
||||
"vulnerableRange": ">=7.0.0 <7.28.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-35p6-xmwp-9g52: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1121254,
|
||||
"package": "undici",
|
||||
"severity": "low",
|
||||
"vulnerableRange": ">=7.0.0 <7.28.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-g8m3-5g58-fq7m: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1121428,
|
||||
"package": "undici",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=7.0.0 <7.28.0",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-pr7r-676h-xcf6: Build-only scraper calls a fixed Google endpoint; no WebSockets, upgrades, shared cache, cookie parsing, or attacker-selected endpoint."
|
||||
},
|
||||
{
|
||||
"id": 1116230,
|
||||
"package": "vite",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=7.0.0 <=7.3.1",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-4w7w-66w2-5vf9: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
|
||||
},
|
||||
{
|
||||
"id": 1116232,
|
||||
"package": "vite",
|
||||
"severity": "high",
|
||||
"vulnerableRange": ">=7.1.0 <=7.3.1",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-v2wj-q39q-566r: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
|
||||
},
|
||||
{
|
||||
"id": 1116235,
|
||||
"package": "vite",
|
||||
"severity": "high",
|
||||
"vulnerableRange": ">=7.0.0 <=7.3.1",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-p9ff-h696-f583: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
|
||||
},
|
||||
{
|
||||
"id": 1120785,
|
||||
"package": "vite",
|
||||
"severity": "moderate",
|
||||
"vulnerableRange": ">=7.0.0 <=7.3.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-v6wh-96g9-6wx3: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
|
||||
},
|
||||
{
|
||||
"id": 1123526,
|
||||
"package": "vite",
|
||||
"severity": "high",
|
||||
"vulnerableRange": ">=7.0.0 <=7.3.4",
|
||||
"expiresOn": "2026-10-19",
|
||||
"rationale": "GHSA-fx2h-pf6j-xcff: Development/build tooling is not deployed; its servers are not exposed and inputs are repository-controlled."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"audit": "python3 ../scripts/check_bun_audit.py --project . --baseline bun_audit_baseline.json",
|
||||
"test": "bun test",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 32 358.32 399.86'>
|
||||
<defs>
|
||||
<linearGradient id='logo-gradient' x1='22.67' y1='425.72' x2='201.83' y2='115.4' gradientUnits='userSpaceOnUse'>
|
||||
<stop offset='0' stop-color='#ab543a'/>
|
||||
<stop offset='1' stop-color='#ff7e57'/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill='url(#logo-gradient)' d='M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-.67-4.5-4.86-4.2-12.45.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95.66,4.5,4.87,4.21,12.46-.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-.66s-4.2-12.46.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95.67,4.5,4.86,4.21,12.45-.66,16.95Z'/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -5,31 +5,27 @@
|
||||
import AmazonIcon from "~icons/cib/amazon";
|
||||
import ChevronDownIcon from "~icons/heroicons/chevron-down-solid";
|
||||
import WindowsIcon from "./WindowsIcon.svelte";
|
||||
import { linuxArchitectures } from "$lib/content/downloads";
|
||||
|
||||
const linuxArchitectures = [
|
||||
{
|
||||
label: "x64 (Intel/AMD)",
|
||||
formats: [
|
||||
{ label: ".deb (Debian/Ubuntu)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb" },
|
||||
{ label: ".rpm (Fedora/RHEL)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm" },
|
||||
{ label: ".pkg.tar.zst (Arch)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst" },
|
||||
{ label: ".tar.gz (Portable)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "ARM64",
|
||||
formats: [
|
||||
{ label: ".deb (Debian/Ubuntu)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb" },
|
||||
{ label: ".rpm (Fedora/RHEL)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm" },
|
||||
{ label: ".pkg.tar.zst (Arch)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst" },
|
||||
{ label: ".tar.gz (Portable)", url: "https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const componentId = $props.id();
|
||||
const linuxPanelId = `${componentId}-linux-downloads`;
|
||||
let linuxOpen = $state(false);
|
||||
let hovered = $state(false);
|
||||
let showDropdown = $derived(linuxOpen || hovered);
|
||||
|
||||
function hoverDisclosure(node: HTMLElement) {
|
||||
const update = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'mouse') hovered = event.type === 'pointerenter';
|
||||
};
|
||||
node.addEventListener('pointerenter', update);
|
||||
node.addEventListener('pointerleave', update);
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointerenter', update);
|
||||
node.removeEventListener('pointerleave', update);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={() => { linuxOpen = false; }} />
|
||||
@@ -87,17 +83,12 @@
|
||||
</a>
|
||||
|
||||
<!-- Linux dropdown -->
|
||||
<div
|
||||
class="linux-control"
|
||||
role="group"
|
||||
onpointerenter={(e) => { if (e.pointerType === 'mouse') hovered = true; }}
|
||||
onpointerleave={(e) => { if (e.pointerType === 'mouse') hovered = false; }}
|
||||
>
|
||||
<div class="linux-control" use:hoverDisclosure>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); linuxOpen = !linuxOpen; }}
|
||||
aria-expanded={showDropdown}
|
||||
aria-haspopup="true"
|
||||
aria-controls={linuxPanelId}
|
||||
class="desktop-button linux-button"
|
||||
class:active={showDropdown}
|
||||
>
|
||||
@@ -109,17 +100,19 @@
|
||||
</button>
|
||||
|
||||
<div
|
||||
role="menu"
|
||||
id={linuxPanelId}
|
||||
class="linux-menu"
|
||||
class:open={showDropdown}
|
||||
aria-hidden={!showDropdown}
|
||||
inert={!showDropdown}
|
||||
>
|
||||
{#each linuxArchitectures as arch, i}
|
||||
{#if i > 0}
|
||||
<div class="linux-separator"></div>
|
||||
<div class="linux-separator" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div class="linux-arch-label">{arch.label}</div>
|
||||
{#each arch.formats as format}
|
||||
<a href={format.url} role="menuitem" onclick={() => { linuxOpen = false; }} class="linux-menu-item">
|
||||
<a href={format.url} onclick={() => { linuxOpen = false; }} class="linux-menu-item">
|
||||
{format.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.footer-logo :global(svg) {
|
||||
.footer-logo :global(img) {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-logo :global(svg) {
|
||||
.brand-logo :global(img) {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
let { class: className = '', gradient = true }: { class?: string; gradient?: boolean } = $props();
|
||||
import logoUrl from '$lib/assets/favicon.svg';
|
||||
|
||||
const gradientId = $props.id();
|
||||
let { class: className = '' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg class={className} viewBox="0 32 358.32 399.86" xmlns="http://www.w3.org/2000/svg">
|
||||
{#if gradient}
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="22.67" y1="425.72" x2="201.83" y2="115.4" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ab543a" />
|
||||
<stop offset="1" stop-color="#ff7e57" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/if}
|
||||
<path
|
||||
fill={gradient ? `url(#${gradientId})` : 'currentColor'}
|
||||
d="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-.67-4.5-4.86-4.2-12.45.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95.66,4.5,4.87,4.21,12.46-.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-.66s-4.2-12.46.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95.67,4.5,4.86,4.21,12.45-.66,16.95Z"
|
||||
/>
|
||||
</svg>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
class={className}
|
||||
/>
|
||||
|
||||
<style>
|
||||
img {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,11 +98,21 @@
|
||||
};
|
||||
|
||||
let active: DeviceType = $state('phone');
|
||||
let loaded: Record<DeviceType, boolean> = $state({
|
||||
phone: true,
|
||||
tablet: false,
|
||||
desktop: false,
|
||||
tv: false,
|
||||
});
|
||||
let scrollContainer: HTMLElement | undefined = $state();
|
||||
let canScrollLeft = $state(false);
|
||||
let canScrollRight = $state(false);
|
||||
let intendedScrollLeft: number | undefined;
|
||||
|
||||
function selectDevice(device: DeviceType) {
|
||||
loaded[device] = true;
|
||||
active = device;
|
||||
}
|
||||
function updateScrollState() {
|
||||
if (!scrollContainer) return;
|
||||
if (intendedScrollLeft !== undefined && Math.abs(scrollContainer.scrollLeft - intendedScrollLeft) < 2) {
|
||||
@@ -174,7 +184,7 @@
|
||||
{@const DeviceIcon = device.icon}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => active = device.id}
|
||||
onclick={() => selectDevice(device.id)}
|
||||
aria-pressed={active === device.id}
|
||||
aria-controls={`screenshots-${device.id}-panel`}
|
||||
aria-label={`Show ${device.label} screenshots`}
|
||||
@@ -228,19 +238,21 @@
|
||||
if (active === device.id) updateScrollState();
|
||||
}}
|
||||
>
|
||||
{#each screenshot.shots as shot}
|
||||
<div class="screenshot-item">
|
||||
<div class={`screenshot-frame ${screenshot.frameClass}`}>
|
||||
<enhanced:img
|
||||
src={shot.image}
|
||||
alt={shot.alt}
|
||||
loading="eager"
|
||||
class="screenshot-image"
|
||||
sizes={screenshot.sizes}
|
||||
/>
|
||||
{#if loaded[device.id]}
|
||||
{#each screenshot.shots as shot}
|
||||
<div class="screenshot-item">
|
||||
<div class={`screenshot-frame ${screenshot.frameClass}`}>
|
||||
<enhanced:img
|
||||
src={shot.image}
|
||||
alt={shot.alt}
|
||||
loading="lazy"
|
||||
class="screenshot-image"
|
||||
sizes={screenshot.sizes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -4,21 +4,49 @@
|
||||
let { children, delay = 0, class: className = '' }: { children: Snippet; delay?: number; class?: string } = $props();
|
||||
|
||||
let el: HTMLDivElement | undefined = $state();
|
||||
let visible = $state(false);
|
||||
let visible = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
visible = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
|
||||
visible = true;
|
||||
if (
|
||||
typeof IntersectionObserver === 'undefined' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const hasGeometry = rect.width > 0 && rect.height > 0;
|
||||
const isInViewport =
|
||||
hasGeometry &&
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < window.innerHeight &&
|
||||
rect.left < window.innerWidth;
|
||||
if (!hasGeometry || isInViewport) return;
|
||||
|
||||
let observer: IntersectionObserver | undefined;
|
||||
try {
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
visible = true;
|
||||
observer?.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
observer.observe(el);
|
||||
visible = false;
|
||||
} catch {
|
||||
observer?.disconnect();
|
||||
visible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
return () => observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -39,7 +67,9 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scroll-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export type MobileStorePlatform = 'ios' | 'android' | 'unknown';
|
||||
|
||||
export type PlatformEvidence = {
|
||||
userAgent?: string;
|
||||
platform?: string;
|
||||
maxTouchPoints?: number;
|
||||
};
|
||||
|
||||
export type StoreOption = {
|
||||
id: 'app-store' | 'play-store';
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const storeOptions = {
|
||||
ios: {
|
||||
id: 'app-store',
|
||||
label: 'App Store',
|
||||
url: 'https://apps.apple.com/us/app/id6754315964',
|
||||
},
|
||||
android: {
|
||||
id: 'play-store',
|
||||
label: 'Google Play',
|
||||
url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy',
|
||||
},
|
||||
} as const satisfies Record<'ios' | 'android', StoreOption>;
|
||||
|
||||
export function detectMobileStorePlatform(evidence: PlatformEvidence = {}): MobileStorePlatform {
|
||||
const userAgent = evidence.userAgent?.toLowerCase() ?? '';
|
||||
const platform = evidence.platform?.toLowerCase() ?? '';
|
||||
|
||||
if (/iphone|ipad|ipod/.test(userAgent)) return 'ios';
|
||||
if (/android/.test(userAgent)) return 'android';
|
||||
|
||||
// iPadOS can request a desktop site and identify as MacIntel. Touch support
|
||||
// distinguishes it from a Mac without guessing from screen dimensions.
|
||||
if (platform === 'macintel' && (evidence.maxTouchPoints ?? 0) > 1) return 'ios';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function storeOptionsForPlatform(platform: MobileStorePlatform): readonly StoreOption[] {
|
||||
if (platform === 'ios') return [storeOptions.ios];
|
||||
if (platform === 'android') return [storeOptions.android];
|
||||
return [storeOptions.ios, storeOptions.android];
|
||||
}
|
||||
|
||||
export const linuxArchitectures = [
|
||||
{
|
||||
label: 'x64 (Intel/AMD)',
|
||||
formats: [
|
||||
{ label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb' },
|
||||
{ label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm' },
|
||||
{ label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst' },
|
||||
{ label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ARM64',
|
||||
formats: [
|
||||
{ label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb' },
|
||||
{ label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm' },
|
||||
{ label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst' },
|
||||
{ label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz' },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -1,3 +1,6 @@
|
||||
export const watchTogetherFaqAnswer =
|
||||
"Watch Together requires every participant to have access to the same media on the same server. It uses a WebSocket relay to exchange room and participant details, server and media identifiers, an optional media title, and playback timing/control state. It does not relay the media stream or your media-server credentials. Plezy’s relay is the default; if you choose a custom relay, that relay’s operator controls its security, logging, retention, and location.";
|
||||
|
||||
export type Faq = {
|
||||
id: string;
|
||||
question: string;
|
||||
@@ -37,8 +40,7 @@ export const faqs: Faq[] = [
|
||||
{
|
||||
id: "watch-together",
|
||||
question: "How does Watch Together work?",
|
||||
answer:
|
||||
"Watch Together uses a WebSocket relay to sync playback between users. The other person needs access to the same media on the same server. Only playback sync messages are exchanged - nothing about your server is shared.",
|
||||
answer: watchTogetherFaqAnswer,
|
||||
},
|
||||
{
|
||||
id: "video-player",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export type StorePrices = {
|
||||
appStorePrice: string | null;
|
||||
playStorePrice: string | null;
|
||||
};
|
||||
|
||||
export type SoftwareApplicationOffer = {
|
||||
'@type': 'Offer';
|
||||
url: string;
|
||||
category: string;
|
||||
price?: string;
|
||||
priceCurrency?: 'USD';
|
||||
};
|
||||
|
||||
export function normalizeUsdStorePrice(value: unknown, currency: unknown): string | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || currency !== 'USD') return null;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function buildSoftwareApplicationOffers({
|
||||
appStorePrice,
|
||||
playStorePrice,
|
||||
}: StorePrices): SoftwareApplicationOffer[] {
|
||||
return [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: 'https://apps.apple.com/us/app/id6754315964',
|
||||
category: 'App Store',
|
||||
...(appStorePrice === null
|
||||
? {}
|
||||
: {
|
||||
price: appStorePrice,
|
||||
priceCurrency: 'USD' as const,
|
||||
}),
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy',
|
||||
category: 'Google Play',
|
||||
...(playStorePrice === null
|
||||
? {}
|
||||
: {
|
||||
price: playStorePrice,
|
||||
priceCurrency: 'USD' as const,
|
||||
}),
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: 'https://www.amazon.com/gp/product/B0GK65CVS1',
|
||||
category: 'Amazon Appstore',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: 'https://github.com/edde746/plezy',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
category: 'GitHub',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
background: var(--color-surface-highest);
|
||||
}
|
||||
|
||||
.error-logo :global(svg) {
|
||||
.error-logo :global(img) {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { normalizeUsdStorePrice } from '$lib/content/software_app_offers';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
let appStoreRating: { score: number; count: number } | null = null;
|
||||
@@ -8,6 +9,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
||||
|
||||
try {
|
||||
const res = await fetch('https://itunes.apple.com/lookup?id=6754315964');
|
||||
if (!res.ok) throw new Error(`App Store lookup failed: HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const app = data.results?.[0];
|
||||
if (app?.averageUserRating && app?.userRatingCount) {
|
||||
@@ -16,25 +18,27 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
||||
count: app.userRatingCount
|
||||
};
|
||||
}
|
||||
if (app?.price != null) {
|
||||
appStorePrice = String(app.price);
|
||||
}
|
||||
appStorePrice = normalizeUsdStorePrice(app?.price, app?.currency);
|
||||
} catch {
|
||||
// App Store fetch failed, continue without it
|
||||
}
|
||||
|
||||
try {
|
||||
const gplay = await import('google-play-scraper');
|
||||
const app = await gplay.default.app({ appId: 'com.edde746.plezy' });
|
||||
// Module initialization is optional external data and must stay inside this failure boundary.
|
||||
const { default: gplay } = await import('google-play-scraper');
|
||||
const app = await gplay.app({
|
||||
appId: 'com.edde746.plezy',
|
||||
country: 'us',
|
||||
lang: 'en'
|
||||
});
|
||||
if (app.available === false) throw new Error('Google Play listing unavailable');
|
||||
if (app.score && app.ratings) {
|
||||
playStoreRating = {
|
||||
score: app.score,
|
||||
count: app.ratings
|
||||
};
|
||||
}
|
||||
if (app.price != null) {
|
||||
playStorePrice = String(app.price);
|
||||
}
|
||||
playStorePrice = normalizeUsdStorePrice(app.price, app.currency);
|
||||
} catch {
|
||||
// Play Store fetch failed, continue without it
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import FAQ from '$lib/components/FAQ.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import { faqSchemaMainEntity } from '$lib/content/faqs';
|
||||
import { buildSoftwareApplicationOffers } from '$lib/content/software_app_offers';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -23,34 +24,10 @@
|
||||
"url": "https://plezy.app",
|
||||
"applicationCategory": "MultimediaApplication",
|
||||
"operatingSystem": "iOS, Android, Android TV, tvOS, Windows, macOS, Linux",
|
||||
"offers": [
|
||||
{
|
||||
"@type": "Offer",
|
||||
"url": "https://apps.apple.com/us/app/id6754315964",
|
||||
"price": data.appStorePrice ?? "0",
|
||||
"priceCurrency": "USD",
|
||||
"category": "App Store"
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"url": "https://play.google.com/store/apps/details?id=com.edde746.plezy",
|
||||
"price": data.playStorePrice ?? "0",
|
||||
"priceCurrency": "USD",
|
||||
"category": "Google Play"
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"url": "https://www.amazon.com/gp/product/B0GK65CVS1",
|
||||
"category": "Amazon Appstore"
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"url": "https://github.com/edde746/plezy",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD",
|
||||
"category": "GitHub"
|
||||
}
|
||||
]
|
||||
"offers": buildSoftwareApplicationOffers({
|
||||
appStorePrice: data.appStorePrice,
|
||||
playStorePrice: data.playStorePrice
|
||||
})
|
||||
};
|
||||
|
||||
if (data.aggregateRating) {
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
|
||||
const title = 'Privacy Policy - Plezy';
|
||||
const description = 'How Plezy stores data on your device and shares data when you use connected services.';
|
||||
const url = 'https://plezy.app/privacy';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Privacy Policy - Plezy</title>
|
||||
<meta name="description" content="Learn how Plezy handles your data when connecting to Plex and Jellyfin. Our privacy policy covers authentication, crash diagnostics, local network information, and data storage practices." />
|
||||
<link rel="canonical" href="https://plezy.app/privacy" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={url} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Plezy" />
|
||||
<meta property="og:title" content="Privacy Policy - Plezy" />
|
||||
<meta property="og:description" content="Learn how Plezy handles your data. Our privacy policy covers authentication, crash diagnostics, local network information, and data storage practices." />
|
||||
<meta property="og:url" content="https://plezy.app/privacy" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:image" content="https://plezy.app/og/plezy-social.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Privacy Policy - Plezy" />
|
||||
<meta name="twitter:description" content="Learn how Plezy handles your data. Our privacy policy covers authentication, crash diagnostics, local network information, and data storage practices." />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content="https://plezy.app/og/plezy-social.png" />
|
||||
</svelte:head>
|
||||
|
||||
@@ -26,146 +30,115 @@
|
||||
<span>Back to Plezy</span>
|
||||
</a>
|
||||
|
||||
<h1 class="privacy-heading">Privacy Policy</h1>
|
||||
<p class="last-updated">Last Updated: May 2, 2026</p>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="last-updated">Last updated: July 24, 2026</p>
|
||||
|
||||
<div class="prose">
|
||||
<h2>Introduction</h2>
|
||||
<p>Plezy ("we", "our", or "the app") is a third-party client for Plex and Jellyfin that allows you to access and stream content from your media server. This privacy policy explains how we handle your information when you use our app.</p>
|
||||
<section aria-labelledby="overview">
|
||||
<h2 id="overview">Overview</h2>
|
||||
<p>
|
||||
Plezy is a third-party Plex and Jellyfin client. Most account, library, playback, streaming, and
|
||||
download traffic travels directly between your device and the services or media servers you choose.
|
||||
Plezy servers are not in the normal media-streaming path.
|
||||
</p>
|
||||
<p>
|
||||
We do not sell personal data or use it for advertising. The Plezy website does not use analytics or
|
||||
advertising trackers.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h2>Information We Collect</h2>
|
||||
<section aria-labelledby="device-data">
|
||||
<h2 id="device-data">Data on your device</h2>
|
||||
<p>
|
||||
To provide its features, Plezy saves server addresses, access tokens and other sign-in data, profiles,
|
||||
settings, integration sessions, playback state, downloads, and cached artwork on your device. Sensitive
|
||||
credentials use platform-provided protected storage where available. TV versions may also publish
|
||||
Continue Watching titles, progress, and artwork to the system home screen.
|
||||
</p>
|
||||
<p>
|
||||
Downloads in a custom folder and copies kept by the operating system or a backup provider may remain
|
||||
outside storage controlled by Plezy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h3>Authentication Information</h3>
|
||||
<p>When you sign in to Plezy, we collect and process the following information depending on which service you connect:</p>
|
||||
<ul>
|
||||
<li>Plex: authentication tokens, account username, and server connection information</li>
|
||||
<li>Jellyfin: server URL, access token, username, and user ID</li>
|
||||
</ul>
|
||||
<section aria-labelledby="connections">
|
||||
<h2 id="connections">Connections and third parties</h2>
|
||||
<p>
|
||||
When you use a connected feature, Plezy sends the data needed for it to the relevant provider. This can
|
||||
include authentication data, searches, library requests, media identifiers, playback state, and changes
|
||||
you make. Providers can include Plex, the Jellyfin or Seerr server you select, TMDB, Trakt, Simkl,
|
||||
MyAnimeList, AniList, Discord, GitHub, jsDelivr, and artwork hosts returned by those services. They also
|
||||
receive ordinary network information such as your IP address and user agent, and handle data under their
|
||||
own privacy terms.
|
||||
</p>
|
||||
<p>
|
||||
Jellyfin and Companion Remote can send discovery traffic on your local network. If you use an external
|
||||
player, that app receives the media URL, which may contain a server access credential, together with
|
||||
playback details. TV home-screen features can automatically fetch artwork from your media server. A
|
||||
server or relay you configure may use HTTP instead of HTTPS.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h3>Crash and Diagnostic Data</h3>
|
||||
<p>To help us identify and fix bugs, the app automatically collects:</p>
|
||||
<ul>
|
||||
<li>Crash reports and error stack traces</li>
|
||||
<li>Application Not Responding (ANR) events</li>
|
||||
<li>Device model, OS version, and app version</li>
|
||||
<li>Diagnostic breadcrumbs (e.g. playback events leading up to a crash)</li>
|
||||
</ul>
|
||||
<p>This data is sent to our self-hosted error tracking server (bugs.plezy.app). Sensitive information such as authentication tokens and server URLs is automatically stripped before transmission.</p>
|
||||
<section aria-labelledby="plezy-services">
|
||||
<h2 id="plezy-services">Optional Plezy services</h2>
|
||||
<p>
|
||||
Plezy-hosted services are used for Watch Together, MyAnimeList and AniList sign-in handoff, temporary
|
||||
Discord artwork, optional crash reporting, and support logs you explicitly upload. Watch Together
|
||||
exchanges room, participant, server and media identifiers, and playback timing; it does not carry the
|
||||
media stream or your media-server credentials. A custom relay is controlled by its operator.
|
||||
</p>
|
||||
<p>
|
||||
Crash reports can include app, device, error, and recent diagnostic information. Support logs may still
|
||||
contain sensitive details after automatic redaction, and anyone with the retrieval ID can access an
|
||||
uploaded log while it is available. Discord artwork is available through a temporary public URL.
|
||||
Plezy-hosted services also keep limited operational logs for security and reliability.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h3>Local Network Information</h3>
|
||||
<p>To connect to your media server on your local network, we may access:</p>
|
||||
<ul>
|
||||
<li>Local network device discovery information</li>
|
||||
<li>IP addresses of media servers on your network</li>
|
||||
</ul>
|
||||
<section aria-labelledby="choices">
|
||||
<h2 id="choices">Your choices</h2>
|
||||
<p>
|
||||
You can remove media-server and tracker connections, disable crash reporting, update checks, scrobbling,
|
||||
Companion Remote, Discord Rich Presence, and external-player routing, or leave Watch Together. You can
|
||||
delete downloads, clear the artwork cache, remove profiles, or delete app data. Support-log uploads are
|
||||
always explicit. Removing local data does not remove copies already held by a connected service, custom
|
||||
storage provider, external app, system home screen, or backup.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h3>Usage Information</h3>
|
||||
<p>The app stores locally on your device:</p>
|
||||
<ul>
|
||||
<li>Your authentication session</li>
|
||||
<li>App preferences and settings</li>
|
||||
<li>Recently accessed content history</li>
|
||||
</ul>
|
||||
<section aria-labelledby="retention-security">
|
||||
<h2 id="retention-security">Retention and security</h2>
|
||||
<p>
|
||||
Local connection and profile data remains until you remove it or delete app data. Downloads have no
|
||||
automatic expiry. Cached artwork is removed through cache maintenance or the Clear Cache action, and TV
|
||||
home-screen data remains until a later update or clear succeeds. Plezy-hosted crash events are configured
|
||||
for up to 90 days, support logs can be retrieved for three days, and Discord artwork is available for no
|
||||
more than three hours. OAuth handoffs and inactive Watch Together rooms are short-lived. Other providers
|
||||
set their own retention periods.
|
||||
</p>
|
||||
<p>
|
||||
Plezy uses HTTPS for its hosted services and supported cloud providers, limits sensitive uploads, and
|
||||
redacts known secrets from diagnostics. No system is completely secure, and user-configured servers,
|
||||
relays, external players, storage locations, and platform backups remain under their respective
|
||||
operators’ control.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h2>How We Use Your Information</h2>
|
||||
<p>We use the collected information solely to:</p>
|
||||
<ul>
|
||||
<li>Authenticate you with your Plex or Jellyfin account</li>
|
||||
<li>Connect to and communicate with your media server</li>
|
||||
<li>Display your media library and stream content</li>
|
||||
<li>Maintain your session and app preferences</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data Storage and Security</h2>
|
||||
<ul>
|
||||
<li>Authentication tokens are stored securely on your device using platform-specific secure storage mechanisms</li>
|
||||
<li>App preferences are stored locally on your device</li>
|
||||
<li>Crash and diagnostic data is sent to our self-hosted error tracking server (bugs.plezy.app) — no third-party services receive this data</li>
|
||||
<li>All other communication is directly between your device and your media server, or with Plex's authentication services when signing in with Plex</li>
|
||||
</ul>
|
||||
|
||||
<h2>Third-Party Services</h2>
|
||||
|
||||
<h3>Plex</h3>
|
||||
<p>Plezy uses Plex's authentication and media services. When you use this app:</p>
|
||||
<ul>
|
||||
<li>You authenticate directly with Plex's servers</li>
|
||||
<li>Your media streaming occurs between your device and your Plex Media Server</li>
|
||||
<li>Plex's own privacy policy applies to their services: <a href="https://www.plex.tv/about/privacy-legal/" target="_blank" rel="noopener noreferrer">plex.tv/about/privacy-legal</a></li>
|
||||
</ul>
|
||||
<p>We do not control and are not responsible for Plex's data practices.</p>
|
||||
|
||||
<h3>Jellyfin</h3>
|
||||
<p>Plezy can connect to your self-hosted Jellyfin server. When you use this app with Jellyfin:</p>
|
||||
<ul>
|
||||
<li>You authenticate directly with your Jellyfin server using your username and password, or via Quick Connect</li>
|
||||
<li>Your media streaming occurs between your device and your Jellyfin server</li>
|
||||
<li>No third party (including Plezy) is involved in Jellyfin authentication or playback</li>
|
||||
</ul>
|
||||
<p>Because Jellyfin is self-hosted, your Jellyfin server's privacy practices are determined by whoever operates it. More information about the Jellyfin project is available at <a href="https://jellyfin.org" target="_blank" rel="noopener noreferrer">jellyfin.org</a>.</p>
|
||||
|
||||
<h3>Litterbox (Discord Rich Presence)</h3>
|
||||
<p>When Discord Rich Presence is enabled, media artwork may be temporarily uploaded to:</p>
|
||||
<ul>
|
||||
<li>Litterbox (litterbox.catbox.moe) — a temporary file hosting service</li>
|
||||
</ul>
|
||||
<p>These uploads are necessary because Discord requires publicly accessible image URLs for Rich Presence artwork. Uploaded images automatically expire after 1 hour. This feature is opt-in and disabled by default.</p>
|
||||
|
||||
<h2>Data Sharing</h2>
|
||||
<p>We do not:</p>
|
||||
<ul>
|
||||
<li>Sell your personal information</li>
|
||||
<li>Share your information with third parties for marketing purposes</li>
|
||||
<li>Transmit your data to our own servers (except crash/diagnostic reports as described above)</li>
|
||||
<li>Track your viewing habits outside of local app functionality</li>
|
||||
</ul>
|
||||
<p>Your data is only shared with:</p>
|
||||
<ul>
|
||||
<li>Plex services for authentication and media server communication, when signing in with Plex (as required for the app to function)</li>
|
||||
<li>Your self-hosted Jellyfin server, when signing in with Jellyfin (as required for the app to function)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Your Rights and Choices</h2>
|
||||
<p>You can:</p>
|
||||
<ul>
|
||||
<li>Sign out at any time to remove your authentication session from the device</li>
|
||||
<li>Uninstall the app to remove all locally stored data</li>
|
||||
<li>Manage your Plex account directly through Plex's services, or your Jellyfin account through your Jellyfin server</li>
|
||||
</ul>
|
||||
|
||||
<h2>Children's Privacy</h2>
|
||||
<p>Plezy does not knowingly collect personal information from children under 13. The app relies on authentication with Plex or Jellyfin servers, and users must comply with the terms of service and age requirements of whichever service they connect to.</p>
|
||||
|
||||
<h2>Changes to This Privacy Policy</h2>
|
||||
<p>We may update this privacy policy from time to time. We will notify users of any material changes by updating the "Last Updated" date at the top of this policy.</p>
|
||||
|
||||
<h2>Data Retention</h2>
|
||||
<ul>
|
||||
<li>Authentication tokens are retained on your device until you sign out or uninstall the app</li>
|
||||
<li>Local preferences and settings are retained until you uninstall the app</li>
|
||||
<li>Crash and diagnostic reports are retained on our self-hosted server and automatically pruned based on relevance</li>
|
||||
</ul>
|
||||
|
||||
<h2>International Data Transfers</h2>
|
||||
<p>As we do not operate backend servers, there are no international data transfers from our side. Any data transfers occur directly between your device and either Plex's services (subject to Plex's privacy policy) or your Jellyfin server (governed by whoever operates it).</p>
|
||||
|
||||
<h2>Legal Compliance</h2>
|
||||
<p>This app complies with:</p>
|
||||
<ul>
|
||||
<li>General Data Protection Regulation (GDPR)</li>
|
||||
<li>California Consumer Privacy Act (CCPA)</li>
|
||||
<li>Children's Online Privacy Protection Act (COPPA)</li>
|
||||
<li>Other applicable privacy laws</li>
|
||||
</ul>
|
||||
|
||||
<h2>Your Consent</h2>
|
||||
<p>By using Plezy, you consent to this privacy policy and the processing of your information as described herein.</p>
|
||||
<section aria-labelledby="contact">
|
||||
<h2 id="contact">Contact and changes</h2>
|
||||
<p>
|
||||
For privacy questions or requests, contact us through the
|
||||
<a href="https://github.com/edde746/plezy" target="_blank" rel="noopener noreferrer">Plezy GitHub project</a>.
|
||||
GitHub activity is public, so do not include passwords, access tokens, support-log IDs, or other sensitive
|
||||
information. Material changes to this policy will be posted here with a new date.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.privacy-article {
|
||||
width: min(100%, 58rem);
|
||||
width: min(100%, 48rem);
|
||||
margin-inline: auto;
|
||||
padding: clamp(2rem, 7vw, 6rem) var(--page-gutter) clamp(5rem, 10vw, 9rem);
|
||||
}
|
||||
@@ -194,12 +167,12 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.back-logo :global(svg) {
|
||||
.back-logo :global(img) {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
|
||||
.privacy-heading {
|
||||
h1 {
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2.75rem, 9vw, 5rem);
|
||||
@@ -216,14 +189,16 @@
|
||||
}
|
||||
|
||||
.prose {
|
||||
max-width: 48rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.prose > section + section {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-display);
|
||||
@@ -233,29 +208,8 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.625rem;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-bottom: 1.125rem;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
margin-bottom: 1.25rem;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem 1.25rem 1.25rem 2.75rem;
|
||||
background: var(--color-surface);
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.prose li + li {
|
||||
margin-top: 0.5rem;
|
||||
.prose p + p {
|
||||
margin-top: 1.125rem;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const csr = false;
|
||||
export const prerender = true;
|
||||
@@ -1,21 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { onMount } from "svelte";
|
||||
import Logo from "$lib/components/Logo.svelte";
|
||||
import AppleIcon from "~icons/simple-icons/apple";
|
||||
import GooglePlayIcon from "~icons/simple-icons/googleplay";
|
||||
import {
|
||||
detectMobileStorePlatform,
|
||||
storeOptionsForPlatform,
|
||||
type MobileStorePlatform,
|
||||
} from "$lib/content/downloads";
|
||||
|
||||
type Platform = "ios" | "android" | "unknown";
|
||||
let platform: MobileStorePlatform = $state("unknown");
|
||||
let availableStores = $derived(storeOptionsForPlatform(platform));
|
||||
|
||||
let platform: Platform = $state("unknown");
|
||||
|
||||
if (browser) {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (/iphone|ipad|ipod/.test(ua)) {
|
||||
platform = "ios";
|
||||
} else if (/android/.test(ua)) {
|
||||
platform = "android";
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
platform = detectMobileStorePlatform({
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
maxTouchPoints: navigator.maxTouchPoints,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -44,30 +47,22 @@
|
||||
<h1 class="scan-heading">Scan in Plezy</h1>
|
||||
<p class="scan-description">To use this feature, scan this QR code with the Plezy app.</p>
|
||||
|
||||
<div class="store-buttons">
|
||||
{#if platform !== "android"}
|
||||
<div class="store-buttons" aria-label="Download Plezy">
|
||||
{#each availableStores as store}
|
||||
<a
|
||||
href="https://apps.apple.com/us/app/id6754315964"
|
||||
href={store.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="store-button"
|
||||
>
|
||||
<AppleIcon />
|
||||
App Store
|
||||
{#if store.id === "app-store"}
|
||||
<AppleIcon />
|
||||
{:else}
|
||||
<GooglePlayIcon />
|
||||
{/if}
|
||||
{store.label}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if platform !== "ios"}
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.edde746.plezy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="store-button"
|
||||
>
|
||||
<GooglePlayIcon />
|
||||
Google Play
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +98,7 @@
|
||||
background: var(--color-surface-highest);
|
||||
}
|
||||
|
||||
.scan-logo :global(svg) {
|
||||
.scan-logo :global(img) {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
|
||||
let vite: ViteDevServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
vite = await createServer({
|
||||
root: process.cwd(),
|
||||
appType: 'custom',
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await vite?.close();
|
||||
}, 30_000);
|
||||
|
||||
async function renderComponent(path: string): Promise<string> {
|
||||
const [{ default: component }, { render: renderOnViteGraph }] = await Promise.all([
|
||||
vite.ssrLoadModule(path),
|
||||
vite.ssrLoadModule('svelte/server'),
|
||||
]);
|
||||
return renderOnViteGraph(component).body;
|
||||
}
|
||||
|
||||
describe('SSR component contracts', () => {
|
||||
test('Logo renders the one managed asset as a decorative non-draggable image', async () => {
|
||||
const html = await renderComponent('/src/lib/components/Logo.svelte');
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('alt=""');
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
expect(html).toContain('draggable="false"');
|
||||
expect(html).not.toContain('<path');
|
||||
}, 30_000);
|
||||
|
||||
test('Linux downloads prerender as an ordinary inert disclosure', async () => {
|
||||
const html = await renderComponent('/src/lib/components/DownloadButtons.svelte');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain('aria-controls="');
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
expect(html).toContain('inert');
|
||||
expect(html).not.toContain('aria-haspopup');
|
||||
expect(html).not.toContain('role="menu"');
|
||||
expect(html).not.toContain('role="menuitem"');
|
||||
expect(html.match(/plezy-linux-(?:x64|arm64)\.(?:deb|rpm|pkg\.tar\.zst|tar\.gz)/g)).toHaveLength(8);
|
||||
}, 30_000);
|
||||
test('Screenshots prerender stable regions but only the initial lazy phone images', async () => {
|
||||
const html = await renderComponent('/src/lib/components/Screenshots.svelte');
|
||||
for (const device of ['phone', 'tablet', 'desktop', 'tv']) {
|
||||
expect(html).toContain(`id="screenshots-${device}-panel"`);
|
||||
}
|
||||
expect(html.match(/<picture\b/g)).toHaveLength(4);
|
||||
expect(html.match(/loading="lazy"/g)).toHaveLength(4);
|
||||
expect(html.match(/width="\d+" height="\d+"/g)).toHaveLength(4);
|
||||
expect(html).toContain('alt="Plezy home screen"');
|
||||
expect(html).toContain('style="opacity: 1; transform: translateY(0px);');
|
||||
expect(html).not.toContain('alt="Plezy on tablet - home"');
|
||||
expect(html).not.toContain('alt="Plezy on desktop - home"');
|
||||
expect(html).not.toContain('alt="Plezy on TV - home"');
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
|
||||
import { faqs, faqSchemaMainEntity, watchTogetherFaqAnswer } from '../src/lib/content/faqs';
|
||||
import { csr, prerender } from '../src/routes/privacy/+page';
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let renderedPrivacyHead: string;
|
||||
let renderedPrivacyHtml: string;
|
||||
let privacySource: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
vite = await createServer({
|
||||
root: process.cwd(),
|
||||
appType: 'custom',
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
const [{ default: privacyPage }, { render }] = await Promise.all([
|
||||
vite.ssrLoadModule('/src/routes/privacy/+page.svelte'),
|
||||
vite.ssrLoadModule('svelte/server'),
|
||||
]);
|
||||
const rendered = render(privacyPage);
|
||||
renderedPrivacyHead = rendered.head;
|
||||
renderedPrivacyHtml = rendered.body;
|
||||
privacySource = await Bun.file('src/routes/privacy/+page.svelte').text();
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await vite?.close();
|
||||
}, 30_000);
|
||||
|
||||
function visibleText(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll(''', "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
describe('privacy page', () => {
|
||||
test('remains a prerendered, server-rendered route', () => {
|
||||
expect(prerender).toBe(true);
|
||||
expect(csr).toBe(false);
|
||||
expect(renderedPrivacyHead).toContain('<title>Privacy Policy - Plezy</title>');
|
||||
expect(renderedPrivacyHead).toContain('<link rel="canonical" href="https://plezy.app/privacy"');
|
||||
});
|
||||
|
||||
test('renders a concise semantic policy without inventory cards or grids', () => {
|
||||
for (const heading of [
|
||||
'Overview',
|
||||
'Data on your device',
|
||||
'Connections and third parties',
|
||||
'Optional Plezy services',
|
||||
'Your choices',
|
||||
'Retention and security',
|
||||
'Contact and changes',
|
||||
]) {
|
||||
expect(renderedPrivacyHtml).toContain(`>${heading}</h2>`);
|
||||
}
|
||||
|
||||
expect(renderedPrivacyHtml.match(/<section/g)).toHaveLength(7);
|
||||
expect(renderedPrivacyHtml).not.toContain('flow-card');
|
||||
expect(renderedPrivacyHtml).not.toContain('<dl');
|
||||
expect(renderedPrivacyHtml).not.toContain('<ul');
|
||||
expect(privacySource).not.toContain('display: grid');
|
||||
expect(visibleText(renderedPrivacyHtml).split(' ').length).toBeLessThan(900);
|
||||
});
|
||||
|
||||
test('uses first-party language and preserves the material privacy boundaries', () => {
|
||||
const text = visibleText(renderedPrivacyHtml);
|
||||
|
||||
expect(text).not.toMatch(/\brepositor(?:y|ies)\b/i);
|
||||
expect(text).not.toMatch(/repository-verified/i);
|
||||
expect(text).toContain('We do not sell personal data or use it for advertising');
|
||||
expect(text).toContain('access tokens and other sign-in data');
|
||||
expect(text).toContain('may contain a server access credential');
|
||||
expect(text).toContain('does not carry the media stream or your media-server credentials');
|
||||
expect(text).toContain('Support-log uploads are always explicit');
|
||||
expect(text).toContain('up to 90 days');
|
||||
expect(text).toContain('retrieved for three days');
|
||||
expect(text).toContain('no more than three hours');
|
||||
expect(renderedPrivacyHtml).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
test('keeps the Watch Together disclosure aligned with visible FAQ content', () => {
|
||||
const faqIndex = faqs.findIndex((faq) => faq.id === 'watch-together');
|
||||
expect(faqIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(faqs[faqIndex]!.answer).toBe(watchTogetherFaqAnswer);
|
||||
expect(faqSchemaMainEntity[faqIndex]!.acceptedAnswer.text).toBe(watchTogetherFaqAnswer);
|
||||
expect(watchTogetherFaqAnswer).toContain('server and media identifiers');
|
||||
expect(watchTogetherFaqAnswer).toContain('does not relay the media stream');
|
||||
expect(watchTogetherFaqAnswer).toContain('custom relay');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
detectMobileStorePlatform,
|
||||
linuxArchitectures,
|
||||
storeOptionsForPlatform,
|
||||
} from '../src/lib/content/downloads';
|
||||
import {
|
||||
buildSoftwareApplicationOffers,
|
||||
normalizeUsdStorePrice,
|
||||
} from '../src/lib/content/software_app_offers';
|
||||
|
||||
type PlayStoreFixture = {
|
||||
available?: boolean;
|
||||
price?: unknown;
|
||||
currency?: unknown;
|
||||
score?: number;
|
||||
ratings?: number;
|
||||
};
|
||||
|
||||
let playStoreFixture: PlayStoreFixture | Error;
|
||||
|
||||
mock.module('google-play-scraper', () => ({
|
||||
default: {
|
||||
app: async () => {
|
||||
if (playStoreFixture instanceof Error) throw playStoreFixture;
|
||||
return playStoreFixture;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Dynamic loading is intentional here: Bun must install the scraper mock before
|
||||
// the route first loads its optional external dependency.
|
||||
const { load } = await import('../src/routes/+page.server');
|
||||
|
||||
function appleFetch(body: unknown, status = 200): typeof fetch {
|
||||
return (async () => new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})) as typeof fetch;
|
||||
}
|
||||
|
||||
async function loadHomepage(fetcher: typeof fetch) {
|
||||
return await (load as (event: { fetch: typeof fetch }) => Promise<{
|
||||
appStorePrice: string | null;
|
||||
playStorePrice: string | null;
|
||||
aggregateRating: { ratingValue: string; ratingCount: number } | null;
|
||||
}>)({ fetch: fetcher });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
playStoreFixture = {
|
||||
available: true,
|
||||
price: 4.99,
|
||||
currency: 'USD',
|
||||
score: 4.5,
|
||||
ratings: 10,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('download component contracts', () => {
|
||||
test('detects mobile stores without browser globals', () => {
|
||||
expect(detectMobileStorePlatform()).toBe('unknown');
|
||||
expect(detectMobileStorePlatform({ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)' })).toBe('ios');
|
||||
expect(detectMobileStorePlatform({ userAgent: 'Mozilla/5.0 (Linux; Android 15)' })).toBe('android');
|
||||
expect(detectMobileStorePlatform({
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
platform: 'MacIntel',
|
||||
maxTouchPoints: 5,
|
||||
})).toBe('ios');
|
||||
expect(detectMobileStorePlatform({
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
|
||||
platform: 'MacIntel',
|
||||
maxTouchPoints: 0,
|
||||
})).toBe('unknown');
|
||||
});
|
||||
|
||||
test('unknown platforms retain both truthful store choices', () => {
|
||||
expect(storeOptionsForPlatform('unknown').map((option) => option.label)).toEqual(['App Store', 'Google Play']);
|
||||
expect(storeOptionsForPlatform('ios').map((option) => option.label)).toEqual(['App Store']);
|
||||
expect(storeOptionsForPlatform('android').map((option) => option.label)).toEqual(['Google Play']);
|
||||
});
|
||||
|
||||
test('Linux disclosure data contains both architectures and eight unique native links', () => {
|
||||
expect(linuxArchitectures.map((architecture) => architecture.label)).toEqual(['x64 (Intel/AMD)', 'ARM64']);
|
||||
const links = linuxArchitectures.flatMap((architecture) => architecture.formats.map((format) => format.url));
|
||||
expect(links).toHaveLength(8);
|
||||
expect(new Set(links).size).toBe(8);
|
||||
expect(links.every((url) => url.startsWith('https://github.com/edde746/plezy/releases/latest/download/'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('homepage store metadata', () => {
|
||||
test('normalizes only finite nonnegative numeric USD prices', () => {
|
||||
expect(normalizeUsdStorePrice(4.99, 'USD')).toBe('4.99');
|
||||
expect(normalizeUsdStorePrice(0, 'USD')).toBe('0');
|
||||
for (const value of [null, undefined, '4.99', Number.NaN, Number.POSITIVE_INFINITY, -1]) {
|
||||
expect(normalizeUsdStorePrice(value, 'USD')).toBeNull();
|
||||
}
|
||||
expect(normalizeUsdStorePrice(4.99, undefined)).toBeNull();
|
||||
expect(normalizeUsdStorePrice(4.99, 'EUR')).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps Google Play metadata when the App Store request fails', async () => {
|
||||
const data = await loadHomepage((async () => {
|
||||
throw new Error('offline');
|
||||
}) as typeof fetch);
|
||||
expect(data.appStorePrice).toBeNull();
|
||||
expect(data.playStorePrice).toBe('4.99');
|
||||
expect(data.aggregateRating).toEqual({ ratingValue: '4.5', ratingCount: 10 });
|
||||
});
|
||||
|
||||
test('keeps App Store metadata when Google Play is unavailable', async () => {
|
||||
playStoreFixture = { available: false, price: 0, currency: 'USD' };
|
||||
const data = await loadHomepage(appleFetch({
|
||||
results: [{ price: 5.99, currency: 'USD', averageUserRating: 4, userRatingCount: 20 }],
|
||||
}));
|
||||
expect(data.appStorePrice).toBe('5.99');
|
||||
expect(data.playStorePrice).toBeNull();
|
||||
expect(data.aggregateRating).toEqual({ ratingValue: '4.0', ratingCount: 20 });
|
||||
});
|
||||
|
||||
test('keeps App Store metadata when Google Play throws', async () => {
|
||||
playStoreFixture = new Error('scraper failed');
|
||||
const data = await loadHomepage(appleFetch({
|
||||
results: [{ price: 5.99, currency: 'USD' }],
|
||||
}));
|
||||
expect(data.appStorePrice).toBe('5.99');
|
||||
expect(data.playStorePrice).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects non-OK and malformed store responses independently', async () => {
|
||||
playStoreFixture = { price: 'free', currency: 'USD' };
|
||||
const data = await loadHomepage(appleFetch({ results: [{ price: 4.99, currency: 'USD' }] }, 503));
|
||||
expect(data.appStorePrice).toBeNull();
|
||||
expect(data.playStorePrice).toBeNull();
|
||||
});
|
||||
|
||||
test('retains paid store URLs when live prices are unavailable', () => {
|
||||
const unavailable = buildSoftwareApplicationOffers({ appStorePrice: null, playStorePrice: null });
|
||||
expect(unavailable.map((offer) => offer.category)).toEqual([
|
||||
'App Store',
|
||||
'Google Play',
|
||||
'Amazon Appstore',
|
||||
'GitHub',
|
||||
]);
|
||||
expect(unavailable.find((offer) => offer.category === 'App Store')).toMatchObject({
|
||||
url: 'https://apps.apple.com/us/app/id6754315964',
|
||||
});
|
||||
expect(unavailable.find((offer) => offer.category === 'Google Play')).toMatchObject({
|
||||
url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy',
|
||||
});
|
||||
expect(unavailable.filter((offer) => offer.price === '0').map((offer) => offer.category)).toEqual(['GitHub']);
|
||||
expect(
|
||||
unavailable
|
||||
.filter((offer) => ['App Store', 'Google Play'].includes(offer.category))
|
||||
.every((offer) => !('price' in offer)),
|
||||
).toBe(true);
|
||||
|
||||
const available = buildSoftwareApplicationOffers({ appStorePrice: '5.99', playStorePrice: '4.99' });
|
||||
expect(available.find((offer) => offer.category === 'App Store')).toMatchObject({ price: '5.99', priceCurrency: 'USD' });
|
||||
expect(available.find((offer) => offer.category === 'Google Play')).toMatchObject({ price: '4.99', priceCurrency: 'USD' });
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,8 @@
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
},
|
||||
"exclude": ["tests"]
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user