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:
edde746
2026-07-24 03:56:40 +02:00
parent b41fb4fe75
commit 09656fa4d3
63 changed files with 5146 additions and 560 deletions
+29 -15
View File
@@ -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
View File
@@ -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
+26
View File
@@ -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.
+11 -6
View File
@@ -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;
}
}
+1
View File
@@ -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
View File
@@ -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
+60
View File
@@ -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"
+52
View File
@@ -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();
}
+41
View File
@@ -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."
]
}
+466
View File
@@ -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"
+14 -8
View File
@@ -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
+66 -10
View File
@@ -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:
+362
View File
@@ -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())
+3 -1
View File
@@ -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)
+155
View File
@@ -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())
+119 -19
View File
@@ -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:
+8
View File
@@ -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
+13
View File
@@ -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 ./...
+12
View File
@@ -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
+114
View File
@@ -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()
+415
View File
@@ -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()
+19
View File
@@ -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()
+133
View File
@@ -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()
+83 -13
View File
@@ -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()
+131
View File
@@ -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()
+193
View File
@@ -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()
+256
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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);
});
}
+158 -21
View File
@@ -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")
}
}
+1
View File
@@ -0,0 +1 @@
db7b9740fbc38dd7ecead775f2c04cbfced2cab95717f2ed1d00d1f0f4438a55
+31 -13
View File
@@ -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.
+56
View File
@@ -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'
+58 -11
View File
@@ -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 \
+318
View File
@@ -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."
}
]
}
+2
View File
@@ -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",
-9
View File
@@ -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}
+1 -1
View File
@@ -40,7 +40,7 @@
font-weight: 700;
}
.footer-logo :global(svg) {
.footer-logo :global(img) {
width: 1.5rem;
height: 1.5rem;
}
+1 -1
View File
@@ -113,7 +113,7 @@
font-weight: 700;
}
.brand-logo :global(svg) {
.brand-logo :global(img) {
width: 2.25rem;
height: 2.25rem;
}
+16 -16
View File
@@ -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>
+25 -13
View File
@@ -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>
+42 -12
View File
@@ -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>
+67
View File
@@ -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;
+4 -2
View File
@@ -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. Plezys relay is the default; if you choose a custom relay, that relays 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',
},
];
}
+1 -1
View File
@@ -55,7 +55,7 @@
background: var(--color-surface-highest);
}
.error-logo :global(svg) {
.error-logo :global(img) {
width: 2.5rem;
height: 2.5rem;
}
+12 -8
View File
@@ -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
}
+5 -28
View File
@@ -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) {
+116 -162
View File
@@ -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) &mdash; 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) &mdash; 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 {
+2
View File
@@ -0,0 +1,2 @@
export const csr = false;
export const prerender = true;
+26 -31
View File
@@ -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;
}
+61
View File
@@ -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);
});
+95
View File
@@ -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('&amp;', '&')
.replaceAll('&#39;', "'")
.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');
});
});
+168
View File
@@ -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' });
});
});
+2 -1
View File
@@ -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
//