diff --git a/.audit/shin-repair.tsv b/.audit/shin-repair.tsv deleted file mode 100644 index 6930bbfb..00000000 --- a/.audit/shin-repair.tsv +++ /dev/null @@ -1,10 +0,0 @@ -ts phase decision why evidence result -2026-09-19T05:45:36Z diagnosis treated the OBS shutdown crash as part of the stability failure four crashes share the same plugin teardown frame coredumpctl info 147864, ~/.config/obs-studio/logs/2026-09-19 11-10-33.txt confirmed openstream_unregister_dock releases an OBS source during shutdown -2026-09-19T05:50:30Z pairing probe tested the installed plugin with a current-format simulated phone separates Linux plugin discovery from the unknown installed Android build ~/.config/obs-studio/logs/2026-09-19 11-20-30.txt plugin sent POST /reserve within 4.5 seconds -2026-09-19T05:52:13Z paired-state probe tested a busy simulated phone reserved for the saved CAM A source matches the phone's reported paired state instead of testing only a free phone ~/.config/obs-studio/logs/2026-09-19 11-22-10.txt plugin discovered the busy phone and sent POST /reserve -2026-09-19T06:08:00Z isolation created a separate shin wire identity and removed the OBS dock avoids source/plugin/app collisions and the proven dock teardown crash git diff; ldd installed shin-obs.so shin uses dev.shin.app, shin_phone_source, SHIN/1, ports 51615/9100/9101, and no frontend or Widgets dependency -2026-09-19T06:12:00Z compatibility added selected-host unicast to broadcast and multicast discovery phones that receive OBS beacons must be able to advertise back even when reverse multicast is filtered PhoneDiscoveryAdvertiser.kt bounded one-second beacon loop now targets broadcast, multicast, and the selected OBS address -2026-09-19T06:14:00Z build built matched Android and Linux artifacts both sides must share the same protocol before installation Gradle assembleDebug; build_plugin_linux.sh Android succeeded; all 5 C++ tests passed -2026-09-19T06:15:00Z installation installed shin and disabled the crashing OpenStream module recoverably prevents both modules from loading and preserves rollback ~/.config/obs-studio/plugins and disabled-plugins shin-obs.so is the only third-party camera module loaded; scene backup created -2026-09-19T06:17:00Z media smoke test ran real OBS against a synthetic H.264/AAC SRT phone verifies discovery, reservation, control, transport, decode, timestamps, frame output, release, and shutdown ~/.config/obs-studio/logs/2026-09-19 11-44-05.txt OBS received 640x360 H.264 plus AAC, emitted decoded frames, released reservation, and exited with no new core dump -2026-09-19T06:23:00Z independent review fixed stale Linux CI artifact and dependency expectations the renamed module and removed UI dependencies changed the packaging contract gpt-6-astra review; .github/workflows/obs-plugin-linux.yml Linux CI now verifies shin paths and rejects frontend, Widgets, or Network linkage; Windows keeps its existing artifact contract diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8d72d8f4..b2355f0f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -29,11 +29,6 @@ jobs: - name: Set up Android build environment uses: ./.github/actions/android-setup - - name: Compute Android version metadata - id: version - run: | - echo "code=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT" - - name: Run repository tests run: | python -m pip install --disable-pip-version-check pytest @@ -41,9 +36,6 @@ jobs: - name: Run Android unit tests and lint, then build APK working-directory: android - env: - OPENSTREAM_VERSION_NAME: 1.0.1 - OPENSTREAM_VERSION_CODE: ${{ steps.version.outputs.code }} run: | chmod +x ./gradlew ./gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug :app:assembleDebug diff --git a/.github/workflows/obs-plugin-linux.yml b/.github/workflows/obs-plugin-linux.yml index 79cfb1aa..4267615f 100644 --- a/.github/workflows/obs-plugin-linux.yml +++ b/.github/workflows/obs-plugin-linux.yml @@ -48,6 +48,7 @@ jobs: # provides libobs-dev 30.0.2 and qt6-base-dev 6.4.2. pkg-config --atleast-version=30 libobs pkg-config --atleast-version=6.2 Qt6Core + pkg-config --atleast-version=6.2 Qt6Widgets - name: Build plugin package run: | @@ -56,12 +57,14 @@ jobs: - name: Verify system ABI linkage run: | - test -f obs-plugin/build/shin-obs.so - ldd obs-plugin/build/shin-obs.so - ldd obs-plugin/build/shin-obs.so | grep -E "libobs\.so" - ldd obs-plugin/build/shin-obs.so | grep -E "libavformat\.so" - ldd obs-plugin/build/shin-obs.so | grep -E "libQt6Core" - ! ldd obs-plugin/build/shin-obs.so | grep -E "libQt6(Widgets|Network)|obs-frontend" + test -f obs-plugin/build/openstream-obs.so + ldd obs-plugin/build/openstream-obs.so + ldd obs-plugin/build/openstream-obs.so | grep -E "libobs\.so" + ldd obs-plugin/build/openstream-obs.so | grep -E "libobs-frontend-api\.so" + ldd obs-plugin/build/openstream-obs.so | grep -E "libavformat\.so" + ldd obs-plugin/build/openstream-obs.so | grep -E "libQt6Core" + ldd obs-plugin/build/openstream-obs.so | grep -E "libQt6Widgets" + nm -C obs-plugin/build/openstream-obs.so | grep -E "openstream_(register|unregister)_dock\(\)" - name: Run repository behavioural tests (pytest) run: | @@ -70,21 +73,21 @@ jobs: - name: Verify Linux package tarball and installer layout run: | - test -f artifacts/shin-obs-linux-x86_64.tar.gz - tar -tzf artifacts/shin-obs-linux-x86_64.tar.gz + test -f artifacts/openstream-obs-linux-x86_64.tar.gz + tar -tzf artifacts/openstream-obs-linux-x86_64.tar.gz tmpdir="$(mktemp -d)" - tar -xzf artifacts/shin-obs-linux-x86_64.tar.gz -C "$tmpdir" - test -f "$tmpdir/shin-obs-linux-x86_64/shin-obs.so" - test -f "$tmpdir/shin-obs-linux-x86_64/install-shin-plugin-linux.sh" + tar -xzf artifacts/openstream-obs-linux-x86_64.tar.gz -C "$tmpdir" + test -f "$tmpdir/openstream-obs-linux-x86_64/openstream-obs.so" + test -f "$tmpdir/openstream-obs-linux-x86_64/install-openstream-plugin-linux.sh" # Installer smoke test in an isolated --dest-dir: validates the # packaged no-argument sibling lookup without touching HOME. - (cd "$tmpdir/shin-obs-linux-x86_64" && ./install-shin-plugin-linux.sh --dest-dir "$tmpdir/install-root") - test -f "$tmpdir/install-root/shin-obs.so" + (cd "$tmpdir/openstream-obs-linux-x86_64" && ./install-openstream-plugin-linux.sh --dest-dir "$tmpdir/install-root") + test -f "$tmpdir/install-root/openstream-obs.so" rm -rf "$tmpdir" - name: Upload plugin package uses: actions/upload-artifact@v4 with: - name: shin-obs-linux-x86_64 - path: artifacts/shin-obs-linux-x86_64.tar.gz + name: openstream-obs-linux-x86_64 + path: artifacts/openstream-obs-linux-x86_64.tar.gz if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f7f9db3..a4b50371 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,8 @@ jobs: - name: Check out repository uses: actions/checkout@v4 with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 persist-credentials: false - name: Set up Android build environment @@ -37,11 +39,6 @@ jobs: chmod +x ./gradlew ./gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug - - name: Compute Android version metadata - id: version - run: | - echo "code=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT" - - name: Require Android signing inputs env: OPENSTREAM_RELEASE_KEYSTORE_BASE64: ${{ secrets.OPENSTREAM_RELEASE_KEYSTORE_BASE64 }} @@ -64,16 +61,65 @@ jobs: printf '%s' "$OPENSTREAM_RELEASE_KEYSTORE_BASE64" | base64 --decode > android/app/openstream-release.keystore - name: Build signed release APK with native SRT enabled + id: release-version working-directory: android env: OPENSTREAM_RELEASE_KEYSTORE: ${{ github.workspace }}/android/app/openstream-release.keystore OPENSTREAM_RELEASE_STORE_PASSWORD: ${{ secrets.OPENSTREAM_RELEASE_STORE_PASSWORD }} OPENSTREAM_RELEASE_KEY_ALIAS: ${{ secrets.OPENSTREAM_RELEASE_KEY_ALIAS }} OPENSTREAM_RELEASE_KEY_PASSWORD: ${{ secrets.OPENSTREAM_RELEASE_KEY_PASSWORD }} - OPENSTREAM_VERSION_NAME: ${{ inputs.tag || github.ref_name }} - OPENSTREAM_VERSION_CODE: ${{ steps.version.outputs.code }} + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} run: | + set -euo pipefail chmod +x ./gradlew + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Release tag must be vMAJOR.MINOR.PATCH (for example v1.0.1)." + exit 1 + fi + TAG_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + TAG_COMMIT=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}") + CHECKED_OUT_COMMIT=$(git rev-parse HEAD) + test "$TAG_COMMIT" = "$CHECKED_OUT_COMMIT" || { + echo "::error::Release tag $RELEASE_TAG does not point at checked-out commit $CHECKED_OUT_COMMIT." + exit 1 + } + if [[ "$GITHUB_REF_TYPE" == "tag" ]] && [[ "$TAG_COMMIT" != "$GITHUB_SHA" ]]; then + echo "::error::Release tag $RELEASE_TAG moved after this workflow was triggered." + exit 1 + fi + + CONFIGURED_VERSION=$(sed -n 's/^openstream.versionName=//p' gradle.properties) + test "$CONFIGURED_VERSION" = "$TAG_VERSION" || { + echo "::error::Set openstream.versionName in android/gradle.properties to $TAG_VERSION before releasing." + exit 1 + } + VERSION_CODE=$(sed -n 's/^openstream.versionCode=//p' gradle.properties) + if [[ ! "$VERSION_CODE" =~ ^[1-9][0-9]{0,9}$ ]] || (( VERSION_CODE > 2100000000 )); then + echo "::error::openstream.versionCode must be an integer from 1 to 2100000000." + exit 1 + fi + + # Older releases used the tag commit timestamp as versionCode. Use + # that value for historical tags that predate the canonical property. + MAX_PREVIOUS_CODE=0 + while IFS= read -r existing_tag; do + [[ "$existing_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue + [[ "$existing_tag" == "$RELEASE_TAG" ]] && continue + old_properties=$(git show "${existing_tag}:android/gradle.properties" 2>/dev/null || true) + old_code=$(sed -n 's/^openstream.versionCode=//p' <<< "$old_properties") + if [[ ! "$old_code" =~ ^[1-9][0-9]*$ ]]; then + old_code=$(git log -1 --format=%ct "$existing_tag") + fi + if [[ "$old_code" =~ ^[1-9][0-9]*$ ]] && (( old_code > MAX_PREVIOUS_CODE )); then + MAX_PREVIOUS_CODE=$old_code + fi + done < <(git tag --list 'v*') + if (( VERSION_CODE <= MAX_PREVIOUS_CODE )); then + echo "::error::openstream.versionCode ($VERSION_CODE) must exceed every previous release code ($MAX_PREVIOUS_CODE)." + exit 1 + fi + echo "version_name=$TAG_VERSION" >> "$GITHUB_OUTPUT" + echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT" ./gradlew --no-daemon :app:assembleRelease - name: Normalize Android APK artifact @@ -85,18 +131,43 @@ jobs: - name: Verify 16 KB native-library alignment run: python tools/check_android_16kb_alignment.py dist/openstream-android.apk - - name: Verify APK signature and versionName matches tag + - name: Verify APK signature and release metadata + env: + EXPECTED_VERSION: ${{ steps.release-version.outputs.version_name }} + EXPECTED_VERSION_CODE: ${{ steps.release-version.outputs.version_code }} run: | - TAG_NAME="${{ inputs.tag || github.ref_name }}" - EXPECTED_VERSION="${TAG_NAME#v}" APKSIGNER=$(ls "$ANDROID_SDK_ROOT/build-tools/"*/apksigner 2>/dev/null | sort -V | tail -n 1) test -n "$APKSIGNER" "$APKSIGNER" verify --print-certs dist/openstream-android.apk AAPT=$(ls "$ANDROID_SDK_ROOT/build-tools/"*/aapt 2>/dev/null | sort -V | tail -n 1) test -n "$AAPT" - ACTUAL_VERSION=$("$AAPT" dump badging dist/openstream-android.apk | sed -n "s/.*versionName='\([^']*\)'.*/\1/p") - test -n "$ACTUAL_VERSION" + APK_METADATA=$("$AAPT" dump badging dist/openstream-android.apk) + ACTUAL_APPLICATION_ID=$(sed -n "s/^package: name='\([^']*\)'.*/\1/p" <<< "$APK_METADATA") + ACTUAL_VERSION_CODE=$(sed -n "s/^package:.*versionCode='\([^']*\)'.*/\1/p" <<< "$APK_METADATA") + ACTUAL_VERSION=$(sed -n "s/^package:.*versionName='\([^']*\)'.*/\1/p" <<< "$APK_METADATA") + test "$ACTUAL_APPLICATION_ID" = "dev.openstream.app" test "$ACTUAL_VERSION" = "$EXPECTED_VERSION" + test "$ACTUAL_VERSION_CODE" = "$EXPECTED_VERSION_CODE" + + - name: Verify APK uses the previous public release signing identity + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if ! LATEST_TAG=$(gh release view --json tagName --jq .tagName 2>/dev/null); then + echo "No published release exists yet; signing continuity check is not applicable." + exit 0 + fi + mkdir -p "$RUNNER_TEMP/previous-release" + gh release download "$LATEST_TAG" --pattern openstream-android.apk --dir "$RUNNER_TEMP/previous-release" + APKSIGNER=$(ls "$ANDROID_SDK_ROOT/build-tools/"*/apksigner 2>/dev/null | sort -V | tail -n 1) + CURRENT_CERT=$("$APKSIGNER" verify --print-certs dist/openstream-android.apk | sed -n 's/.*certificate SHA-256 digest: //p' | tr '[:upper:]' '[:lower:]' | sort) + PREVIOUS_CERT=$("$APKSIGNER" verify --print-certs "$RUNNER_TEMP/previous-release/openstream-android.apk" | sed -n 's/.*certificate SHA-256 digest: //p' | tr '[:upper:]' '[:lower:]' | sort) + test -n "$CURRENT_CERT" && test -n "$PREVIOUS_CERT" + if [[ "$CURRENT_CERT" != "$PREVIOUS_CERT" ]]; then + echo "::error::Android signing certificate differs from published release $LATEST_TAG; existing app installations cannot update in place." + exit 1 + fi - name: Upload APK artifact uses: actions/upload-artifact@v4 @@ -116,6 +187,8 @@ jobs: - name: Check out repository uses: actions/checkout@v4 with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 persist-credentials: false - name: Set up OBS and Qt build environment @@ -163,8 +236,9 @@ jobs: - name: Build plugin installer shell: powershell env: - OPENSTREAM_VERSION: ${{ inputs.tag || github.ref_name }} + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} run: | + $env:OPENSTREAM_VERSION = $env:RELEASE_TAG -replace '^v', '' $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" & $iscc "tools\installer\openstream-obs-plugin.iss" ` "/DPluginDll=$PWD\obs-plugin\build\openstream-obs.dll" ` @@ -178,6 +252,11 @@ jobs: & tools\installer\Test-OpenStreamInnoInstaller.ps1 ` -InstallerPath "$PWD\artifacts\openstream-obs-plugin-installer-windows-x64.exe" + - name: Publish SHA256 for OBS installer + shell: powershell + run: | + (Get-FileHash artifacts\openstream-obs-plugin-installer-windows-x64.exe -Algorithm SHA256).Hash.ToLowerInvariant() + " openstream-obs-plugin-installer-windows-x64.exe" | Out-File -FilePath artifacts\openstream-obs-plugin-installer-windows-x64.exe.sha256 -Encoding ascii -NoNewline + - name: Upload plugin artifact uses: actions/upload-artifact@v4 with: @@ -186,13 +265,56 @@ jobs: artifacts/openstream-obs-windows-x64.zip artifacts/openstream-obs-windows-x64.zip.sha256 artifacts/openstream-obs-plugin-installer-windows-x64.exe + artifacts/openstream-obs-plugin-installer-windows-x64.exe.sha256 + if-no-files-found: error + + obs-plugin-linux: + name: Build native Linux OBS plugin + runs-on: ubuntu-24.04 + timeout-minutes: 60 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake g++ pkg-config libobs-dev libavformat-dev libavcodec-dev \ + libavutil-dev libswscale-dev qt6-base-dev + + - name: Build, test, and package Linux plugin + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + OPENSTREAM_PLUGIN_PACKAGE_DIR: ${{ github.workspace }}/artifacts + run: | + export OPENSTREAM_VERSION="${RELEASE_TAG#v}" + ./build_plugin_linux.sh + + - name: Publish SHA256 for Linux package + run: | + cd artifacts + sha256sum openstream-obs-linux-x86_64.tar.gz > openstream-obs-linux-x86_64.tar.gz.sha256 + + - name: Upload Linux plugin artifact + uses: actions/upload-artifact@v4 + with: + name: openstream-obs-linux-x86_64 + path: | + artifacts/openstream-obs-linux-x86_64.tar.gz + artifacts/openstream-obs-linux-x86_64.tar.gz.sha256 if-no-files-found: error publish: name: Publish GitHub release runs-on: ubuntu-latest timeout-minutes: 15 - needs: [android, obs-plugin] + needs: [android, obs-plugin, obs-plugin-linux] permissions: contents: write @@ -200,6 +322,8 @@ jobs: - name: Check out repository uses: actions/checkout@v4 with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 persist-credentials: false - name: Download build artifacts @@ -213,24 +337,33 @@ jobs: test -f dist/openstream-android.apk test -f dist/openstream-android.apk.sha256 test -f dist/openstream-obs-plugin-installer-windows-x64.exe + test -f dist/openstream-obs-plugin-installer-windows-x64.exe.sha256 test -f dist/openstream-obs-windows-x64.zip test -f dist/openstream-obs-windows-x64.zip.sha256 + test -f dist/openstream-obs-linux-x86_64.tar.gz + test -f dist/openstream-obs-linux-x86_64.tar.gz.sha256 (cd dist && sha256sum --check openstream-android.apk.sha256) (cd dist && sha256sum --check openstream-obs-windows-x64.zip.sha256) + (cd dist && sha256sum --check openstream-obs-plugin-installer-windows-x64.exe.sha256) + (cd dist && sha256sum --check openstream-obs-linux-x86_64.tar.gz.sha256) - name: Create release env: GH_TOKEN: ${{ github.token }} INPUT_TAG: ${{ inputs.tag }} run: | + set -euo pipefail TAG_NAME="${INPUT_TAG:-${GITHUB_REF_NAME}}" + RELEASE_COMMIT=$(git rev-parse HEAD) gh release create "$TAG_NAME" \ dist/openstream-android.apk \ dist/openstream-android.apk.sha256 \ dist/openstream-obs-plugin-installer-windows-x64.exe \ + dist/openstream-obs-plugin-installer-windows-x64.exe.sha256 \ dist/openstream-obs-windows-x64.zip \ dist/openstream-obs-windows-x64.zip.sha256 \ - --target "$GITHUB_SHA" \ + dist/openstream-obs-linux-x86_64.tar.gz \ + dist/openstream-obs-linux-x86_64.tar.gz.sha256 \ + --target "$RELEASE_COMMIT" \ --title "OpenStream ${TAG_NAME}" \ --notes-file docs/release-notes-template.md - diff --git a/.gitignore b/.gitignore index 931d49ce..1af0ff9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ compact.md .agents/ +.claude/ skills-lock.json working/ diff --git a/AGENTS.md b/AGENTS.md index 10af09fd..dbf2597c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,34 @@ # OpenStream Repository Rules +## Product objective + +OpenStream is one product made from the Android camera app and the native Linux +OBS plugin. Keep changes focused on making that complete Android-to-OBS path +work reliably: discovery, pairing, reservation, camera controls, SRT media, +disconnect, and reconnect must agree across both sides. A change to one side +must preserve or deliberately update its counterpart and the protocol docs. + +The Android app must be straightforward to update during development and in +releases. Keep its `applicationId` stable, keep update signing consistent, and +use one documented version source rather than manually changing version +strings in unrelated files. Before changing the application ID or signing +identity, establish why existing installations cannot be upgraded and document +the migration impact. Version code must increase for releases; development APKs +should have a clear install/update command and explain signature or downgrade +errors when those prevent in-place installation. + +Improve the UI and UX around the actual camera workflow: show clear connection, +reservation, streaming, and failure states; make connect/disconnect and phone +selection discoverable; and keep Android and OBS labels/actions consistent. +Prefer fixes tied to observed user friction over broad visual changes that do +not improve the end-to-end workflow. + +For each change, check which part of the Android-to-Linux OBS workflow it +affects, preserve compatibility where required, and leave the system in a +state that can be built, installed, and manually exercised. Do not claim the +whole product works based only on unit or contract tests; report which parts +were actually verified and which require a physical Android device or OBS. + These rules apply to every OpenStream change. 1. Never replace source timestamps with arrival time. @@ -25,4 +54,3 @@ These rules apply to every OpenStream change. ## Completion workflow After completing a task, create a pull request on GitHub for the finished changes. - diff --git a/README.md b/README.md index b17ecadd..7318a00c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ not included in this release. | 1 | [`openstream-android.apk`](https://github.com/YashasVM/OpenStream/releases/latest/download/openstream-android.apk) | Android phone | | 2 | [`openstream-obs-plugin-installer-windows-x64.exe`](https://github.com/YashasVM/OpenStream/releases/latest/download/openstream-obs-plugin-installer-windows-x64.exe) | Windows OBS PC | | Fallback | [`openstream-obs-windows-x64.zip`](https://github.com/YashasVM/OpenStream/releases/latest/download/openstream-obs-windows-x64.zip) | Manual OBS plugin install | +| Linux candidate | [Build and install from source](docs/linux.md) | Native Linux OBS; the next tagged release will include a matched Linux package | Need the non-technical walkthrough with screenshots? Start with [`docs/set-up.md`](docs/set-up.md). @@ -49,7 +50,7 @@ Phone camera -> hardware AVC/H.264 + AAC -> SRT over Wi-Fi -> OpenStream camera | Area | V1.0.1 release detail | |---|---| -| **OBS setup** | The OpenStream Camera Control dock provides the legacy connection and camera controls. | +| **OBS setup** | The V1.0.1 release uses its legacy source controls. The current candidate exposes controls in source properties. | | **Scene compatibility** | Existing `OpenStream V7` scene sources keep loading while new sources appear as `OpenStream V8`. | | **Pairing clarity** | One camera per OBS process, with a simple computer picker on Android. | | **Release polish** | Version defaults, release notes, and setup docs are aligned around V1.0.1. | @@ -60,6 +61,16 @@ Phone camera -> hardware AVC/H.264 + AAC -> SRT over Wi-Fi -> OpenStream camera Download the APK from the release, copy it to your Android phone, open it, and allow the install prompt. Grant camera and microphone permissions on first launch. +### Updating a local development build + +Connect the phone with USB debugging enabled and run `cd android && ./gradlew +installDebug`. Repeating this command installs the updated app in place when +the installed build has the same application ID and signing key. The version +name and code come from `android/gradle.properties`; increase the code there +for a release. Android rejects an in-place update if the APK uses a different +signing key, such as when switching between a local debug build and the +published release. Keep using the same build type for day-to-day updates. + ### 2. Install the OBS Plugin Download and run `openstream-obs-plugin-installer-windows-x64.exe` on the Windows PC that has OBS Studio installed. Restart OBS after the installer finishes. @@ -123,14 +134,14 @@ For native Linux build and installation instructions, see [docs/linux.md](docs/l | Feature | Details | |---|---| | **Native OBS Source** | Adds an `OpenStream V8` source type inside OBS Studio while preserving V7 scene compatibility. | -| **Camera Dock** | One camera with connect, retry, stop, zoom, torch, and lens controls. | +| **OBS controls** | Connection, phone selection, zoom, torch, lens, and identify controls are exposed through source properties. | | **One-Click Installer** | Windows installer copies the plugin into the OBS plugin folder. | -| **Single Camera** | One active OpenStream source per OBS process. Reuse that source across scenes. | +| **Phone ownership** | Each phone can be reserved by one OBS source at a time. Reuse that source across scenes. | | **Phone Discovery** | Lists discovered Android phones, includes a refresh action, and can let the phone choose the OBS computer. | | **Auto-Connect** | Listens for the Android app and connects without typing IP addresses. | | **Deep-Link Pairing URL** | Exposes an `openstream://connect` pairing URL with slot, port, latency, and source identity. | | **Separate Audio Mixer** | Phone microphone audio uses the OpenStream source’s OBS mixer channel. | -| **Remote Controls** | Adjust zoom, torch, rear/front camera, and the phone identify overlay from the persistent dock. | +| **Remote Controls** | Adjust zoom, torch, rear/front camera, and the phone identify overlay from OBS source properties. | | **Reconnect Handling** | Reserves and releases phones per source so streams recover into the same OBS source. | --- @@ -140,7 +151,7 @@ For native Linux build and installation instructions, see [docs/linux.md](docs/l | Moment | Experience | |---|---| | **First launch** | The phone explains camera/microphone access, then presents a guided discovery state over the camera preview. | -| **OBS setup** | Add one `OpenStream V8` source and control it from the Camera Control dock. | +| **OBS setup** | Add a camera source and use its properties to select a phone and control the connection. | | **Pairing** | The phone scans for available OBS computers and provides refresh, empty-state recovery, and manual fallback. | | **Going live** | A `LIVE` badge, zoom chip, stream stats, and status text make the active connection visible at a glance. | | **Other scenes** | Choose Add Existing to reuse your camera without opening another session. | @@ -159,7 +170,7 @@ Android phone MPEG-TS muxer libsrt sender | - | SRT media stream on port 9000 + | SRT media stream on port 9100 v Windows or native Linux PC OBS Studio @@ -168,7 +179,7 @@ Windows or native Linux PC OBS video frame + audio mixer output ``` -Transport defaults (SRT `:9000`, control `:9001`, discovery `:51515`, `12 Mbps`, `120 ms`) are canonical in [docs/protocol.md](docs/protocol.md). +Current candidate defaults (SRT `:9100`, phone control `:9101`, discovery `:51615`, `12 Mbps`, `120 ms`) are canonical in [docs/protocol.md](docs/protocol.md). The standalone Python receiver remains a developer tool and can use a separately selected port. --- @@ -245,7 +256,7 @@ See [`docs/release.md`](docs/release.md) for release tagging and validation. Validate SRT transport without OBS: ```powershell -python tools/openstream_receiver.py --port 9000 --latency-ms 120 --ffplay +python tools/openstream_receiver.py --port 9100 --latency-ms 120 --ffplay ``` This is a developer/debug tool only. Normal users should install the OBS plugin. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b0017b9c..359df775 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,24 +16,24 @@ val hasReleaseSigning = listOf( releaseKeyPassword, ).all { !it.isNullOrBlank() } val openStreamVersionName = providers.gradleProperty("openstream.versionName") - .orElse(providers.environmentVariable("OPENSTREAM_VERSION_NAME")) - .orElse("1.0.1") - .map { it.removePrefix("v") } + .get() + .removePrefix("v") val openStreamVersionCode = providers.gradleProperty("openstream.versionCode") - .orElse(providers.environmentVariable("OPENSTREAM_VERSION_CODE")) - .orElse("2") .map { it.toInt() } + .get() android { namespace = "dev.openstream.app" compileSdk = 35 defaultConfig { - applicationId = "dev.shin.app" + // Keep the published OpenStream identity so existing installs update + // in place when they use the same release signing key. + applicationId = "dev.openstream.app" minSdk = 29 targetSdk = 35 - versionCode = openStreamVersionCode.get() - versionName = openStreamVersionName.get() + versionCode = openStreamVersionCode + versionName = openStreamVersionName externalNativeBuild { cmake { @@ -110,4 +110,9 @@ kotlin { dependencies { testImplementation("junit:junit:4.13.2") + // Unit tests run on the local JVM against android.jar stubs, where + // org.json.JSONObject methods throw "not mocked". Ship the real + // org.json implementation for tests only; on-device code keeps using + // the platform's built-in org.json. + testImplementation("org.json:json:20240303") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6f6b6df5..0fbd7ba7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,16 @@ android:host="connect" android:scheme="shin" /> + + + + + + + + = emptyList() private var activeStreamBitrate: Int = streamConfig.bitrate private var lastObsSlotRenderKeys: List? = null private val callerLifecycleLock = Any() + private val reservedBy: String? + get() = reservationState.confirmedSourceInstanceId + + private val reservedSlotLabel: String? + get() = reservationState.confirmedReservation?.slotLabel?.takeIf { it.isNotBlank() } + + private val advertisedReservationId: String? + get() = reservationState.advertisedSourceInstanceId + + private fun isPhoneBusy(): Boolean = reservationState.isBusy(phoneConnected) + private val statsTicker = object : Runnable { override fun run() { renderStreamStats() @@ -126,24 +137,28 @@ class MainActivity : Activity() { ?: ConnectionTarget.DEFAULT_PORT streamClient = SrtStreamClient() + if (!streamClient.isNativeAvailable) { + statusDetail.text = + "SRT unavailable: ${streamClient.nativeLoadError?.message ?: "missing native lib"}" + } phoneAdvertiser = PhoneDiscoveryAdvertiser( - context = this, + context = applicationContext, config = streamConfig, port = currentPort, - busyProvider = { phoneConnected || reservedBy != null }, - reservedByProvider = { reservedBy }, + busyProvider = { isPhoneBusy() }, + reservedByProvider = { advertisedReservationId }, selectedObsHostProvider = { selectedObsHost }, ) obsDiscoveryClient = ObsDiscoveryClient( - context = this, + context = applicationContext, onDevicesChanged = { devices -> currentDevices = devices - renderObsSlots(devices) + if (activityStarted) renderObsSlots(devices) }, ) encoder = createVideoEncoder(activeStreamBitrate) audioEncoder = MediaCodecAudioEncoder( - context = this, + context = applicationContext, sampleRate = streamConfig.audioSampleRate, channelCount = streamConfig.audioChannelCount, bitrate = streamConfig.audioBitrate, @@ -157,7 +172,7 @@ class MainActivity : Activity() { }, ) camera = Camera2Controller( - context = this, + context = applicationContext, previewSurfaceProvider = { cameraPreview.holder.surface }, lensProvider = { currentLens }, targetFps = streamConfig.fps, @@ -179,6 +194,7 @@ class MainActivity : Activity() { runOnUiThread { statusText.text = "Paired to ${slotLabel.ifBlank { "OBS computer" }}" statusDetail.text = "OBS acknowledged; waiting to go live" + renderDisconnectVisibility() renderObsSlots(currentDevices) } } @@ -186,6 +202,7 @@ class MainActivity : Activity() { }, onRelease = { sourceInstanceId -> releaseForSource(sourceInstanceId) }, onIdentify = { label, subtitle -> runOnUiThread { showIdentifyOverlay(label, subtitle) } }, + onError = { message -> runOnUiThread { statusDetail.text = message } }, ) cameraPreview.holder.addCallback(object : SurfaceHolder.Callback { @@ -238,17 +255,24 @@ class MainActivity : Activity() { override fun onStop() { activityStarted = false cancelLensRestart() - camera.stop() - stopPhoneServer(clearReservation = false, updateStatus = false) - obsDiscoveryClient.stop() - phoneAdvertiser.stop() - controlServer.stop() stopLiveDotAnimation() + // Never block the UI thread on network/thread joins (AGENTS.md 6). + // Flag flips are immediate; heavy teardown runs on a daemon thread. + // stopPhoneServer detects a non-UI thread and runs blocking teardown inline. + Thread({ + runCatching { camera.stop() } + runCatching { stopPhoneServer(clearReservation = false, updateStatus = false) } + runCatching { obsDiscoveryClient.stop() } + runCatching { phoneAdvertiser.stop() } + runCatching { controlServer.stop() } + }, "shinActivityStop").apply { isDaemon = true; start() } super.onStop() } override fun onDestroy() { clearReservation() + identifyHideRunnable?.let(mainHandler::removeCallbacks) + identifyHideRunnable = null mainHandler.removeCallbacksAndMessages(null) super.onDestroy() } @@ -259,13 +283,22 @@ class MainActivity : Activity() { grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == 100 && - checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED - ) { - initializeLenses() - startPreviewIfAllowed() - startPhoneServerIfAllowed() + if (requestCode != 100) return + val cameraGranted = checkSelfPermission(Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + val audioGranted = checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!cameraGranted) { + statusText.text = "Camera permission required" + statusDetail.text = "Grant camera access to stream; audio continues muted" + return } + if (!audioGranted) { + statusDetail.text = "Microphone denied; streaming video without audio" + } + initializeLenses() + startPreviewIfAllowed() + startPhoneServerIfAllowed() } @Deprecated("Uses the platform Activity result API to avoid an AndroidX dependency") @@ -324,9 +357,9 @@ class MainActivity : Activity() { startActivityForResult(intent, SETTINGS_REQUEST_CODE) } btnStop.setOnClickListener { - stopPhoneServer(clearReservation = false) + stopPhoneServer(clearReservation = true) startPreviewIfAllowed() - startPhoneServerIfAllowed() + pendingListenerStart = true } // Tap the screen-off overlay to re-enable display @@ -380,11 +413,13 @@ class MainActivity : Activity() { private fun useStreamBitrate(bitrateMbps: Int?) { val nextBitrate = (bitrateMbps ?: streamConfig.bitrateMbps) .coerceIn(StreamConfig.MIN_BITRATE_MBPS, StreamConfig.MAX_BITRATE_MBPS) * 1_000_000 - if (activeStreamBitrate == nextBitrate) return - activeStreamBitrate = nextBitrate - if (activeTargetName == null) { - encoder.stop() - encoder = createVideoEncoder(activeStreamBitrate) + synchronized(callerLifecycleLock) { + if (activeStreamBitrate == nextBitrate) return + activeStreamBitrate = nextBitrate + if (activeTargetName == null) { + encoder.stop() + encoder = createVideoEncoder(activeStreamBitrate) + } } } @@ -453,21 +488,33 @@ class MainActivity : Activity() { encoder.stop() } currentLens = lens - camera.switchLens(lens) - // If we were streaming, re-create the encoder and re-attach after the camera settles + runCatching { camera.switchLens(lens) }.onFailure { error -> + Log.e("shin", "Lens switch failed", error) + statusDetail.text = error.message ?: "Lens switch failed" + } + // If we were streaming, re-create the encoder and re-attach after the camera settles. + // MediaCodecList probing is heavy; never run encoder.start() on the UI thread. if (wasStreaming) { cancelLensRestart() val restart = Runnable { lensRestartRunnable = null if (!activityStarted || activeTargetName == null) return@Runnable - runCatching { - encoder.start() - camera.startStreaming(encoder.inputSurface()) - }.onFailure { e -> - Log.e("shin", "Failed to restart encoder after lens switch", e) - statusText.text = "Encoder error" - statusDetail.text = e.message ?: "Unknown" - } + Thread({ + val result = runCatching { + // Single-flight with caller/listener paths via callerLifecycleLock. + synchronized(callerLifecycleLock) { + encoder.start() + camera.startStreaming(encoder.inputSurface()) + } + } + result.onFailure { e -> + Log.e("shin", "Failed to restart encoder after lens switch", e) + mainHandler.post { + statusText.text = "Encoder error" + statusDetail.text = e.message ?: "Unknown" + } + } + }, "shinLensRestart").apply { isDaemon = true; start() } } lensRestartRunnable = restart mainHandler.postDelayed(restart, LENS_RESTART_DELAY_MS) @@ -547,7 +594,7 @@ class MainActivity : Activity() { // alive; without this, each beacon paid removeAllViews() + N TextView // inflations on the UI thread even when nothing changed. val renderKeys = devices.map { device -> - "${device.sourceInstanceId}|${device.displayLabel}|${device.busy}|${device.bitrateMbps}|${reservedBy == device.sourceInstanceId}|$phoneConnected" + "${device.sourceInstanceId}|${device.displayLabel}|${device.busy}|${device.bitrateMbps}|${advertisedReservationId == device.sourceInstanceId}|$phoneConnected" } if (renderKeys == lastObsSlotRenderKeys) return lastObsSlotRenderKeys = renderKeys @@ -576,7 +623,7 @@ class MainActivity : Activity() { obsSlotList.addView(title) devices.forEach { device -> - val isReservedForThisPhone = reservedBy == device.sourceInstanceId + val isReservedForThisPhone = advertisedReservationId == device.sourceInstanceId val enabled = !device.busy || isReservedForThisPhone val card = TextView(this).apply { text = "${device.displayLabel} · ${slotAvailabilityLabel(device, isReservedForThisPhone)}" @@ -617,11 +664,12 @@ class MainActivity : Activity() { } private fun reserveForSlot(device: DiscoveredObsDevice) { - if (device.busy && reservedBy != device.sourceInstanceId) return + if (device.busy && advertisedReservationId != device.sourceInstanceId) return selectedObsHost = device.host - // UI-initiated reservation is local-only: it sets reservedBy/slot state - // here without binding CameraControlServer.activeControllerAddress. The + // UI-initiated selection is pending-only: it is advertised so the + // matching OBS instance can POST /reserve, but it is not busy and + // cannot authorize camera controls until /reserve confirms it. The // OBS host binds its peer IP on its next POST /reserve with the same // sourceInstanceId (the server allows adoption while unbound), so no // separate mirror call is needed and no network work happens here. @@ -630,9 +678,10 @@ class MainActivity : Activity() { stopStream(updateStatus = false) } - if (reserveForSource(device.sourceInstanceId, device.displayLabel, device.bitrateMbps)) { + if (selectForSource(device.sourceInstanceId, device.displayLabel, device.bitrateMbps)) { statusText.text = "Selected ${device.displayLabel}" statusDetail.text = "Waiting for OBS acknowledgement" + renderDisconnectVisibility() renderObsSlots(currentDevices) } } @@ -657,9 +706,10 @@ class MainActivity : Activity() { val slotLabel = uri.getQueryParameter("slotLabel")?.trim().orEmpty() val bitrateMbps = uri.getQueryParameter("bitrateMbps")?.toIntOrNull() ?.coerceIn(StreamConfig.MIN_BITRATE_MBPS, StreamConfig.MAX_BITRATE_MBPS) - if (reserveForSource(sourceInstanceId, slotLabel, bitrateMbps)) { + if (selectForSource(sourceInstanceId, slotLabel, bitrateMbps)) { statusText.text = "Selected ${slotLabel.ifBlank { "OBS computer" }}" statusDetail.text = "Waiting for OBS acknowledgement" + renderDisconnectVisibility() } return } @@ -754,7 +804,7 @@ class MainActivity : Activity() { statusText.text = getString(R.string.status_ready) statusText.setTextColor(getColor(R.color.os_text_primary)) statusDetail.text = getString(R.string.status_waiting) - btnStop.visibility = View.GONE + renderDisconnectVisibility() val thread = Thread({ try { @@ -844,6 +894,9 @@ class MainActivity : Activity() { clearReservation: Boolean = true, updateStatus: Boolean = true, ) { + // Fast, non-blocking section: flip flags on the caller thread so new + // connects/listens cannot start. Heavy teardown (native disconnect, + // thread joins, encoder stop) runs off the UI thread per AGENTS.md 6. callerGeneration += 1 callerConnectThread?.interrupt() pendingListenerStart = false @@ -853,23 +906,50 @@ class MainActivity : Activity() { if (clearReservation) clearReservation() activeTargetName = null mainHandler.removeCallbacks(statsTicker) - streamClient.disconnect() val thread = listenerThread thread?.interrupt() - if (thread != null && thread !== Thread.currentThread()) { - runCatching { thread.join(LISTENER_STOP_TIMEOUT_MS) } - .onFailure { Thread.currentThread().interrupt() } - } - if (thread?.isAlive == true) { - Log.w("shin", "SRT listener did not stop within ${LISTENER_STOP_TIMEOUT_MS}ms") - } else if (listenerThread === thread) { - listenerThread = null + hideLiveState() + // Keep disconnect/release discoverable when Reserved but not live so the + // stuck-reserved state always has a one-tap escape hatch. ReservationState + // covers both pending (advertised) and confirmed selections. + renderDisconnectVisibility() + + val blockingWork = { + streamClient.disconnect() + if (thread != null && thread !== Thread.currentThread()) { + runCatching { thread.join(LISTENER_STOP_TIMEOUT_MS) } + .onFailure { Thread.currentThread().interrupt() } + } + if (thread?.isAlive == true) { + Log.w("shin", "SRT listener did not stop within ${LISTENER_STOP_TIMEOUT_MS}ms") + } else if (listenerThread === thread) { + listenerThread = null + } + synchronized(callerLifecycleLock) { + // stopActiveEncoding touches views when updateStatus; post that part. + val needsStatus = updateStatus + cancelLensRestart() + // Camera/encoder stops are safe off-UI (synchronized internally). + camera.stopStreaming() + encoder.stop() + audioEncoder.stop() + if (needsStatus) { + mainHandler.post { + statusText.text = getString(R.string.status_stopped) + statusDetail.text = "Camera preview remains active" + } + } + } + mainHandler.post { + hideLiveState() + if (pendingListenerStart) startPhoneServerIfAllowed() + } } - synchronized(callerLifecycleLock) { - stopActiveEncoding(updateStatus) + if (Looper.myLooper() == Looper.getMainLooper()) { + Thread({ blockingWork() }, "shinStopServer").apply { isDaemon = true; start() } + } else { + blockingWork() } - hideLiveState() - btnStop.visibility = View.GONE } private fun stopStream(updateStatus: Boolean = true) { @@ -898,18 +978,45 @@ class MainActivity : Activity() { // ─────────────────────────── Live state UI ─────────────────────────── + @Synchronized + private fun selectForSource( + sourceInstanceId: String, + slotLabel: String = "", + bitrateMbps: Int? = null, + ): Boolean { + val selection = ReservationSelection( + sourceInstanceId = sourceInstanceId, + slotLabel = slotLabel, + bitrateMbps = bitrateMbps, + obsHost = selectedObsHost, + ) + if (!reservationState.beginSelection(selection)) return false + useStreamBitrate(bitrateMbps) + reservationGeneration += 1 + renderDisconnectVisibility() + if (phoneConnected) { + cancelReservationRelease() + } else { + schedulePendingRelease(sourceInstanceId) + } + return true + } + @Synchronized private fun reserveForSource( sourceInstanceId: String, slotLabel: String = "", bitrateMbps: Int? = null, ): Boolean { - val currentReservation = reservedBy - if (phoneConnected && currentReservation != sourceInstanceId) return false + val currentReservation = reservationState.confirmedSourceInstanceId + if (phoneConnected && currentReservation != null && currentReservation != sourceInstanceId) return false + val effectiveSlot = slotLabel.ifBlank { + reservationState.confirmedReservation?.slotLabel.orEmpty() + } + if (!reservationState.confirm(sourceInstanceId, effectiveSlot, bitrateMbps)) return false useStreamBitrate(bitrateMbps) reservationGeneration += 1 - reservedBy = sourceInstanceId - reservedSlotLabel = slotLabel.ifBlank { reservedSlotLabel } + renderDisconnectVisibility() if (phoneConnected) { cancelReservationRelease() } else { @@ -920,35 +1027,81 @@ class MainActivity : Activity() { @Synchronized private fun releaseForSource(sourceInstanceId: String): Boolean { - if (reservedBy == sourceInstanceId) { + val confirmed = reservationState.confirmedSourceInstanceId + if (confirmed == sourceInstanceId) { clearReservation() return true } - return reservedBy == null + // Allow releasing a matching pending selection so it cannot stick forever. + if (confirmed == null && reservationState.pendingSourceInstanceId == sourceInstanceId) { + clearReservation() + return true + } + return confirmed == null && reservationState.pendingSourceInstanceId == null } @Synchronized private fun clearReservation() { cancelReservationRelease() - reservedBy = null - reservedSlotLabel = null + reservationState.clear() selectedObsHost = null + renderDisconnectVisibility() + } + + private fun renderDisconnectVisibility() { + mainHandler.post { + if (!::btnStop.isInitialized) return@post + val shouldShow = reservationState.hasReservationToDisconnect || + phoneConnected || activeTargetName != null + btnStop.visibility = if (shouldShow) View.VISIBLE else View.GONE + } + } + + @Synchronized + private fun schedulePendingRelease(sourceInstanceId: String) { + val generation = reservationGeneration + cancelReservationRelease() + releaseReservationRunnable = Runnable { + synchronized(this) { + if (!phoneConnected && + reservationGeneration == generation && + reservationState.confirmedSourceInstanceId == null + ) { + reservationState.rollbackPending(sourceInstanceId) + renderDisconnectVisibility() + if (reservationState.pendingSelection == null && + reservationState.confirmedReservation == null + ) { + selectedObsHost = null + statusText.text = "Connection timed out" + statusDetail.text = "Choose an OBS slot to try again" + } + } + } + } + mainHandler.postDelayed(releaseReservationRunnable!!, RECONNECT_RESERVATION_MS) } @Synchronized private fun scheduleReservationRelease() { - val sourceInstanceId = reservedBy ?: return + val sourceInstanceId = reservationState.confirmedSourceInstanceId ?: return val generation = reservationGeneration cancelReservationRelease() releaseReservationRunnable = Runnable { synchronized(this) { if (!phoneConnected && - reservedBy == sourceInstanceId && + reservationState.confirmedSourceInstanceId == sourceInstanceId && reservationGeneration == generation ) { - reservedBy = null - reservedSlotLabel = null - selectedObsHost = null + reservationState.release(sourceInstanceId) + renderDisconnectVisibility() + if (reservationState.confirmedReservation == null && + reservationState.pendingSelection == null + ) { + selectedObsHost = null + statusText.text = "Disconnected" + statusDetail.text = "Choose an OBS slot to reconnect" + } } } } @@ -963,12 +1116,13 @@ class MainActivity : Activity() { private fun showIdentifyOverlay(label: String, subtitle: String) { val text = if (subtitle.isBlank()) label else "$label\n$subtitle" + identifyHideRunnable?.let(mainHandler::removeCallbacks) identifyOverlay.text = text identifyOverlay.visibility = View.VISIBLE identifyOverlay.bringToFront() - mainHandler.postDelayed({ - identifyOverlay.visibility = View.GONE - }, IDENTIFY_OVERLAY_MS) + val hide = Runnable { identifyOverlay.visibility = View.GONE } + identifyHideRunnable = hide + mainHandler.postDelayed(hide, IDENTIFY_OVERLAY_MS) } private fun showLiveState(targetName: String) { @@ -1119,8 +1273,8 @@ class MainActivity : Activity() { context = this, config = streamConfig, port = currentPort, - busyProvider = { phoneConnected || reservedBy != null }, - reservedByProvider = { reservedBy }, + busyProvider = { isPhoneBusy() }, + reservedByProvider = { advertisedReservationId }, selectedObsHostProvider = { selectedObsHost }, ) phoneAdvertiser.start() diff --git a/android/app/src/main/java/dev/openstream/app/ReservationState.kt b/android/app/src/main/java/dev/openstream/app/ReservationState.kt new file mode 100644 index 00000000..a5d342bd --- /dev/null +++ b/android/app/src/main/java/dev/openstream/app/ReservationState.kt @@ -0,0 +1,103 @@ +package dev.openstream.app + +/** Details selected locally before the OBS control handshake completes. */ +internal data class ReservationSelection( + val sourceInstanceId: String, + val slotLabel: String = "", + val bitrateMbps: Int? = null, + val obsHost: String? = null, +) + +/** + * Owns the phone-side reservation handshake. + * + * A pending selection is only a discovery hint: it is advertised with its + * source id so the matching OBS instance can issue /reserve, but it is not + * busy and cannot authorize camera controls. Only a successful /reserve + * promotion creates a confirmed reservation. + */ +internal class ReservationState { + @Volatile private var pending: ReservationSelection? = null + @Volatile private var confirmed: ReservationSelection? = null + + val pendingSelection: ReservationSelection? + get() = pending + + val confirmedReservation: ReservationSelection? + get() = confirmed + + val pendingSourceInstanceId: String? + get() = pending?.sourceInstanceId + + val confirmedSourceInstanceId: String? + get() = confirmed?.sourceInstanceId + + /** A pending selection can be cancelled before OBS confirms ownership. */ + val hasReservationToDisconnect: Boolean + get() = pending != null || confirmed != null + + /** The id carried in discovery so OBS can initiate the control handshake. */ + val advertisedSourceInstanceId: String? + get() = confirmed?.sourceInstanceId ?: pending?.sourceInstanceId + + /** Pending selection alone must never make the phone appear occupied. */ + fun isBusy(phoneConnected: Boolean): Boolean = phoneConnected || confirmed != null + + @Synchronized + fun beginSelection(selection: ReservationSelection): Boolean { + val current = confirmed + if (current != null && current.sourceInstanceId != selection.sourceInstanceId) { + return false + } + // A confirmed reservation is already authoritative; do not demote it + // back to pending when the user taps the same slot again. + pending = if (current == null) selection else null + return true + } + + /** Promotes a matching pending selection, or accepts an OBS-initiated reserve. */ + @Synchronized + fun confirm(sourceInstanceId: String, slotLabel: String, bitrateMbps: Int?): Boolean { + val current = confirmed + if (current != null && current.sourceInstanceId != sourceInstanceId) return false + val selected = pending + if (selected != null && selected.sourceInstanceId != sourceInstanceId) return false + + val base = selected ?: current + confirmed = ReservationSelection( + sourceInstanceId = sourceInstanceId, + slotLabel = slotLabel.ifBlank { base?.slotLabel.orEmpty() }, + bitrateMbps = bitrateMbps ?: base?.bitrateMbps, + obsHost = base?.obsHost, + ) + pending = null + return true + } + + /** Rolls back only the matching unconfirmed selection. */ + @Synchronized + fun rollbackPending(sourceInstanceId: String): Boolean { + val selected = pending ?: return true + if (selected.sourceInstanceId != sourceInstanceId) return false + pending = null + return true + } + + /** Releases confirmed ownership or a matching pending selection. */ + @Synchronized + fun release(sourceInstanceId: String): Boolean { + val current = confirmed + if (current != null && current.sourceInstanceId != sourceInstanceId) return false + val selected = pending + if (selected != null && selected.sourceInstanceId != sourceInstanceId) return false + confirmed = null + pending = null + return true + } + + @Synchronized + fun clear() { + confirmed = null + pending = null + } +} diff --git a/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt b/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt index b0704803..effd772a 100644 --- a/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt +++ b/android/app/src/main/java/dev/openstream/app/SettingsActivity.kt @@ -127,7 +127,7 @@ class SettingsActivity : Activity() { @Suppress("DEPRECATION") info.versionCode.toLong() } - versionInfo.text = "shin v${info.versionName} (${code})" + versionInfo.text = "OpenStream v${info.versionName} (${code})" } } diff --git a/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt b/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt index ae9dab8a..54f73820 100644 --- a/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt +++ b/android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt @@ -27,12 +27,14 @@ import java.util.concurrent.atomic.AtomicLong * - Enumerating available physical lenses. */ class Camera2Controller( - private val context: Context, + context: Context, private val previewSurfaceProvider: () -> Surface, private val lensProvider: () -> CameraLens = { CameraLens.Back }, private val targetFps: Int = 30, ) { - private val cameraManager = context.getSystemService(CameraManager::class.java) + private val appContext = context.applicationContext + private val cameraManager: CameraManager? = + appContext.getSystemService(CameraManager::class.java) private var thread: HandlerThread? = null private var handler: Handler? = null private var camera: CameraDevice? = null @@ -82,8 +84,12 @@ class Camera2Controller( */ fun availableLenses(): List { val result = mutableListOf() + val manager = cameraManager ?: run { + Log.w(TAG, "CameraManager unavailable; falling back to single Back lens") + return listOf(CameraLens.Back) + } val cameraIds: Array = try { - cameraManager.cameraIdList + manager.cameraIdList } catch (error: CameraAccessException) { Log.w(TAG, "Could not enumerate cameras", error) return listOf(CameraLens.Back) @@ -103,7 +109,7 @@ class Camera2Controller( data class CamInfo(val id: String, val focalLength: Float, val facing: Int) val cameras = cameraIds.mapNotNull { id -> try { - val chars = cameraManager.getCameraCharacteristics(id) + val chars = manager.getCameraCharacteristics(id) val facing = chars.get(CameraCharacteristics.LENS_FACING) ?: return@mapNotNull null val focalLengths = chars.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) val focal = focalLengths?.firstOrNull() ?: 0f @@ -163,7 +169,10 @@ class Camera2Controller( ensureThread() cancelCameraRecoveryLocked() val desiredLens = lensProvider() - val desiredId = selectCameraId(desiredLens) + val desiredId = runCatching { selectCameraId(desiredLens) }.getOrElse { error -> + Log.w(TAG, "No camera available for lens $desiredLens; preview deferred", error) + return + } if (camera != null && activeCameraId == desiredId) { createSession() @@ -176,8 +185,13 @@ class Camera2Controller( activeLens = desiredLens activeCameraId = desiredId + val managerForOpen = cameraManager ?: run { + Log.w(TAG, "CameraManager unavailable; cannot open $desiredId") + closeCamera() + return + } try { - cameraManager.openCamera(desiredId, object : CameraDevice.StateCallback() { + managerForOpen.openCamera(desiredId, object : CameraDevice.StateCallback() { override fun onOpened(device: CameraDevice) { synchronized(lifecycleLock) { if (!desiredRunning || @@ -244,7 +258,10 @@ class Camera2Controller( fun switchLens(lens: CameraLens) { synchronized(lifecycleLock) { - val newId = selectCameraId(lens) + val newId = runCatching { selectCameraId(lens) }.getOrElse { error -> + Log.w(TAG, "Cannot switch to lens $lens: no camera available", error) + return + } if (newId == activeCameraId) return activeLens = lens @@ -368,8 +385,14 @@ class Camera2Controller( } } recoveryCallback = callback + val managerForWatch = cameraManager + if (managerForWatch == null) { + if (recoveryCallback === callback) recoveryCallback = null + Log.w(TAG, "Could not watch camera $cameraId availability: no CameraManager") + return + } runCatching { - cameraManager.registerAvailabilityCallback(callback, handler) + managerForWatch.registerAvailabilityCallback(callback, handler) }.onFailure { error -> if (recoveryCallback === callback) recoveryCallback = null Log.w(TAG, "Could not watch camera $cameraId availability", error) @@ -380,12 +403,17 @@ class Camera2Controller( private fun cancelCameraRecoveryLocked() { val callback = recoveryCallback ?: return recoveryCallback = null - runCatching { cameraManager.unregisterAvailabilityCallback(callback) } + val managerForCancel = cameraManager ?: return + runCatching { managerForCancel.unregisterAvailabilityCallback(callback) } } private fun loadZoomCapabilities(cameraId: String) { + val managerForCaps = cameraManager ?: run { + Log.w(TAG, "Could not read characteristics for camera $cameraId: no CameraManager") + return + } val chars = try { - cameraManager.getCameraCharacteristics(cameraId) + managerForCaps.getCameraCharacteristics(cameraId) } catch (error: CameraAccessException) { Log.w(TAG, "Could not read characteristics for camera $cameraId", error) return @@ -621,8 +649,12 @@ class Camera2Controller( } private fun selectCameraId(lens: CameraLens): String { + val manager = cameraManager ?: run { + return activeCameraId + ?: throw IllegalStateException("No cameras available on this device") + } val cameraIds: Array = try { - cameraManager.cameraIdList + manager.cameraIdList } catch (error: CameraAccessException) { Log.w(TAG, "Could not enumerate cameras for lens $lens", error) return activeCameraId @@ -642,7 +674,7 @@ class Camera2Controller( } val candidates = cameraIds.filter { id -> try { - cameraManager.getCameraCharacteristics(id) + manager.getCameraCharacteristics(id) .get(CameraCharacteristics.LENS_FACING) == lens.facing } catch (error: CameraAccessException) { Log.w(TAG, "Skipping camera $id while selecting lens $lens", error) @@ -665,7 +697,7 @@ class Camera2Controller( data class CamCandidate(val id: String, val focalLength: Float) val sorted = candidates.mapNotNull { id -> try { - val chars = cameraManager.getCameraCharacteristics(id) + val chars = manager.getCameraCharacteristics(id) val focal = chars.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.firstOrNull() ?: 0f CamCandidate(id, focal) } catch (error: CameraAccessException) { diff --git a/android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt b/android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt index 764aed8d..44240a27 100644 --- a/android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt +++ b/android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt @@ -36,10 +36,12 @@ class CameraControlServer( private val onReserve: (String, String, Int?) -> Boolean, private val onRelease: (String) -> Boolean, private val onIdentify: (String, String) -> Unit, + private val onError: ((String) -> Unit)? = null, ) { private val running = AtomicBoolean(false) private val pendingRestart = AtomicBoolean(false) private val lifecycleLock = Any() + private val clientLock = Any() private var desiredRunning = false @Volatile private var serverSocket: ServerSocket? = null @Volatile private var activeClient: Socket? = null @@ -77,7 +79,7 @@ class CameraControlServer( pendingRestart.set(false) running.set(false) runCatching { serverSocket?.close() } - runCatching { activeClient?.close() } + synchronized(clientLock) { runCatching { activeClient?.close() } } worker.also { it?.interrupt() } } if (thread != null && thread !== Thread.currentThread()) { @@ -87,14 +89,20 @@ class CameraControlServer( synchronized(lifecycleLock) { if (worker === thread && thread?.isAlive != true) worker = null serverSocket = null - activeClient = null + synchronized(clientLock) { activeClient = null } } } private fun run() { var openedSocket: ServerSocket? = null try { - val socket = ServerSocket(port) + val socket = try { + ServerSocket(port) + } catch (error: Exception) { + Log.e(TAG, "Control server failed to bind port $port", error) + onError?.invoke("Control port $port unavailable: ${error.message ?: "bind failed"}") + return + } openedSocket = socket serverSocket = socket socket.soTimeout = 1000 @@ -109,15 +117,20 @@ class CameraControlServer( if (running.get()) Log.w(TAG, "Socket error", e) break } - activeClient = client + synchronized(clientLock) { activeClient = client } try { handleClient(client) } finally { - activeClient = null + synchronized(clientLock) { + if (activeClient === client) activeClient = null + } } } } catch (e: Exception) { - if (running.get()) Log.e(TAG, "Control server error", e) + if (running.get()) { + Log.e(TAG, "Control server error", e) + onError?.invoke("Control server error: ${e.message ?: "unknown"}") + } } finally { runCatching { openedSocket?.close() } synchronized(lifecycleLock) { diff --git a/android/app/src/main/java/dev/openstream/app/discovery/ObsDiscoveryClient.kt b/android/app/src/main/java/dev/openstream/app/discovery/ObsDiscoveryClient.kt index f5cbf54d..bba72abb 100644 --- a/android/app/src/main/java/dev/openstream/app/discovery/ObsDiscoveryClient.kt +++ b/android/app/src/main/java/dev/openstream/app/discovery/ObsDiscoveryClient.kt @@ -236,11 +236,12 @@ object ObsDiscoveryProtocol { val port = json.optInt("listenerPort", -1) if (port !in 1..65535) return null val advertisedHost = json.optString("host").trim() - val host = advertisedHost.ifBlank { packetHost } + val host = connectionHost(packetHost, advertisedHost) + if (host.isBlank()) return null val fallbackId = json.optString("instanceId", "$packetHost:$port") return DiscoveredObsDevice( - name = json.optString("name", "shin Phone Link").ifBlank { "shin Phone Link" }, + name = json.optString("name", "OpenStream Camera").ifBlank { "OpenStream Camera" }, host = host, port = port, latencyMs = json.optInt("latencyMs", 120).coerceIn(80, 200), @@ -257,4 +258,25 @@ object ObsDiscoveryProtocol { busy = json.optBoolean("busy", false), ) } + + /** + * Discovery is a same-LAN control protocol, so the UDP peer is the + * authoritative address for the follow-up SRT connection. An advertised + * host can be stale, belong to another interface, or be a VPN address. + * Keep it only as a fallback for callers that do not have a peer address + * (for example, a persisted/manual beacon). + * + * Manual beacons must pass an empty [packetHost] so the explicit + * [advertisedHost] is used verbatim. Multi-homed/VPN hosts that need to + * override the UDP peer should use [manualConnectionHost]. + */ + internal fun connectionHost(packetHost: String, advertisedHost: String): String = + packetHost.trim().ifBlank { advertisedHost.trim() } + + /** + * Explicit manual override for persisted beacons and multi-homed/VPN hosts. + * Returns the trimmed manual host, or empty if none was supplied so callers + * can reject the beacon instead of connecting to a blank host. + */ + internal fun manualConnectionHost(manualHost: String): String = manualHost.trim() } diff --git a/android/app/src/main/java/dev/openstream/app/discovery/PhoneDiscoveryAdvertiser.kt b/android/app/src/main/java/dev/openstream/app/discovery/PhoneDiscoveryAdvertiser.kt index beca0f2f..7977c1ea 100644 --- a/android/app/src/main/java/dev/openstream/app/discovery/PhoneDiscoveryAdvertiser.kt +++ b/android/app/src/main/java/dev/openstream/app/discovery/PhoneDiscoveryAdvertiser.kt @@ -3,6 +3,7 @@ package dev.openstream.app.discovery import android.content.Context import android.net.wifi.WifiManager import android.os.Build +import android.text.format.Formatter import dev.openstream.app.control.CameraControlServer import dev.openstream.app.encoder.advertisedMimeType import dev.openstream.app.stream.StreamConfig diff --git a/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecVideoEncoder.kt b/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecVideoEncoder.kt index 5f48ddce..e74fdf79 100644 --- a/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecVideoEncoder.kt +++ b/android/app/src/main/java/dev/openstream/app/encoder/MediaCodecVideoEncoder.kt @@ -75,7 +75,7 @@ class MediaCodecVideoEncoder( mimeType = resolvedMime Log.i( "shinEncoder", - "Using hardware encoder ${resolvedSelection.codecName} for $mimeType " + + "Using hardware encoder $resolvedCodecName for $mimeType " + "${width}x${height}@${fps} (${bitrate / 1_000_000} Mbps)", ) val encoder = createConfiguredEncoder(resolvedCodecName) @@ -148,7 +148,7 @@ class MediaCodecVideoEncoder( "Encoder accepted latency=${format.getInteger(MediaFormat.KEY_LATENCY)} frame(s)", ) } - codecConfigBytes()?.let { config -> + format.codecConfigBytes()?.let { config -> deliverIfCurrent( generation, EncodedAccessUnit( @@ -301,7 +301,7 @@ class MediaCodecVideoEncoder( MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR, ) ) { - Log.d("OpenStreamEncoder", "Skipping ${candidate.name}: CBR is unsupported") + Log.d("shinEncoder", "Skipping ${candidate.name}: CBR is unsupported") return@mapNotNull null } @@ -312,7 +312,7 @@ class MediaCodecVideoEncoder( }.getOrDefault(false) if (!supportsTarget) { Log.d( - "OpenStreamEncoder", + "shinEncoder", "Skipping ${candidate.name}: cannot sustain ${width}x${height}@${fps} " + "at ${bitrate / 1_000_000} Mbps", ) @@ -324,7 +324,7 @@ class MediaCodecVideoEncoder( return candidates.firstOrNull() ?: run { Log.e( - "OpenStreamEncoder", + "shinEncoder", "No hardware surface encoder can satisfy ${width}x${height}@${fps} " + "at ${bitrate / 1_000_000} Mbps", ) @@ -332,87 +332,6 @@ class MediaCodecVideoEncoder( "OpenStream needs a hardware AVC encoder that supports the selected stream profile", ) } - val infos = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos - for (mime in mimeTypes) { - val candidates = infos.mapNotNull { candidate -> - if (!candidate.isEncoder || - !candidate.isHardwareAccelerated || - candidate.isSoftwareOnly || - candidate.supportedTypes.none { it.equals(mime, true) } - ) { - return@mapNotNull null - } - - val capabilities = runCatching { - candidate.getCapabilitiesForType(mime) - }.getOrNull() ?: return@mapNotNull null - if (!capabilities.colorFormats.contains( - MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface, - ) - ) { - return@mapNotNull null - } - if (!capabilities.encoderCapabilities.isBitrateModeSupported( - MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR, - ) - ) { - Log.d("shinEncoder", "Skipping ${candidate.name}: CBR is unsupported") - return@mapNotNull null - } - - val videoCapabilities = capabilities.videoCapabilities - val supportsTarget = runCatching { - videoCapabilities.areSizeAndRateSupported(width, height, fps.toDouble()) && - videoCapabilities.bitrateRange.contains(bitrate) - }.getOrDefault(false) - if (!supportsTarget) { - Log.d( - "shinEncoder", - "Skipping ${candidate.name}: cannot sustain ${width}x${height}@${fps} " + - "at ${bitrate / 1_000_000} Mbps", - ) - return@mapNotNull null - } - - EncoderSelection(mime, candidate.name) - } - - val selected = candidates.firstOrNull() ?: continue - if (preference == CodecPreference.PreferHevc && - mime == MediaFormat.MIMETYPE_VIDEO_AVC - ) { - Log.w( - "shinEncoder", - "Hardware HEVC cannot satisfy the requested stream profile; " + - "explicitly falling back to hardware AVC", - ) - } - return selected - } - - Log.e( - "shinEncoder", - "No hardware surface encoder can satisfy ${width}x${height}@${fps} " + - "at ${bitrate / 1_000_000} Mbps", - ) - throw IllegalStateException( - "shin needs a hardware AVC/HEVC encoder that supports the selected stream profile", - ) - } - - private fun codecConfigFrom(format: MediaFormat): ByteArray? { - val output = ByteArrayOutputStream() - for (index in 0..2) { - val key = "csd-$index" - if (!format.containsKey(key)) continue - val buffer = format.getByteBuffer(key) ?: continue - val duplicate = buffer.duplicate() - duplicate.position(0) - val bytes = ByteArray(duplicate.remaining()) - duplicate.get(bytes) - output.write(bytes) - } - return output.toByteArray().takeIf { it.isNotEmpty() } } companion object { diff --git a/android/app/src/main/java/dev/openstream/app/stream/ConnectionTarget.kt b/android/app/src/main/java/dev/openstream/app/stream/ConnectionTarget.kt index 26b918d9..c4914b3a 100644 --- a/android/app/src/main/java/dev/openstream/app/stream/ConnectionTarget.kt +++ b/android/app/src/main/java/dev/openstream/app/stream/ConnectionTarget.kt @@ -15,7 +15,7 @@ data class ConnectionTarget( } companion object { - const val DEFAULT_NAME = "shin Phone Link" + const val DEFAULT_NAME = "OpenStream Camera" const val DEFAULT_HOST = "192.168.1.2" const val DEFAULT_PORT = 9100 const val DEFAULT_LATENCY_MS = 120 diff --git a/android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt b/android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt index 07dd126a..c762f074 100644 --- a/android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt +++ b/android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt @@ -27,6 +27,11 @@ class SrtStreamClient { private val sessionGeneration = AtomicLong() private val operationLock = Any() private val stateLock = Any() + + val isNativeAvailable: Boolean + get() = SrtNativeBridge.isAvailable + val nativeLoadError: Throwable? + get() = SrtNativeBridge.loadError val stats: StreamStats get() = StreamStats( accessUnitsSent = accessUnitsSent.get(), @@ -192,6 +197,9 @@ class SrtStreamClient { } private inline fun establishSession(operationName: String, nativeOperation: (Long) -> Boolean) { + check(SrtNativeBridge.isAvailable) { + "Native SRT library unavailable: ${SrtNativeBridge.loadError?.message ?: "missing"}" + } val generation = synchronized(stateLock) { connected = false sessionGeneration.incrementAndGet().also { generation -> @@ -253,12 +261,78 @@ class SrtStreamClient { } private object SrtNativeBridge { + @Volatile var loadError: Throwable? = null + private set + val isAvailable: Boolean + get() = loadError == null + init { - System.loadLibrary("openstream_srt") + try { + System.loadLibrary("openstream_srt") + } catch (error: Throwable) { + // 16KB-page misalignment or missing ABI must surface as a warning, + // never crash the app on first connect/listen/disconnect. + loadError = error + android.util.Log.e("shinSrt", "Native SRT library failed to load", error) + } + } + + private inline fun guardOr(default: T, block: () -> T): T { + if (loadError != null) return default + return try { + block() + } catch (error: UnsatisfiedLinkError) { + loadError = error + android.util.Log.e("shinSrt", "Native SRT call failed; library unavailable", error) + default + } + } + + fun beginSession(sessionGeneration: Long) { + guardOr(Unit) { nativeBeginSession(sessionGeneration) } + } + fun connect( + url: String, + codecMime: String, + width: Int, + height: Int, + fps: Int, + sessionGeneration: Long, + ): Boolean = guardOr(false) { + nativeConnect(url, codecMime, width, height, fps, sessionGeneration) + } + fun listen( + url: String, + codecMime: String, + width: Int, + height: Int, + fps: Int, + sessionGeneration: Long, + ): Boolean = guardOr(false) { + nativeListen(url, codecMime, width, height, fps, sessionGeneration) + } + fun sendVideo( + data: ByteArray, + presentationTimeUs: Long, + flags: Int, + sessionGeneration: Long, + ): Boolean = guardOr(false) { + nativeSendVideo(data, presentationTimeUs, flags, sessionGeneration) + } + fun sendAudio( + data: ByteArray, + presentationTimeUs: Long, + flags: Int, + sessionGeneration: Long, + ): Boolean = guardOr(false) { + nativeSendAudio(data, presentationTimeUs, flags, sessionGeneration) + } + fun disconnect(sessionGeneration: Long) { + guardOr(Unit) { nativeDisconnect(sessionGeneration) } } - external fun beginSession(sessionGeneration: Long) - external fun connect( + private external fun nativeBeginSession(sessionGeneration: Long) + private external fun nativeConnect( url: String, codecMime: String, width: Int, @@ -266,7 +340,7 @@ private object SrtNativeBridge { fps: Int, sessionGeneration: Long, ): Boolean - external fun listen( + private external fun nativeListen( url: String, codecMime: String, width: Int, @@ -274,17 +348,17 @@ private object SrtNativeBridge { fps: Int, sessionGeneration: Long, ): Boolean - external fun sendVideo( + private external fun nativeSendVideo( data: ByteArray, presentationTimeUs: Long, flags: Int, sessionGeneration: Long, ): Boolean - external fun sendAudio( + private external fun nativeSendAudio( data: ByteArray, presentationTimeUs: Long, flags: Int, sessionGeneration: Long, ): Boolean - external fun disconnect(sessionGeneration: Long) + private external fun nativeDisconnect(sessionGeneration: Long) } diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 7302160a..aea9da76 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,5 @@ - - + - diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 13eb4258..c672775c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - shin + OpenStream 1× diff --git a/android/app/src/test/java/dev/openstream/app/ReservationStateTest.kt b/android/app/src/test/java/dev/openstream/app/ReservationStateTest.kt new file mode 100644 index 00000000..e3ae59ed --- /dev/null +++ b/android/app/src/test/java/dev/openstream/app/ReservationStateTest.kt @@ -0,0 +1,86 @@ +package dev.openstream.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReservationStateTest { + private fun selection(sourceInstanceId: String) = ReservationSelection( + sourceInstanceId = sourceInstanceId, + slotLabel = "Phone Camera", + bitrateMbps = 12, + obsHost = "192.168.1.24", + ) + + @Test + fun pendingSelectionIsDiscoverableButNotBusyOrConfirmed() { + val state = ReservationState() + + assertTrue(state.beginSelection(selection("source-a"))) + assertEquals("source-a", state.advertisedSourceInstanceId) + assertEquals("source-a", state.pendingSourceInstanceId) + assertTrue(state.hasReservationToDisconnect) + assertNull(state.confirmedSourceInstanceId) + assertFalse(state.isBusy(phoneConnected = false)) + } + + @Test + fun failedReserveRollbackClearsPendingSelection() { + val state = ReservationState() + state.beginSelection(selection("source-a")) + + assertTrue(state.rollbackPending("source-a")) + assertNull(state.advertisedSourceInstanceId) + assertNull(state.pendingSourceInstanceId) + assertNull(state.confirmedSourceInstanceId) + assertFalse(state.hasReservationToDisconnect) + assertFalse(state.isBusy(phoneConnected = false)) + } + + @Test + fun onlyMatchingReservePromotesPendingSelection() { + val state = ReservationState() + state.beginSelection(selection("source-a")) + + assertFalse(state.confirm("source-b", "Other", 8)) + assertNull(state.confirmedSourceInstanceId) + assertEquals("source-a", state.pendingSourceInstanceId) + + assertTrue(state.confirm("source-a", "Phone Camera", 12)) + assertEquals("source-a", state.confirmedSourceInstanceId) + assertNull(state.pendingSourceInstanceId) + assertTrue(state.hasReservationToDisconnect) + assertTrue(state.isBusy(phoneConnected = false)) + } + + @Test + fun releaseClearsConfirmedReservationAndIsIdempotentWhenIdle() { + val state = ReservationState() + assertTrue(state.confirm("source-a", "Phone Camera", 12)) + + assertFalse(state.release("source-b")) + assertEquals("source-a", state.confirmedSourceInstanceId) + assertTrue(state.release("source-a")) + assertNull(state.confirmedSourceInstanceId) + assertFalse(state.hasReservationToDisconnect) + assertTrue(state.release("source-a")) + } + + @Test + fun clearReleasesBothPendingAndConfirmedSelections() { + val state = ReservationState() + state.beginSelection(selection("source-a")) + state.clear() + assertNull(state.advertisedSourceInstanceId) + assertFalse(state.hasReservationToDisconnect) + assertFalse(state.isBusy(phoneConnected = false)) + + state.confirm("source-b", "Phone Camera", 12) + state.clear() + assertNull(state.confirmedSourceInstanceId) + assertFalse(state.hasReservationToDisconnect) + assertFalse(state.isBusy(phoneConnected = false)) + } +} diff --git a/android/app/src/test/java/dev/openstream/app/discovery/ObsDiscoveryProtocolTest.kt b/android/app/src/test/java/dev/openstream/app/discovery/ObsDiscoveryProtocolTest.kt new file mode 100644 index 00000000..e848e755 --- /dev/null +++ b/android/app/src/test/java/dev/openstream/app/discovery/ObsDiscoveryProtocolTest.kt @@ -0,0 +1,43 @@ +package dev.openstream.app.discovery + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ObsDiscoveryProtocolTest { + @Test + fun discoveredObsTargetUsesDatagramPeerAddressForConnection() { + assertEquals( + "192.168.1.24", + ObsDiscoveryProtocol.connectionHost("192.168.1.24", "10.0.0.77"), + ) + } + + @Test + fun discoveryFallsBackToAdvertisedHostWithoutDatagramPeer() { + assertEquals( + "10.0.0.77", + ObsDiscoveryProtocol.connectionHost("", "10.0.0.77"), + ) + } + + @Test + fun manualBeaconUsesExplicitHostVerbatim() { + assertEquals( + "10.0.0.77", + ObsDiscoveryProtocol.manualConnectionHost(" 10.0.0.77 "), + ) + assertEquals( + "", + ObsDiscoveryProtocol.manualConnectionHost(" "), + ) + } + + @Test + fun beaconWithoutAnyHostIsRejected() { + val beacon = """SHIN/1 {"type":"dev.shin.listener","version":1,"listenerPort":7000,"host":""}""" + assertEquals( + null, + ObsDiscoveryProtocol.parseBeacon(beacon, "", 0L), + ) + } +} diff --git a/android/gradle.properties b/android/gradle.properties index e131c520..e022458f 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,3 +2,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true android.nonTransitiveRClass=true +openstream.versionName=1.0.1 +openstream.versionCode=1789925364 diff --git a/android/gradlew b/android/gradlew old mode 100644 new mode 100755 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index a825eab7..077247fd 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -14,5 +14,5 @@ dependencyResolutionManagement { } } -rootProject.name = "shin-android" +rootProject.name = "openstream-android" include(":app") diff --git a/build_plugin_linux.sh b/build_plugin_linux.sh index 0f1937dd..6df32aa2 100755 --- a/build_plugin_linux.sh +++ b/build_plugin_linux.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# shin OBS Plugin Build Script for Linux. +# OpenStream OBS Plugin Build Script for Linux. # Configures, builds, tests, and optionally packages the OBS source plugin. # # ./build_plugin_linux.sh [--package-only] [--install-user] [--install-system] @@ -34,7 +34,7 @@ done echo "" echo "=====================================================" -echo " shin - OBS Plugin Builder (Linux)" +echo " OpenStream - OBS Plugin Builder (Linux)" echo "=====================================================" echo "" @@ -51,7 +51,7 @@ for tool in cmake g++ pkg-config; do done missing_pc=() -for module in libobs libavformat libavcodec libavutil libswscale Qt6Core; do +for module in libobs libavformat libavcodec libavutil libswscale Qt6Core Qt6Widgets; do if ! pkg-config --exists "${module}" 2>/dev/null; then missing_pc+=("${module}") fi @@ -62,6 +62,7 @@ if [[ "${#missing_pc[@]}" -gt 0 ]]; then fi echo "OBS system ABI (no Windows version pin; see docs/linux.md):" echo " libobs $(pkg-config --modversion libobs)" +echo " OBS frontend API: resolved by CMake from the installed OBS development package" echo " libavformat $(pkg-config --modversion libavformat)" echo " Qt $(qmake6 -query QT_VERSION 2>/dev/null || pkg-config --modversion Qt6Core)" echo "" @@ -73,19 +74,19 @@ if [[ "${PACKAGE_ONLY}" -eq 0 ]]; then -DCMAKE_BUILD_TYPE=Release \ "-DOPENSTREAM_VERSION=${OPENSTREAM_VERSION}" - echo "[2/4] Building shin plugin..." + echo "[2/4] Building OpenStream plugin..." cmake --build "${BUILD_DIR}" --config Release - if [[ ! -f "${BUILD_DIR}/shin-obs.so" ]]; then - echo "ERROR: Build output not found: ${BUILD_DIR}/shin-obs.so" >&2 + if [[ ! -f "${BUILD_DIR}/openstream-obs.so" ]]; then + echo "ERROR: Build output not found: ${BUILD_DIR}/openstream-obs.so" >&2 exit 1 fi echo "[3/4] Running C++ contract tests (ctest)..." ctest --test-dir "${BUILD_DIR}" --output-on-failure else - if [[ ! -f "${BUILD_DIR}/shin-obs.so" ]]; then - echo "ERROR: --package-only needs an existing build at ${BUILD_DIR}/shin-obs.so" >&2 + if [[ ! -f "${BUILD_DIR}/openstream-obs.so" ]]; then + echo "ERROR: --package-only needs an existing build at ${BUILD_DIR}/openstream-obs.so" >&2 exit 1 fi fi @@ -93,31 +94,31 @@ fi if [[ -n "${PACKAGE_DIR}" ]]; then echo "[4/4] Packaging plugin artifact..." mkdir -p "${PACKAGE_DIR}" - STAGE_DIR="${PACKAGE_DIR}/shin-obs-linux-x86_64" + STAGE_DIR="${PACKAGE_DIR}/openstream-obs-linux-x86_64" rm -rf "${STAGE_DIR}" mkdir -p "${STAGE_DIR}" - cp "${BUILD_DIR}/shin-obs.so" "${STAGE_DIR}/shin-obs.so" - cp "${SCRIPT_DIR}/tools/installer/install-shin-plugin-linux.sh" "${STAGE_DIR}/" - tar -czf "${PACKAGE_DIR}/shin-obs-linux-x86_64.tar.gz" -C "${PACKAGE_DIR}" "shin-obs-linux-x86_64" - echo "Packaged: ${PACKAGE_DIR}/shin-obs-linux-x86_64.tar.gz" + cp "${BUILD_DIR}/openstream-obs.so" "${STAGE_DIR}/openstream-obs.so" + cp "${SCRIPT_DIR}/tools/installer/install-openstream-plugin-linux.sh" "${STAGE_DIR}/" + tar -czf "${PACKAGE_DIR}/openstream-obs-linux-x86_64.tar.gz" -C "${PACKAGE_DIR}" "openstream-obs-linux-x86_64" + echo "Packaged: ${PACKAGE_DIR}/openstream-obs-linux-x86_64.tar.gz" else echo "[4/4] Packaging skipped (set OPENSTREAM_PLUGIN_PACKAGE_DIR to package)." fi if [[ "${INSTALL_USER}" -eq 1 ]]; then - "${SCRIPT_DIR}/tools/installer/install-shin-plugin-linux.sh" --so "${BUILD_DIR}/shin-obs.so" + "${SCRIPT_DIR}/tools/installer/install-openstream-plugin-linux.sh" --so "${BUILD_DIR}/openstream-obs.so" fi if [[ "${INSTALL_SYSTEM}" -eq 1 ]]; then - "${SCRIPT_DIR}/tools/installer/install-shin-plugin-linux.sh" --so "${BUILD_DIR}/shin-obs.so" --system + "${SCRIPT_DIR}/tools/installer/install-openstream-plugin-linux.sh" --so "${BUILD_DIR}/openstream-obs.so" --system fi echo "" echo "=====================================================" -echo " SUCCESS! shin plugin built." +echo " SUCCESS! OpenStream plugin built." echo "=====================================================" echo "" -echo " Plugin: ${BUILD_DIR}/shin-obs.so" +echo " Plugin: ${BUILD_DIR}/openstream-obs.so" echo "" -echo "Restart OBS Studio, then add a shin source." +echo "Restart OBS Studio, then add an OpenStream source." echo "" diff --git a/docs/agent-roadmap.md b/docs/agent-roadmap.md new file mode 100644 index 00000000..7e9171e4 --- /dev/null +++ b/docs/agent-roadmap.md @@ -0,0 +1,174 @@ +# OpenStream agent roadmap + +This roadmap turns the [product readiness review](product-readiness-review.md) into work that a coordinator can assign to subagents. It targets one Android phone supplying one native OBS camera source over a local network on Windows and native Linux. It does not assume that every issue in the review is a confirmed runtime defect. Measure uncertain behavior before changing it. + +The roadmap is complete when the **same release** of the Android app and both OBS plugins can be installed, updated, connected, controlled, disconnected, and reconnected on supported hardware; the exact artifacts pass the automated and physical gates below; and the user-facing names, docs, and downloads agree. A passing unit-test suite alone does not meet that definition. + +## How the coordinator runs the work + +1. Create one issue and one PR for each numbered package. Give each subagent its own branch or worktree. A subagent owns its files until its PR is reviewed; no two agents edit `MainActivity.kt`, `openstream-source.cpp`, or release workflows at the same time. +2. Start each package from the latest accepted predecessor. Record the old behavior, make the smallest change, run its checks, and attach the evidence to the PR. Keep Android, OBS, and `docs/protocol.md` in one PR when a wire behavior changes. +3. Require a reviewer to inspect the actual diff and test output. Use **VERIFIED**, **NOT VERIFIED**, or **INCONCLUSIVE** for each acceptance check. An unrun physical check is INCONCLUSIVE, even when CI passes. +4. Keep `applicationId=dev.openstream.app` and the published Android signing identity. Preserve source timestamps, bounded queues, hardware-encode policy, and old scene/protocol identities that real prior artifacts used. Do not introduce a second phone encoder, silent software video fallback, or network work on the UI thread. +5. Do not publish a release from an open PR. The coordinator collects the exact APK and plugin hashes, checks the four Android/plugin version combinations, and obtains the physical acceptance record before tagging and publishing. + +Each subagent's handoff must name its package ID, PR, changed files, Android-to-OBS workflow step affected, old/new compatibility result, commands and device tests actually run, measured before/after results for performance work, and remaining INCONCLUSIVE checks. The coordinator updates [docs/testing.md](testing.md) only with observed results. + +## Work order and parallel lanes + +| Stage | Coordinator's gate | Work that can run in parallel after the gate | +| --- | --- | --- | +| 0. Establish the baseline | B1 records the published artifacts; B2 then records candidate device behavior | After B1, V1, O1, and A1 can use separate worktrees while B2 runs. | +| 1. Make builds and identity truthful | V1 and V2 prove matching artifact versions; O1 proves old scene behavior | A2 follows A1. V3 follows V2. | +| 2. Make the session reliable | A3 follows A2; the session owner and Stop behavior pass device checks | D1 can proceed separately. P1 follows A3 and O1. | +| 3. Finish both ends of the connection | P1 and P2 pass protocol and discovery checks; O2 provides host control parity | Run P1, P2, O2, then M1 in sequence where they share source files. U1 and U2 follow the state and control changes. | +| 4. Prove a releasable product | G1 checks exact artifacts, compatibility, device matrix, and new-user trials | Optional performance or transport work begins only from measured demand. | + +The coordinator can change this order when baseline evidence shows a more urgent defect. Write the reason and the replacement acceptance check in the relevant PR. Keep protocol changes together across Android and OBS, even when subagents investigate the two sides separately. + +## Stage 0: establish what works now + +### B1. Record the published release and candidate baseline + +- **Agent owns:** `docs/testing.md`, a reproducible evidence script or checklist under `tools/`, and saved artifact metadata. Download the published v1.0.1 APK and Windows plugin. Read the APK's real package ID, version code, and signing fingerprint. Save the asset hashes and OBS scene/source IDs from an actual old scene collection. Record the current candidate's APK/plugin hashes separately. +- **Prove:** Install the candidate APK over the published APK on a phone with the same signing identity. Open an old scene in real OBS and note whether the source loads, retains settings, and receives media. Record the result as VERIFIED or INCONCLUSIVE, not as assumed compatibility. +- **Blocker:** This needs a phone, OBS, the published artifacts, and release signing access. Other code packages may proceed, but G1 cannot pass without it. + +### B2. Build the repeatable end-to-end check + +- **Agent owns:** `docs/testing.md` and a small runner or recording template under `tools/`. Specify device model/Android version, OBS version/host OS, APK/plugin hashes, Wi-Fi setup, latency setting, result, and log location. +- **Prove:** Exercise install, first discovery, reserve, first video and AAC audio, lens/torch/zoom, Stop, reconnect after short and long loss, OBS restart, source removal, Virtual Camera, and a 30-minute run. Include a clap or tone for A/V sync. Record latency, dropped media, recovery time, battery, and temperature. +- **Gate:** At least three distinct Android devices and both supported OBS hosts run the same script before G1. A device or host not run is explicitly unsupported or unverified for that release. +- **Depends on:** B1, because both packages update `docs/testing.md`. On each host, start OBS from a clean installation, confirm one plugin loads and its source and controls appear, then test OBS shutdown with the phone unreachable. Keep an unrelated OBS source active while OpenStream fails to check isolation. + +## Stage 1: one version and honest OBS identity + +### V1. Move the candidate version to one source + +- **Agent owns:** new `release/version.properties` with `productVersion` and `androidVersionCode`; `android/app/build.gradle.kts`; `obs-plugin/CMakeLists.txt`; `build_plugin.bat`; `build_plugin_linux.sh`; `tools/installer/openstream-obs-plugin.iss`; the Windows and Linux CI workflows; and version tests. +- **Change:** Parse the root values in Gradle and CMake. Make build scripts pass the same product version to CMake and Inno. Remove independent `1.0.1` defaults and the literal `1.0.1` assertions in `tests/test_repo_contract.py`. Give private `website/package.json` a tooling version, not a public product claim. +- **Prove:** A deliberate mismatch fails a pull-request build. A normal build reports one candidate product version on Android, Windows, and Linux. Keep `SHIN/1`, saved OBS source IDs, OBS 32.2.1/FFmpeg ABI, and vendored SRT versions separate. Do not lower the current Android version code or change signing. +- **Depends on:** B1's actual published APK metadata. The next release code must exceed every published APK code for the same application ID, including prereleases, and the current candidate code. + +### V2. Check the binaries, installer, and release tag + +- **Agent owns:** a version checker under `tools/`, `.github/workflows/release.yml`, plugin version metadata in `obs-plugin/src/openstream-source.cpp`, package scripts, and a generated release manifest. +- **Change:** Embed an inspectable product version in the plugin. Have CI compare the tag, APK package/name/code/signing certificate, both plugin versions, Windows installer metadata, artifact hashes, and manifest. Check the previous **published APK's** code and certificate; do not infer them only from historical tag timestamps. Fail closed when fetching the previous release fails. Reject a stale binary in Linux `--package-only` mode. Split candidate build from publication: current `release.yml` builds and calls `gh release create` automatically on a tag push, before any physical acceptance gate. +- **Prove:** CI rejects a wrong tag, wrong APK version code, wrong signing certificate, stale plugin binary, wrong installer version, and missing artifact. It accepts a matched candidate. The checker runs on PRs where possible and again on the exact release artifacts. Candidate workflow uploads signed artifacts without publishing; a separate manual publish step consumes those exact bytes and their hashes. +- **Depends on:** V1. The release manifest names protocol and supported OBS ABI separately from product version. + +### V3. Separate development version from published download version + +- **Agent owns:** `website/src/main.jsx`, `website/index.html`, `website/package.json` and lockfile, `README.md`, `docs/release.md`, and `docs/release-notes-template.md`. +- **Change:** Remove hard-coded current-version claims from pages that link to `releases/latest`. Show “Latest release” until the deployment has the published release manifest, then show that manifest's version and matched assets. Keep historical notes fixed. Generate new release notes for each release instead of overwriting v1.0.1 history. +- **Prove:** A candidate bump cannot make the public site advertise an unpublished APK. A published release changes the site only after matching assets exist. Website build and link checks pass. +- **Depends on:** V2's manifest. This package does not change the Android application ID or release signing. + +### O1. Prove and repair OBS source identity + +- **Agent owns:** `obs-plugin/src/openstream-source.cpp`, native source tests, old scene fixtures, and setup docs. +- **Current evidence:** The plugin registers only `shin_phone_source` and displays `shin`. Current docs say users should add `OpenStream V8` and claim V7 saved-scene support. The source does not establish that claim. +- **Change:** Inspect source IDs in published plugins and saved scenes. Keep every confirmed old ID loadable through an adapter or explicit migration. Change only the new source's visible picker name to `OpenStream Camera`; retain its stable internal ID. Correct docs to match what OBS actually shows. +- **Prove:** A published-release scene opens in Windows and Linux OBS without a missing-source dialog, keeps settings, and receives media. A new scene displays `OpenStream Camera`. If an old ID never existed in an artifact, document that rather than inventing an adapter. +- **Depends on:** B1. Do this before changing UI labels or promising scene compatibility. + +## Stage 2: make the Android session reliable + +### A1. Define the session states and Stop behavior + +- **Agent owns:** `android/app/src/main/java/dev/openstream/app/ReservationState.kt`, a new pure session-state module, `MainActivity.kt` call sites, and JVM behavior tests. +- **Change:** Model Available, Selected, Reserved, Connecting, Live, Reconnecting, Error, and Stopped with explicit transition events and a generation for stale work. Decide and implement distinct actions: **Stop** ends media and does not auto-restart; **Start** makes the phone available again; **Disconnect** releases the OBS reservation while the phone remains available. Today the Stop button sets `pendingListenerStart`, so its label and behavior disagree. +- **Prove:** Unit tests cover duplicate reserves, rapid phone switching, timeout, stale callback, reconnect, release, Stop, and Start. On a device, Stop remains stopped across background/foreground, and a visible Start action returns to reserve/live without restarting the app. The phone and OBS show the same transition. Avoid spreading state flags across additional UI callbacks. + +### A2. Remove blocking teardown from Android UI paths + +- **Agent owns:** `MainActivity.kt`, `camera/Camera2Controller.kt`, `stream/SrtStreamClient.kt`, encoders, and native SRT adapter as needed. +- **Change:** Move camera/codec/socket start and stop to one serialized, bounded session worker. Keep view changes on the main thread. `surfaceDestroyed()` currently calls camera and server teardown from the UI callback; other UI paths can reach native disconnect and codec stop. Ensure each operation checks session generation so late work cannot revive a stopped stream. +- **Prove:** Android behavior tests cover rapid start/stop, lens change, Settings, preview destruction, and stale callbacks. StrictMode or trace evidence on real phones shows no network/disk work or long codec teardown on the UI thread. Each queue or worker declares its capacity and overflow behavior. +- **Depends on:** A1. Do not parallel-edit `MainActivity.kt` with A3. + +### A3. Give live media a lifecycle owner + +- **Agent owns:** a foreground camera session module or equivalent, `MainActivity.kt`, `AndroidManifest.xml`, preview binding, notification actions, and Android tests. +- **Change:** The session owner holds camera, encoders, SRT, discovery, control, and reservation state. The activity observes state and supplies a preview surface. Define behavior for Home, screen lock, Settings, permission revoke, task removal, and process recreation under supported Android versions. Keep one video encoder and make notification Stop final. +- **Prove:** A device run shows the documented background behavior without a stale reservation or second encoder. A fast return to the app restores preview and current state. Behavioral tests exercise owner transitions; instrumentation covers lifecycle and permission edges where an emulator can reproduce them. +- **Depends on:** A2. Android background-camera restrictions require device checks before promising uninterrupted screen-off streaming. + +## Stage 3: secure and finish the connection + +### P1. Make pairing and control authorization one protocol change + +- **Agent owns:** Android `control/CameraControlServer.kt`, discovery and reservation code, OBS `openstream-source.cpp` control client, `docs/protocol.md`, and cross-product tests. One integration PR owns both ends and the wire documentation. +- **Current evidence:** V1 control is not authenticated. `/reserve` can receive an optional token; camera commands rely on the reserved peer address. The docs claim a pairing URL carries a control token, but the current URL builder does not add one. +- **Change:** Specify capability/version negotiation, phone approval, a session secret, expiry and rotation, and authorization for every mutating command. Define a safe response for previous Android/new OBS and new Android/previous OBS. Do not silently accept an incompatible pairing or expose secrets in discovery, logs, or a support bundle. +- **Prove:** Another LAN host cannot reserve or control the camera; stale and released secrets fail. Test reserve, control, release, reconnect, and both previous/candidate combinations on actual Android and OBS. Keep source timestamps and SRT media unchanged unless a separate measured media change is needed. +- **Depends on:** A3 and O1. The coordinator owns this shared protocol PR; separate subagents may investigate each side without concurrent edits to the same branch. + +### P2. Repair discovery and deep-link fallback + +- **Agent owns:** Android `discovery/*.kt`, `stream/ConnectionTarget.kt`, `MainActivity.kt`, `AndroidManifest.xml`, OBS pairing URL code, protocol docs, and parsing tests. +- **Current evidence:** The manifest advertises `openstream://connect`, but the manual `ConnectionTarget.fromPairingUri()` path accepts only `shin://connect`. Confirm behavior for published links before modifying it. +- **Change:** Parse confirmed legacy and current schemes, validate host/port/latency and input sizes, and show a specific reason when multicast, Wi-Fi, VPN, firewall, or port binding prevents discovery. Keep manual connection as a reachable fallback. +- **Prove:** Old and new links connect on a device; malformed links are rejected without a crash. Discovery and manual pairing work on the same network and on a multicast-blocked network. Review pairing behavior against P1's version rules. +- **Depends on:** P1's protocol specification. If the parser fix is independent, it may be a smaller earlier PR after B1. + +### O2. Give Windows and Linux equivalent live controls + +- **Agent owns:** `obs-plugin/CMakeLists.txt`, `src/openstream-dock.cpp`, `src/openstream-source.cpp`, OBS tests, and setup docs. +- **Current evidence:** The dock builds only on Linux, while Windows docs tell users to open it. Linux source properties and the dock also overlap on some controls. +- **Change:** Make a dock or equally clear live control area available on both supported hosts. Keep setup settings in source properties; put live connect/release, selection, status, zoom, lens, torch, and identify actions in one place per host. Remove a duplicate control only after its replacement works on Windows and Linux. +- **Prove:** Real OBS on both hosts shows each action, the action reaches the selected phone, and an unreachable phone cannot freeze OBS. The old saved scene still opens. +- **Depends on:** O1, V1, and P1. Apply its protocol changes before editing the same OBS source file for live controls. + +### M1. Make media failures observable and test the receiver behavior + +- **Agent owns:** Android `encoder/MediaCodecAudioEncoder.kt`, video encoder, `stream/SrtStreamClient.kt`, `cpp/openstream_srt.cpp`; OBS `media-clock.hpp`, `async-control-client.*`, `openstream-source.cpp`; and behavioral media tests. +- **Change:** Surface video-only, audio muted/failed, encoder unavailable, software AAC fallback, SRT queue saturation, and reconnect reasons in the app and OBS. Keep phone software video encode disabled unless the user explicitly opts into it. Preserve source presentation timestamps and log recording gaps. Add executable tests for packet timestamps, loss, bounded queues, stop/release, and reconnect rather than relying only on Python source-text checks. +- **Prove:** A failed microphone does not silently look healthy; loss and overflow produce a visible state and bounded recovery. A/V offset stays measured after reconnect and Virtual Camera starts. Every performance adjustment includes same-device before/after CPU, latency, drops, battery, and heat. +- **Depends on:** A3, P1, and O2. Treat hardware OBS decode as an experiment after the baseline: the current probe always falls back to software. Ship a hardware path only if measured benefit justifies it and the fallback remains explicit. + +### U1. Make setup and labels match the real workflow + +- **Agent owns:** Android `strings.xml`, layouts, `MainActivity.kt`, `SettingsActivity.kt`; OBS visible labels and docs; new-user acceptance script. +- **Change:** Show OpenStream consistently while keeping confirmed legacy IDs and stored preference keys. Guide permission grant, OBS discovery, selection, pairing, first frame, and audio check. Put raw IP, ports, and latency in Advanced. Show version mismatch and offer a matched download. Make Stop and Disconnect distinct and discoverable. +- **Prove:** A person who has never built the repo reaches live video and audio without reading protocol docs, can identify the connected phone, and can stop or recover deliberately. Check labels and accessibility on a real phone and both OBS hosts. +- **Depends on:** A1, A3, O1, O2, P1, and P2. Do not use a broad visual redesign to substitute for connection clarity. + +### U2. Add actionable diagnostics and a feedback path + +- **Agent owns:** Android and OBS status presentation, bounded recent-event logs, a redacted support export, `docs/set-up.md`, and issue templates. +- **Change:** Report discovery, control, reservation, SRT media, audio, and encoder health separately. Give a next action for denied permission, wrong network, phone busy, firewall/port issue, codec failure, and stale reservation. Export exact app/plugin/OBS/OS versions, state transitions, and error codes only with user action; remove secrets, IP addresses, and raw media. +- **Prove:** Each injected failure maps to one clear message on the phone and OBS, and a tester can submit a useful report without a developer extracting Logcat. Validate redaction with tests and inspect an actual export. +- **Depends on:** P1, M1, and U1. + +### D1. Add the public trust and support basics + +- **Agent owns:** root license/provenance inventory, `SECURITY.md`, `CONTRIBUTING.md`, privacy/support docs, issue templates, and release notes process. +- **Change:** Identify copyright holders and bundled dependency obligations. The project owner chooses the top-level license after reviewing that inventory; then add the approved license. Publish supported host/device limits, a security contact, update instructions, and a feedback form. Do not call the repository open-source before the license exists. +- **Prove:** A new contributor can build and report an issue; a user can find the supported platforms and privacy behavior from the download page. After candidate staging in G1, run 10 to 20 matched-artifact beta installs with people outside the developer workflow. Record setup completion, time to first frame, failed reconnects, and common support questions. +- **Depends on:** B1 for true release claims. It can run in parallel with technical packages except where it edits their docs. + +## Stage 4: release gate + +### G1. Publish only a matched, exercised release + +- **Agent owns:** release workflow, `docs/release.md`, final evidence record, and release manifest. The coordinator is the single writer of the final release decision. +- **Automated gate:** Repository tests, Android JVM tests/lint/native-SRT APK, Android instrumentation where practical, Windows and Linux plugin builds/native tests, installer/package smoke tests, website build, version checker, signature/code continuity, and hashes must be VERIFIED. CI's Python contracts supplement native tests; they do not replace them. +- **Candidate staging:** Build one signed APK and both host packages from a fixed commit in a workflow that uploads candidate artifacts without publishing. Pin the commit and record each artifact hash in the manifest. The current tag workflow auto-publishes, so remove that behavior before this gate is used. +- **Physical gate:** Download and install those exact bytes on at least three Android devices and both supported OBS hosts. Start real OBS and check clean module load, one source picker entry, and the expected controls before media testing. Complete B2's 30-minute, network-loss, A/V sync, Virtual Camera, controls, screen lock, old scene, OBS shutdown, unrelated-source isolation, and reinstall/update checks. Test previous/previous, previous/candidate, candidate/previous, and candidate/candidate Android/plugin combinations. Record actual results and release blockers. A missing device result stays INCONCLUSIVE and blocks claims of that device's support. +- **Publication:** After sign-off, a manual publish workflow verifies the staged hashes, tags the tested commit, and attaches the **same files** and release notes. It does not rebuild. The published SHA-256 hashes must equal the tested manifest. A changed artifact requires repeat verification. Then update the website's published version. The release notes state tested hosts and known limits. Do not treat an automatic CI green result as the entire product sign-off. + +## Add after the core release is proven + +- Add a simple Stable 720p and Balanced 1080p profile selector when device measurements show both are sustainable. Show the actual resolution, frame rate, bitrate, latency target, and thermal warning. +- Add USB only if beta reports show Wi-Fi setup or stability is a frequent blocker and a prototype shows better results without making installation harder. +- Add an optional, privacy-safe feedback channel and a public compatibility matrix from completed device runs. +- Consider hardware decode, more simultaneous phones, macOS, remote contribution, and advanced camera controls only with a measured use case and a test plan for both ends. + +## Remove, hide, or stop claiming + +- Remove hard-coded current product versions from build defaults, tests, and public download copy. Keep historical release notes and independent protocol/ABI numbers. +- Remove `shin` from visible new-user labels after confirming and preserving the internal IDs that old artifacts require. +- Remove duplicate OBS live controls only after Windows and Linux have the same working replacement. Hide manual network fields in Advanced, not in protocol or developer docs. +- Stop calling unmeasured reconnect behavior “reliable” or promising a latency number without device evidence. Stop claiming V7/V8 scene support until old scene fixtures load in real OBS. +- Keep the Python receiver as a developer tool. Do not add cloud accounts, built-in recording, filters, automatic microphone mixing, or a second phone encoder to the supported one-phone path. diff --git a/docs/architecture.md b/docs/architecture.md index 1f6d0f56..3a2668ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ then connects directly over SRT. The discovery flow: -1. OBS plugin broadcasts UDP beacon every 1 second on port `51515` (multicast `239.255.42.99` + subnet broadcast). +1. OBS plugin broadcasts UDP beacon every 1 second on port `51615` (multicast `239.255.43.99` + subnet broadcast). 2. Android app listens for beacons and displays discovered OBS instances. 3. User taps an OBS device -> Android constructs `srt://:?mode=caller&latency=`. 4. Android MPEG-TS stream begins. @@ -40,7 +40,7 @@ URL for manual/QR pairing. ## Remote Camera Controls -The Android app runs a lightweight HTTP control server on port `9001`. The OBS +The Android app runs a lightweight HTTP control server on port `9101`. The OBS plugin sends POST requests to control the phone camera in real-time: | Endpoint | Body | Effect | @@ -61,7 +61,7 @@ transport can be tested without blocking the reliable product path. ## Transport Defaults -Canonical values live in [protocol.md](protocol.md): SRT `:9000`, control `:9001`, discovery UDP `:51515`, `12 Mbps`, `120 ms`. +Canonical values live in [protocol.md](protocol.md): SRT `:9100`, control `:9101`, discovery UDP `:51615`, `12 Mbps`, `120 ms`. ## Encoding Defaults diff --git a/docs/linux.md b/docs/linux.md index 46d63806..e354afe8 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -6,8 +6,8 @@ and validation notes for this branch. ## Scope - Native (distro or PPA) OBS Studio on Linux x86_64 with Qt6. -- **One active camera per OBS process**. Reuse the same source across scenes - with Add Existing. Physical phone acceptance testing is still required. +- One OBS source reserves one phone. Reuse that source across scenes with Add + Existing; a phone cannot be reserved by two sources at once. - **No Flatpak/Snap guarantee.** Sandboxed OBS builds isolate the plugin directory and network namespace, so the install paths and discovery behaviour below are not validated for them. @@ -18,16 +18,22 @@ Install the development packages first (example for Debian/Ubuntu; the build script never installs system packages itself): - `cmake` (>= 3.24), `g++` (C++20), `pkg-config`, `ninja` (optional) -- `libobs-dev` (>= 30; provides `libobs`, `obs-frontend-api` headers/library) +- `libobs-dev` (>= 30; provides the OBS core and frontend API development + files) - `libavformat-dev`, `libavcodec-dev`, `libavutil-dev`, `libswscale-dev` -- `qt6-base-dev` (>= 6.2, for Qt6 Network + Widgets) +- `qt6-base-dev` (>= 6.2, for Qt6 Core and Widgets) Notes: -- `obs-frontend-api.pc` may be absent on distro packages. That is legitimate: - `obs-plugin/CMakeLists.txt` probes it with pkg-config and falls back to - standard header/library lookup, which still requires the frontend headers - and `libobs-frontend-api` from `libobs-dev`. +- The native Linux module registers the **OpenStream Camera Control** dock in + OBS. It lists discovered phones, lets you name the source, connect or release + a reservation, and send zoom, lens, torch, and identify commands. These UI + dependencies stay in the OBS plugin target; the media engine does not link + OBS frontend or Qt Widgets. +- `libobs-dev` supplies the frontend API headers and link library on Ubuntu + 24.04, but does not ship an `obs-frontend-api.pc` pkg-config file. CMake + resolves the library through the system linker paths; the build script + checks the OBS core and Qt dependencies before configuring. - Linux links the **system OBS/FFmpeg ABI** (whatever the distro OBS was built against). There is intentionally no FFmpeg version pin on this path, unlike the Windows release build, which stays pinned to OBS 32.2.1 / @@ -79,8 +85,9 @@ Testing hook (touches nothing outside the given directory): The install is atomic (stage to a temp file in the destination, then rename), replaces the canonical module, and removes only other-named stale -copies (`openstream-beta-obs.so`, `libopenstream-obs.so`, the legacy flat -per-user copy) after the new copy succeeds. +copies (`shin-obs.so`, `openstream-beta-obs.so`, `libopenstream-obs.so`, and +the legacy flat per-user copy) after the new copy succeeds. A per-user +installation also removes the old Shin module from its former plugin folder. Uninstall: delete `openstream-obs.so` from the user or system plugin directory above and restart OBS. There is no uninstaller script. @@ -107,18 +114,22 @@ directory above and restart OBS. There is no uninstaller script. What was actually checked for this branch (local machine): -- `cmake` configure of `obs-plugin` against system libobs/FFmpeg/Qt6 with - no `OBS_ROOT`: passes. -- Full plugin build (`openstream-obs.so`) and native tests cover control - shutdown, SIGPIPE, single-camera ownership, JSON parsing and source settings. -- `ldd` confirms linkage to system `libobs`, `libobs-frontend-api`, - `libavformat`/`libavcodec`/`libavutil`/`libswscale`, Qt6 Network/Widgets. +- Direct `g++` C++20 syntax checks passed for the source, control client, and + dock against the installed OBS 32.2.2, FFmpeg, and Qt6 development headers. +- A manual shared-library link produced `openstream-obs.so`. `ldd` confirmed links + to `libobs`, `libobs-frontend-api`, FFmpeg, Qt6 Core, and Qt6 Widgets, with no + missing libraries. The dock registration symbols are present in the module. +- The Linux installer smoke test copied the linked module into an isolated + `--dest-dir`; the installed file matched the built module byte for byte. +- Focused Python contracts passed (4 tests), and `bash -n` passed for the + Linux build and installer scripts. +- CMake and CTest were not run because CMake is unavailable in this environment. - See [reliability audit](reliability-audit.md) for the final check results. - No installation into the running user’s OBS configuration is part of validation. What was NOT validated and remains a blocker for release claims: -- Loading `openstream-obs.so` inside a running OBS GUI on Linux. +- Loading `openstream-obs.so` inside a running native OBS GUI on Linux, including + dock behavior. - A physical phone streaming over Wi-Fi (discovery, reservation, SRT media, reconnect, controls). - Any Flatpak/Snap OBS build, any distro other than the validation diff --git a/docs/product-readiness-review.md b/docs/product-readiness-review.md new file mode 100644 index 00000000..674a0d1f --- /dev/null +++ b/docs/product-readiness-review.md @@ -0,0 +1,109 @@ +# OpenStream product readiness review + +Reviewed on 2026-09-23 against the current `fix/openstream-prod-reliability` candidate. This is a source and build review, not a device acceptance result. The published v1.0.1 downloads predate the candidate changes. + +The ordered implementation plan is in the [agent roadmap](agent-roadmap.md). + +## Assessment + +**Confidence that this candidate is ready for broad, professional use: 5/10.** This is a judgment about release evidence and user experience, not a measured failure rate. The core Android-to-OBS design is credible and the product reportedly works, but a new user still has several ways to get stuck without a clear recovery path. The most important candidate workflow has no recorded physical phone and OBS sign-off. + +| Area | Score | Evidence | +| --- | ---: | --- | +| Core media and protocol engineering | 7/10 | Hardware phone encode, bounded SRT send queue, source timestamp handling, reservation logic, reconnect status, Linux and Windows native builds. See `docs/architecture.md`, `docs/protocol.md`, and `docs/reliability-audit.md`. | +| Reliability proof | 4/10 | CI runs repository, Android, and plugin checks, but `docs/reliability-audit.md` records no candidate device run. `docs/testing.md` lists the missing 30-minute, loss, sync, and old/new compatibility checks. | +| First-use and daily operation | 4/10 | Discovery, manual connection, source controls, and setup docs exist. Android and the current OBS source still display `shin`, while setup docs instruct users to add `OpenStream V8`. The live control dock builds only on Linux. | +| Installation and release discipline | 6/10 | Android signing and version-code gates, Windows installer tests, Linux packaging, checksums, and CI are present. The published release differs from the current candidate and Linux support is still a source-built candidate. | +| Trust and user feedback | 3/10 | No top-level project license, contribution guide, security contact, privacy statement, issue templates, or recorded user acceptance results. The absence of open issues is not evidence of user satisfaction. | + +The score is weighted toward the core workflow and reliability proof. It should rise only after candidate builds pass repeatable device tests and people outside the development environment complete setup and recovery without help. + +## What already works well + +- The product has a coherent local path: Camera2, hardware AVC and AAC, MPEG-TS over SRT, and a native OBS source. It avoids a cloud account and a second phone encoder in the standard mode. +- The app and plugin have explicit ownership and reconnect behavior. The phone transport has a byte-bounded send queue; the receiver preserves source timestamps and logs late media gaps. +- CI covers Android build and lint, C++ tests, Windows packaging, Linux packaging, and release signing checks. Saved-scene compatibility still needs an actual old-scene load test. +- The repo has a setup guide and protocol documentation, which give future maintainers a base for field support. + +## Highest-impact gaps + +1. **Prove the complete candidate workflow on devices.** `docs/reliability-audit.md` explicitly lacks a physical Android-to-OBS run. Test install-over-release, discovery, pairing, 30-minute video and audio, screen lock/background behavior, lens and torch changes, short and long Wi-Fi loss, OBS restart, Virtual Camera, and source removal on Windows and native Linux. Record device, OS, OBS version, network, measured latency, A/V offset, dropped frames, heat, and recovery time. Test previous and candidate Android/plugin combinations before a protocol release. +2. **Give the media session an Android owner independent of the activity.** `MainActivity.kt` creates the camera, encoders, transport, discovery, and control server, then stops them in `onStop()`. `surfaceDestroyed()` also calls camera/server teardown from a UI callback, and the Stop button schedules the listener to start again. A foreground camera service or equivalent session owner should manage live state, a real Stop/Start action, and teardown off the UI thread; the activity should observe it. First reproduce background and screen-lock behavior on supported Android versions, then implement the lifecycle change with behavioral tests. +3. **Make pairing authorization real.** `docs/protocol.md` says protocol V1 does not authenticate the control channel. `CameraControlServer.kt` accepts an optional reservation token and authorizes camera commands by reserved peer address. Add an explicit pairing confirmation and session secret, validate every control command, and show the paired OBS identity on the phone. Roll out with a versioned Android/plugin compatibility plan so old clients fail with an understandable upgrade message. +4. **Make one connection model visible everywhere.** The Android launcher name and current OBS source picker say `shin`; the docs call the source `OpenStream V8`. The Linux dock is absent from the Windows build. Verify saved scenes from earlier releases in OBS before changing any source ID, then present `OpenStream Camera` consistently in the phone, OBS source, controls, installer, and docs. Use the same human-readable states: Available, Pairing, Connected, Live, Reconnecting, and Action needed. +5. **Make failure recovery a product feature.** Show whether discovery, control, and SRT media are reachable separately. Give a specific next action for denied permissions, wrong network, busy phone, blocked port, failed hardware encoder, missing audio, and stale reservation. Add a redacted support bundle with app/plugin versions, device/OBS/OS details, recent state changes, and error codes. Never include pairing secrets or raw video. +6. **Finish the public trust basics.** Choose and add a top-level license after checking ownership and bundled dependencies. Add a security reporting path, privacy and local-network data statement, supported-platform table, contribution guide, issue templates, and a changelog tied to actual artifacts. Do not call the repository open-source until the license is published. + +## Feature decisions + +| Priority | Include | Why and acceptance check | +| --- | --- | --- | +| Next release | A guided first connection that checks permissions, matching app/plugin versions, phone and OBS discovery, reservation, then first video and audio | A new user completes setup without reading protocol docs; each failure names one corrective action. | +| Next release | Persistent live session, explicit Stop, reconnect reason and progress, and visible audio status | Locking the screen or opening another app follows a documented behavior; a brief network loss returns to the same OBS source. | +| Next release | Diagnostic screen and support bundle | A tester can report a failure with device and error details without sending video or secrets. | +| Next release | Device and OBS compatibility matrix in CI/manual sign-off | At least a small, named set of Android vendors and current OBS versions passes the same scripted scenarios before publishing. | +| After reliability gate | Simple quality presets such as Stable 720p and Balanced 1080p, with actual bitrate, frame rate, and health shown | Profiles are validated on hardware and tell users when thermal or network conditions force a change. Measure latency and heat before shipping adaptive behavior. | +| After reliability gate | USB transport only if user tests show local Wi-Fi is a frequent blocker | Compare setup time, stability, and maintenance cost against the existing SRT path before adding a second transport. | +| Later, only with demand | Multiple simultaneous phones, macOS, remote internet transport, recording, effects, or cloud accounts | These expand the protocol, support matrix, or market position. Prioritize from user requests and evidence, not a feature checklist. | + +## One version system for the whole release + +The current release number is copied into `android/gradle.properties`, `obs-plugin/CMakeLists.txt`, both plugin build scripts, the Inno installer, the Windows CI workflow, the website source and metadata, and release prose. The repository test even asserts several literal `1.0.1` strings. The release workflow checks the Android value against the tag, but a local or pull-request build can still produce mismatched artifacts. Android's version code is a separate monotonic install counter, while the `V7`/`V8` OBS names and protocol `1` are compatibility identifiers. These four concepts need distinct names. + +Use one root file, for example `release/version.properties`, as the source for **candidate product version** and **Android version code**: + +```properties +productVersion=1.0.1 +androidVersionCode=1789925364 +``` + +The next public release must use a larger Android version code than the maximum previously published value. Keep `applicationId=dev.openstream.app` and the existing release signing certificate. Never derive the version code from SemVer or reset the current large code to `10200`; Android would reject that as a downgrade. Do not put the signing key in the version file. + +Implement the migration as one focused PR: + +1. Make Gradle read both properties from the root file. Make CMake and the Windows/Linux build scripts read `productVersion` or require the same value as an explicit build argument; remove their `1.0.1` fallbacks. Pass that value into the Inno installer. The website build should receive the version only when showing a candidate; its public download label should come from the **latest published release**, not the current development checkout. +2. Add `tools/check_release_version.py` as a platform-neutral CI gate. It should validate SemVer, a positive Android version code, the release tag, embedded APK metadata, the plugin's built version, the installer version, and the version in each artifact manifest. It should reject a mismatch and check the previous published APK's code and signing certificate. Replace tests that assert literal `1.0.1` with tests for that behavior. +3. Publish one matched release manifest alongside the APK and Windows/Linux packages. Include product version, Android code, protocol version, plugin build identifiers, supported OBS ABI, artifact hashes, and signing-certificate fingerprint. The download page can show the manifest's release version and flag app/plugin mismatches during pairing. +4. Keep wire protocol version, legacy OBS source IDs, and OBS/FFmpeg ABI baseline separate. Increase them only when their own compatibility rules require it. Keep fixed version numbers in historical release notes; do not generate old release history from the current candidate version. + +For example, a future candidate could be `productVersion=1.1.0` with `androidVersionCode=1789925365`, if that code exceeds every published APK for the same application ID. The wire protocol could remain `1`, and an old saved scene could still have its V7 source ID. The release tag would be `v1.1.0`, and CI would prove that the APK, both plugin packages, installer, manifest, and release page all describe `1.1.0` before publication. + +## Trim or consolidate + +- Preserve any legacy source and `shin` protocol IDs that old artifacts actually use, but remove them from new user-facing labels. Prove saved-scene support with old scene fixtures before claiming compatibility. +- Put setup options in OBS source properties and live controls/status in one control area on both supported hosts. Build a Windows dock or choose a cross-platform alternative before removing existing property controls. Keep the Android screen focused on phone selection, preview, camera controls, and a clear Stop action. +- Hide raw host, port, and latency fields behind an Advanced path. Offer manual pairing only when discovery fails or the user chooses it. +- Stop promoting unvalidated claims such as “reliable reconnects” or specific low latency in public copy until device measurements support them. Keep the existing technical target values in protocol docs. +- Keep the Python receiver as a developer tool. It should not become another supported end-user path. +- Avoid effects, built-in recording, and a cloud account in the next release. OBS already owns production and recording; OpenStream's job is to supply a dependable phone camera source. + +## A practical release sequence + +1. **First evidence pass.** Run the current candidate with at least three distinct Android devices and both supported OBS hosts. Measure setup completion, 30-minute stability, reconnect time, audio/video offset, and support questions. Fix any crash, stuck reservation, or silent media failure first. +2. **Lifecycle and pairing pass.** Move session ownership out of the activity, add observable connection states, then version and secure the pairing/control protocol. Test candidate/previous combinations and add scene adapters only for IDs confirmed in old release artifacts. +3. **Professional onboarding pass.** Align branding, simplify the OBS setup and live dock, add actionable diagnostics, and test the entire flow with people who have never built the repo. +4. **Release operations pass.** Publish a license and support policies, automate compatibility gates where possible, record manual acceptance evidence for the exact release artifacts, and release the Android APK and matching OBS plugin together. + +Before claiming better performance, capture before-and-after latency, dropped frames, CPU, memory, battery, and heat on the same devices and network. Before claiming broad support, track the percentage of first-time users who reach live video, time to first frame, and successful reconnect rate. + +For a small public beta, recruit 10 to 20 users who did not build the project, including at least two Android vendors and both OBS host platforms. Give each tester the matched APK and plugin packages, a short task script, and one feedback form. Ask them to install, reach live video, switch a lens, recover from a network drop, and update to the next build. Record where they stop, the error they see, and the time it takes to recover. Review reports weekly, publish known issues with workarounds, and use the measured failures to choose the next change. Make logs opt-in and remove IP addresses and pairing data before upload. + +## Position against other products + +| Product | Strong fit | Trade-off for OpenStream users | +| --- | --- | --- | +| OpenStream | A local Android-to-native-OBS source with camera controls and no account | One phone, LAN-only, limited release/device proof, and a narrower platform matrix. | +| [DroidCam OBS](https://droidcam.app/obs/) | Established OBS phone-camera plugin with Wi-Fi and USB and support for multiple devices | OpenStream cannot win on transport or device breadth yet; it can compete on a focused open, local, diagnosable workflow if licensing and reliability evidence catch up. | +| [Camo Studio](https://camo.com/studio) | Polished camera setup, USB/wireless connection, video adjustments, and broad desktop app use | Its wider creative scope is a poor immediate target for OpenStream. Match the clarity of setup and controls before adding effects. | +| [VDO.Ninja](https://docs.vdo.ninja/getting-started/mobile-phone-camera-into-webcam) | Browser-based phone camera into an OBS Browser Source, useful for remote guests and quick joining | OpenStream's native local source can be simpler for repeated LAN use if pairing and recovery become predictable; remote contribution is a separate product problem. | + +OpenStream's useful position is a dependable, privacy-conscious Android camera for OBS on the same network. The strongest near-term advantage would be predictable reconnection and clear diagnosis, backed by a published device matrix. It does not need to match Camo's editing tools or VDO.Ninja's remote guest workflow. + +## Checks performed for this review + +- `python -m pytest -q`: 115 passed. +- Five previously built native test executables under `obs-plugin/build/`: passed when run directly. This does not prove that those binaries match the current source commit. +- `npm run build` in `website`: passed. +- Android unit tests and lint: attempted, blocked because this workstation has no configured Android SDK. +- `ctest`: unavailable on this workstation; CI defines native CTest jobs. +- No physical Android device or OBS session was used. No runtime latency, heat, stability, or first-use success rate was measured. diff --git a/docs/protocol.md b/docs/protocol.md index de42a5c2..d937a20c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,12 +1,12 @@ -# shin Protocol Specification +# OpenStream Protocol Specification ## Overview -shin V1.0.1 currently uses version 1 of its LAN wire protocol. Product +OpenStream V1.0.1 currently uses version 1 of its LAN wire protocol. Product version numbers and protocol versions are independent: `SHIN/1` and `SHIN_PHONE/1` below identify the protocol, not the app release. -shin uses three communication channels between the Android phone and OBS: +OpenStream uses three communication channels between the Android phone and OBS: 1. **Media Stream** - SRT/MPEG-TS for video + audio (phone -> OBS) 2. **Discovery** - UDP multicast/broadcast beacons (bidirectional) @@ -123,8 +123,13 @@ SHIN_PHONE/1 {"type":"dev.shin.phone","version":1,"name":"","instan | `reservedBy` | string | OBS source instance that currently owns the reservation, or empty | OBS keeps a registry of discovered phones keyed by `instanceId`. Each -shin source has a `selected_phone_id` setting. `auto` selects the first +The OpenStream source has a `selected_phone_id` setting. `auto` selects the first non-busy phone; any other value binds that source to one specific phone. +Selection alone does not grant ownership: the source must successfully call +`/reserve` before it may open media or send camera controls. A phone whose +`reservedBy` names a different source is unavailable to that source, even if +its ID is selected manually. On disconnect, the owner calls `/release`; a +failed release is retried and remains visible in the OBS status/log. ### Fallback Pairing @@ -134,6 +139,9 @@ If discovery is blocked, the OBS source exposes a deep-link URL: shin://connect?slotId=&slotLabel=&sourceInstanceId=&host=&port=&latency=&name=... ``` +The Android app also accepts the legacy `openstream://connect` scheme so +previously saved pairing links continue to open the app. + This can be encoded as a QR code or entered manually in the Android app. --- @@ -177,6 +185,11 @@ Content-Type: application/json Clears the reservation after disconnect. +The release is idempotent for the owning `sourceInstanceId`; a request from a +different source cannot clear another source's reservation. OBS sends release +when a source is stopped or removed, including a source that was reserved but +did not yet receive SRT media. + ### Endpoints #### Set Zoom diff --git a/docs/release.md b/docs/release.md index c9551dd1..a8001025 100644 --- a/docs/release.md +++ b/docs/release.md @@ -3,53 +3,72 @@ OpenStream releases should give users direct installable assets instead of making them use GitHub source-code archives. The Windows plugin baseline is OBS Studio **32.2.1 x64**. Its package contains only -`openstream-obs.dll`; it deliberately does not ship a -second copy of OBS/FFmpeg DLLs. +`openstream-obs.dll`; it deliberately does not ship a second copy of OBS/FFmpeg +DLLs. Linux packages are built on Ubuntu 24.04 and use the host's OBS, FFmpeg, +and Qt libraries. Install them only on compatible x86_64 distributions; the +archive does not bundle those system libraries. ## Release Assets | Asset | Audience | Purpose | |---|---|---| -| `openstream-android.apk` | Android users | Signed install package for the OpenStream V1.0.1 camera app. | +| `openstream-android.apk` | Android users | Signed install package for the OpenStream camera app. | | `openstream-android.apk.sha256` | Android users and automation | SHA-256 checksum for the exact APK in the release. | | `openstream-obs-plugin-installer-windows-x64.exe` | Windows OBS users | Recommended one-click OBS plugin installer. | +| `openstream-obs-plugin-installer-windows-x64.exe.sha256` | Windows users and automation | SHA-256 checksum for the installer. | | `openstream-obs-windows-x64.zip` | Technical users | Manual plugin package with DLL and install scripts. | | `openstream-obs-windows-x64.zip.sha256` | Technical users and automation | SHA-256 checksum for the exact manual plugin package. | +| `openstream-obs-linux-x86_64.tar.gz` | Native Linux OBS users | Linux plugin module and install script for compatible distro OBS builds. | +| `openstream-obs-linux-x86_64.tar.gz.sha256` | Linux users and automation | SHA-256 checksum for the exact Linux package. | -### Android compatibility note +### Android compatibility and updates -The V1.0.1 APK is a universal signed APK with `minSdk 29` and native libraries -for `arm64-v8a`, `armeabi-v7a`, `x86`, and `x86_64`. It is therefore suitable for -modern ARM64 phones such as the Samsung Galaxy S24 Ultra. Camera lens availability -and device-specific Camera2 behavior should still be verified on the target phone. +The release APK keeps the published application ID `dev.openstream.app` and +release signing identity so existing installs can update in place. +Android can update an existing installation only when the application ID and +signing certificate match and the new APK has a higher version code. The release +workflow checks the version code against prior tagged releases and compares the +APK certificate with the latest published APK. Keep the four Android signing +secrets below stable; replacing the keystore or alias prevents in-place updates +and requires users to uninstall first, losing app data. + +The APK is universal with `minSdk 29` and native libraries for `arm64-v8a`, +`armeabi-v7a`, `x86`, and `x86_64`. Camera lens availability and device-specific +Camera2 behavior still require checking on target phones. ## Automated Release -Create and push the V1.0.1 release tag: +Set both `openstream.versionName` and `openstream.versionCode` in +`android/gradle.properties`, then create and push a matching `vMAJOR.MINOR.PATCH` +tag that points to that commit. The version name must equal the tag without its +leading `v`; the version code must be a positive integer greater than every +previous release code and no greater than Android's 2,100,000,000 limit. ```powershell git tag v1.0.1 git push origin v1.0.1 ``` +The same workflow can be run manually with an already pushed release tag. It +builds the commit referenced by that tag and rejects malformed or mismatched +tags. It does not create a release from the current default branch. Older +releases used the tag commit timestamp as the Android version code; the +workflow uses that value when checking historical tags without the canonical +`openstream.versionCode` property. + The `Release` workflow builds: | Job | Output | |---|---| | Android APK | `openstream-android.apk`, `openstream-android.apk.sha256` | | OBS plugin package | `openstream-obs-windows-x64.zip`, `openstream-obs-windows-x64.zip.sha256` | -| OBS plugin installer | `openstream-obs-plugin-installer-windows-x64.exe` | - -The publish job runs only after the repository tests, Android unit tests and -lint, signed Android build, and OBS plugin build succeed. It downloads the -artifacts, normalizes the APK name, writes its SHA-256 sidecar, and runs -`gh release create`. - -You can also run the `Release` workflow manually from GitHub Actions with the -tag `v1.0.1`. +| OBS plugin installer | `openstream-obs-plugin-installer-windows-x64.exe`, `openstream-obs-plugin-installer-windows-x64.exe.sha256` | +| Linux OBS plugin | `openstream-obs-linux-x86_64.tar.gz`, `openstream-obs-linux-x86_64.tar.gz.sha256` | -The Android job passes the release tag into Gradle as the APK `versionName` and -uses the release commit timestamp as `versionCode`. +The publish job runs only after repository tests, Android unit tests and lint, +the signed Android build, and both Windows and Linux plugin builds succeed. It +downloads all artifacts, verifies each published binary's SHA-256 sidecar, and +then runs `gh release create`. Public releases require all Android signing secrets. Missing or incomplete signing inputs fail the workflow; it never publishes a debug-signed fallback. @@ -129,13 +148,16 @@ only; do not publish that APK as a release. - Confirm the README links point to the release tag being published. - Confirm the setup guide links to the same APK, installer EXE, and plugin zip. -- Confirm pytest, Android unit tests, lint, and both production builds passed. +- Confirm pytest, Android unit tests, lint, and Android, Windows, and Linux release builds passed. - Confirm `openstream-android.apk.sha256` matches the APK. +- Confirm `openstream-obs-plugin-installer-windows-x64.exe.sha256` matches the installer. - Confirm `openstream-obs-windows-x64.zip.sha256` matches the manual plugin package. +- Confirm `openstream-obs-linux-x86_64.tar.gz.sha256` matches the Linux package. - Confirm OBS lists `OpenStream V8` and can still load saved `openstream_phone_v7_source` scenes. - Confirm the dependency report names `avformat-62.dll`, `avcodec-62.dll`, `avutil-60.dll`, and `swscale-9.dll`, and the clean OBS 32.2.1 log has no OpenStream module-load error. - Seed `openstream-obs.dll`, run both installer forms, and confirm stale Program Files, ProgramData, and AppData copies were migrated without touching OBS settings or scenes. - Confirm the Android APK is release-signed and installable over the previous public release. +- Confirm the signing identity matches the previous release and the version code increased. - Confirm protocol-affecting Android and OBS changes are released together and pass the old/new compatibility matrix. - Confirm the GitHub release assets are attached, not only source-code archives. - Confirm the repository website is set to `https://openstream.pages.dev`. diff --git a/docs/set-up.md b/docs/set-up.md index da01ea3c..a2bc9e12 100644 --- a/docs/set-up.md +++ b/docs/set-up.md @@ -2,7 +2,8 @@ This guide is the slower, screenshot-led path for installing OpenStream V1.0.1 on an Android phone and a Windows OBS Studio PC. -For native Linux, follow [Linux setup](linux.md). Published Windows/APK downloads predate the solo-camera changes on this branch. +For native Linux, follow [Linux setup](linux.md). The same phone pairing and +per-source ownership rules apply on Linux and Windows. For the fast technical version, use the [README quick start](../README.md#quick-start). @@ -133,9 +134,36 @@ If the phone does not find OBS, open the source’s **Network & Pairing (Advance | Value | Default | |---|---| | OBS PC IP address | Your PC's LAN IP, for example `192.168.1.25` | -| SRT port | `9000` | +| SRT port | `9100` | | Latency | `120 ms` | +The phone uses its own control endpoint on port `9101`; do not enter that port +as the media port. Manual receive mode is an advanced fallback: it makes the +phone call the OBS listener directly, while automatic pairing reserves the +selected phone before opening its SRT stream. + +### Connect and disconnect a source + +Each OBS source is one camera slot. In the source properties, choose a phone +under **1. Camera**, then click **Connect / Reconnect**. With **Automatic +pairing**, OBS reserves that phone and the phone shows the selected slot label. +The source status changes from `Waiting` to `Reserved` and then `Live` after +SRT media arrives. Use **Add Existing** when the same camera should appear in +another scene; creating another active source for the same camera is blocked. + +To disconnect, stop or remove the source, or use the source's **Disconnect** +control when it is shown. OBS releases the reservation and the phone returns +to its available state. Disconnecting one source does not stop another phone. +If the phone is reserved but media has not arrived, disconnect still releases +the reservation; a short reconnect window may keep the same phone preferred +when the network briefly drops. + +On Android, tap an OBS device in the discovered list to connect. The active +card shows the slot label and a **Disconnect** action. Tap it before selecting +another OBS slot. The phone's **Settings** screen also provides **Save** and +**Save and connect** for manual pairing; the saved host, port, latency and +listening port survive a disconnect and app restart. + --- ## 6. Confirm Audio and Controls @@ -161,7 +189,7 @@ In OBS: | OpenStream V8 is missing in OBS | Confirm `openstream-obs.dll` is in one of the plugin folders above, remove stale older copies, then restart OBS. | | Old OpenStream source is still visible | OBS may be loading an older all-users DLL from `C:\ProgramData\obs-studio\plugins\openstream-obs\bin\64bit\`. Replace it with the V1.0.1 DLL or remove it. | | Phone cannot see OBS | Put both devices on the same Wi-Fi, disable VPNs, and check guest/client isolation. | -| Camera stays blank | Start with port `9000`, latency `120 ms`, and one phone only. | +| Camera stays blank | Start with media port `9100`, latency `120 ms`, and one phone only. | | Audio is missing | Grant microphone permission on Android and check the OBS mixer channel. | | Stream stutters | Use 5 GHz or Wi-Fi 6, move closer to the router, and avoid congested networks. | @@ -172,7 +200,7 @@ In OBS: The Python receiver is a developer/debug path only; it is not part of the normal user workflow. ```powershell -python tools/openstream_receiver.py --port 9000 --latency-ms 120 --ffplay +python tools/openstream_receiver.py --port 9100 --latency-ms 120 --ffplay ``` Use it only when debugging SRT transport outside OBS. diff --git a/docs/testing.md b/docs/testing.md index c2eca638..7415a424 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -21,11 +21,11 @@ implementation. ## Device acceptance tests - One Android phone streams 1080p30 for 30 minutes without receiver crash. -- Android discovers an OBS `OpenStream Phone Link` listener without manual IP entry. +- Android discovers a OpenStream OBS source without manual IP entry. - Tapping a discovered OBS device starts the stream directly. - Stopping the OBS listener removes the device from Android discovery within about 5 seconds. - SRT reconnect completes within 2 seconds after a short Wi-Fi interruption. -- OBS receives video as one source. +- OBS receives video through the selected `shin` source. - OBS receives mono AAC audio at 48 kHz on the source's mixer channel. - The OBS source shows only the phone camera feed, never the Android screen. - Starting OBS Virtual Camera while the source is live does not create an diff --git a/obs-plugin/CMakeLists.txt b/obs-plugin/CMakeLists.txt index 72a2645c..58b33578 100644 --- a/obs-plugin/CMakeLists.txt +++ b/obs-plugin/CMakeLists.txt @@ -46,11 +46,6 @@ endif() find_package(Qt6 6.2 COMPONENTS Core REQUIRED) if (BUILD_TESTING) - add_executable(openstream-solo-camera-tests tests/test_solo_camera.cpp) - target_include_directories(openstream-solo-camera-tests PRIVATE src) - target_link_libraries(openstream-solo-camera-tests PRIVATE Threads::Threads) - add_test(NAME openstream-solo-camera-tests COMMAND openstream-solo-camera-tests) - add_executable(openstream-control-json-tests tests/test_control_json.cpp) target_include_directories(openstream-control-json-tests PRIVATE src) target_link_libraries(openstream-control-json-tests PRIVATE Qt6::Core) @@ -134,6 +129,7 @@ else() find_package(PkgConfig QUIET) if (PkgConfig_FOUND) pkg_check_modules(PC_LIBOBS QUIET libobs) + pkg_check_modules(PC_OBS_FRONTEND QUIET obs-frontend-api) pkg_check_modules(PC_AVFORMAT QUIET libavformat) pkg_check_modules(PC_AVCODEC QUIET libavcodec) pkg_check_modules(PC_AVUTIL QUIET libavutil) @@ -203,6 +199,20 @@ target_link_libraries(openstream-obs PRIVATE ${SWSCALE_LIBRARY} ) +if (UNIX AND NOT APPLE) + # Native OBS Studio exposes its dock API through obs-frontend-api. Keep + # this UI dependency at the plugin boundary; the media engine stays in + # openstream-source.cpp and does not depend on OBS frontend or Qt Widgets. + find_package(Qt6 6.2 COMPONENTS Widgets REQUIRED) + find_library(OBS_FRONTEND_LIBRARY + NAMES obs-frontend-api + HINTS ${PC_OBS_FRONTEND_LIBRARY_DIRS} ${PC_LIBOBS_LIBRARY_DIRS} + REQUIRED + ) + target_sources(openstream-obs PRIVATE src/openstream-dock.cpp) + target_link_libraries(openstream-obs PRIVATE Qt6::Widgets ${OBS_FRONTEND_LIBRARY}) +endif() + if (WIN32) target_link_libraries(openstream-obs PRIVATE ws2_32 iphlpapi) endif() @@ -226,8 +236,7 @@ if (WIN32) # Keep the existing Windows packaging contract outside this Linux release. set_target_properties(openstream-obs PROPERTIES PREFIX "") else() - # A distinct module name allows shin and OpenStream to coexist. - set_target_properties(openstream-obs PROPERTIES PREFIX "" OUTPUT_NAME "shin-obs") + set_target_properties(openstream-obs PROPERTIES PREFIX "") endif() # Install rules: `cmake --install` stages the module into OBS's plugin dir. @@ -235,7 +244,9 @@ endif() # distro libdir (e.g. /usr/lib/obs-plugins or ~/.local/lib/obs-plugins). if (WIN32) install(TARGETS openstream-obs - RUNTIME DESTINATION "obs-plugins/64bit") + RUNTIME DESTINATION "obs-plugins/64bit" + LIBRARY DESTINATION "obs-plugins/64bit" + ARCHIVE DESTINATION "obs-plugins/64bit") else() include(GNUInstallDirs) install(TARGETS openstream-obs diff --git a/obs-plugin/src/openstream-control-api.hpp b/obs-plugin/src/openstream-control-api.hpp index 23735ec3..adfdfaaf 100644 --- a/obs-plugin/src/openstream-control-api.hpp +++ b/obs-plugin/src/openstream-control-api.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include // UI-safe bridge used by the frontend dock. The source reference is used only // during the call; queued work copies its network target and JSON body. @@ -10,6 +11,11 @@ bool openstream_post_camera_command(obs_source_t *source, const char *path, bool openstream_start_camera_source(obs_source_t *source); bool openstream_stop_camera_source(obs_source_t *source); const char *openstream_source_status(obs_source_t *source); +size_t openstream_camera_phone_count(obs_source_t *source); +const char *openstream_camera_phone_id(obs_source_t *source, size_t index); +const char *openstream_camera_phone_name(obs_source_t *source, size_t index); +bool openstream_select_camera_phone(obs_source_t *source, const char *phone_id); +bool openstream_set_camera_label(obs_source_t *source, const char *label); void openstream_register_dock(); void openstream_unregister_dock(); diff --git a/obs-plugin/src/openstream-dock.cpp b/obs-plugin/src/openstream-dock.cpp index 8fc596dd..68f51f1a 100644 --- a/obs-plugin/src/openstream-dock.cpp +++ b/obs-plugin/src/openstream-dock.cpp @@ -3,168 +3,215 @@ #include #include -#include +#include #include #include #include -#include -#include -#include -#include -#include -#include +#include #include +#include #include -#include #include -#include #include -#include -#include #include namespace { -int compareSemver(const QString &left, const QString &right) { - const auto split = [](const QString &version) { - const int dash = version.indexOf(QChar('-')); - return qMakePair(QVersionNumber::fromString( - dash < 0 ? version : version.left(dash)), - dash < 0 ? QString() : version.mid(dash + 1)); - }; - const auto lhs = split(left); - const auto rhs = split(right); - const int core = QVersionNumber::compare(lhs.first, rhs.first); - if (core != 0) return core; - if (lhs.second.isEmpty() != rhs.second.isEmpty()) - return lhs.second.isEmpty() ? 1 : -1; - if (lhs.second == rhs.second) return 0; - const QStringList leftIds = lhs.second.split(QChar('.')); - const QStringList rightIds = rhs.second.split(QChar('.')); - const int count = std::min(leftIds.size(), rightIds.size()); - for (int i = 0; i < count; ++i) { - bool leftNumeric = false; - bool rightNumeric = false; - const int leftNumber = leftIds[i].toInt(&leftNumeric); - const int rightNumber = rightIds[i].toInt(&rightNumeric); - if (leftNumeric && rightNumeric && leftNumber != rightNumber) - return leftNumber < rightNumber ? -1 : 1; - if (leftNumeric != rightNumeric) return leftNumeric ? -1 : 1; - const int text = QString::compare(leftIds[i], rightIds[i], Qt::CaseSensitive); - if (text != 0) return text < 0 ? -1 : 1; - } - if (leftIds.size() == rightIds.size()) return 0; - return leftIds.size() < rightIds.size() ? -1 : 1; -} +constexpr int kSourceRefreshMs = 1500; class OpenStreamDock final : public QWidget { public: OpenStreamDock() { setObjectName("OpenStreamCameraControl"); + auto *layout = new QVBoxLayout(this); - source_ = new QLabel(this); - status_ = new QLabel("No OpenStream camera source", this); - status_->setWordWrap(true); - layout->addWidget(new QLabel("Your phone camera", this)); - layout->addWidget(source_); + source_selector_ = new QComboBox(this); + source_selector_->setObjectName("openstreamDeviceSelector"); + source_selector_->setAccessibleName("OpenStream source"); + layout->addWidget(new QLabel("Source", this)); + layout->addWidget(source_selector_); + phone_selector_ = new QComboBox(this); + phone_selector_->setObjectName("openstreamPhoneSelector"); + phone_selector_->setAccessibleName("OpenStream phone"); + layout->addWidget(new QLabel("Phone", this)); + layout->addWidget(phone_selector_); + + status_ = new QLabel("No OpenStream source", this); + status_->setObjectName("openstreamStatus"); layout->addWidget(status_); + auto *name_row = new QHBoxLayout(); + name_row->addWidget(new QLabel("Name", this)); + name_ = new QLineEdit(this); + name_->setObjectName("openstreamSourceName"); + name_->setAccessibleName("OpenStream source name"); + name_->setMaxLength(128); + name_->setPlaceholderText("Name this source"); + name_row->addWidget(name_); + layout->addLayout(name_row); + connect(name_, &QLineEdit::editingFinished, this, + [this] { renameSelectedSource(); }); + auto *connection = new QHBoxLayout(); - auto *retry = new QPushButton("Connect / retry", this); - auto *stop = new QPushButton("Stop", this); - // These API calls only publish into the source's bounded, coalescing - // lifecycle slot. Network work and worker joins happen off the UI thread. - connect(retry, &QPushButton::clicked, this, [this] { - obs_source_t *selected = currentSource(); - if (!selected) { - status_->setText("Add one OpenStream source in OBS to connect your phone."); + test_connect_ = new QPushButton("Test / connect", this); + test_connect_->setObjectName("openstreamTestConnect"); + disconnect_ = new QPushButton("Disconnect / release", this); + disconnect_->setObjectName("openstreamDisconnect"); + connection->addWidget(test_connect_); + connection->addWidget(disconnect_); + layout->addLayout(connection); + + // These calls only publish into the source's bounded, coalescing slots. + // Network work and worker joins stay outside the OBS UI thread. + connect(test_connect_, &QPushButton::clicked, this, [this] { + obs_source_t *source = currentSource(); + if (!source) { + showNoSource(); return; } - const bool queued = openstream_start_camera_source(selected); - status_->setText(queued ? "Connection starting in background..." - : "Camera source is shutting down"); + const bool queued = openstream_start_camera_source(source); + status_->setText(queued ? "Connection test queued in background..." + : "Could not queue the connection test."); }); - connect(stop, &QPushButton::clicked, this, [this] { - obs_source_t *selected = currentSource(); - if (!selected) { - status_->setText("Add one OpenStream source in OBS to connect your phone."); + connect(disconnect_, &QPushButton::clicked, this, [this] { + obs_source_t *source = currentSource(); + if (!source) { + showNoSource(); return; } - const bool queued = openstream_stop_camera_source(selected); - status_->setText(queued ? "Stopping camera in background..." - : "Camera source is shutting down"); + const bool queued = openstream_stop_camera_source(source); + status_->setText(queued ? "Disconnect queued; phone release is in progress..." + : "Could not queue the disconnect."); }); - connection->addWidget(retry); - connection->addWidget(stop); - layout->addLayout(connection); - - auto *lens = new QHBoxLayout(); - addButton(lens, "Rear", "/lens", R"({"lens":"1\u00d7"})"); - addButton(lens, "Front", "/lens", R"({"lens":"Front"})"); - layout->addLayout(lens); - auto *torch = new QHBoxLayout(); - addButton(torch, "Torch on", "/torch", R"({"enabled":true})"); - addButton(torch, "Torch off", "/torch", R"({"enabled":false})"); - addButton(torch, "Identify", "/identify", R"({"label":"OBS"})"); - layout->addLayout(torch); - - auto *zoomRow = new QHBoxLayout(); + auto *zoom_row = new QHBoxLayout(); zoom_ = new QDoubleSpinBox(this); + zoom_->setObjectName("openstreamZoom"); + zoom_->setAccessibleName("Camera zoom"); zoom_->setRange(1.0, 10.0); zoom_->setSingleStep(0.1); + zoom_->setDecimals(1); zoom_->setSuffix("x"); - auto *applyZoom = new QPushButton("Set zoom", this); - connect(applyZoom, &QPushButton::clicked, this, [this] { + apply_zoom_ = new QPushButton("Apply zoom", this); + apply_zoom_->setObjectName("openstreamApplyZoom"); + zoom_row->addWidget(new QLabel("Zoom", this)); + zoom_row->addWidget(zoom_); + zoom_row->addWidget(apply_zoom_); + layout->addLayout(zoom_row); + connect(apply_zoom_, &QPushButton::clicked, this, [this] { const QByteArray body = QByteArray("{\"value\":") + QByteArray::number(zoom_->value(), 'f', 1) + "}"; send("/zoom", body.constData()); }); - zoomRow->addWidget(zoom_); - zoomRow->addWidget(applyZoom); - layout->addLayout(zoomRow); - - update_ = new QLabel(this); - update_->setWordWrap(true); - updateButton_ = new QPushButton("View update", this); - updateButton_->hide(); - connect(updateButton_, &QPushButton::clicked, this, [this] { - QDesktopServices::openUrl(updateUrl_); + + // Peer-bound camera controls. Each call only publishes into the source's + // bounded, coalescing control slot; the network round-trip stays off the + // OBS UI thread and the phone rejects non-reserving peers with 401. + auto *lens_row = new QHBoxLayout(); + lens_rear_ = new QPushButton("Rear", this); + lens_rear_->setObjectName("openstreamLensRear"); + lens_front_ = new QPushButton("Front", this); + lens_front_->setObjectName("openstreamLensFront"); + lens_row->addWidget(new QLabel("Lens", this)); + lens_row->addWidget(lens_rear_); + lens_row->addWidget(lens_front_); + layout->addLayout(lens_row); + connect(lens_rear_, &QPushButton::clicked, this, [this] { + send("/lens", R"({"lens":"1×"})"); + }); + connect(lens_front_, &QPushButton::clicked, this, [this] { + send("/lens", R"({"lens":"Front"})"); }); - layout->addWidget(update_); - layout->addWidget(updateButton_); + + auto *torch_row = new QHBoxLayout(); + torch_on_ = new QPushButton("Torch on", this); + torch_on_->setObjectName("openstreamTorchOn"); + torch_off_ = new QPushButton("Torch off", this); + torch_off_->setObjectName("openstreamTorchOff"); + identify_ = new QPushButton("Identify", this); + identify_->setObjectName("openstreamIdentify"); + torch_row->addWidget(torch_on_); + torch_row->addWidget(torch_off_); + torch_row->addWidget(identify_); + layout->addLayout(torch_row); + connect(torch_on_, &QPushButton::clicked, this, [this] { + send("/torch", R"({"enabled":true})"); + }); + connect(torch_off_, &QPushButton::clicked, this, [this] { + send("/torch", R"({"enabled":false})"); + }); + connect(identify_, &QPushButton::clicked, this, [this] { + send("/identify", R"({"label":"OBS"})"); + }); + layout->addStretch(); + connect(source_selector_, &QComboBox::currentIndexChanged, this, + [this](int) { updateSelectedSource(true); }); + connect(phone_selector_, &QComboBox::currentIndexChanged, this, + [this](int index) { + obs_source_t *source = currentSource(); + if (!source || index < 0) return; + const QByteArray id = phone_selector_->itemData(index).toString().toUtf8(); + if (openstream_select_camera_phone(source, id.constData())) { + status_->setText("Phone selected; press Connect to bind it."); + } + }); refresh_ = new QTimer(this); - refresh_->setInterval(1500); - connect(refresh_, &QTimer::timeout, this, [this] { refreshSources(); }); + refresh_->setInterval(kSourceRefreshMs); + connect(refresh_, &QTimer::timeout, this, + [this] { refreshSources(); }); refresh_->start(); refreshSources(); - checkForUpdates(); } ~OpenStreamDock() override { releaseSources(); } private: - void addButton(QHBoxLayout *row, const char *label, const char *path, - const char *body) { - auto *button = new QPushButton(label, this); - connect(button, &QPushButton::clicked, this, - [this, path = QByteArray(path), body = QByteArray(body)] { - send(path.constData(), body.constData()); - }); - row->addWidget(button); + void showNoSource() { + status_->setText("Add an OpenStream source in OBS first."); + } + + void renameSelectedSource() { + obs_source_t *source = currentSource(); + if (!source) { + showNoSource(); + return; + } + + const QString name = name_->text().trimmed(); + if (name.isEmpty()) { + name_->setText(QString::fromUtf8(obs_source_get_name(source))); + status_->setText("Source name cannot be empty."); + return; + } + + const QByteArray utf8_name = name.toUtf8(); + obs_source_set_name(source, utf8_name.constData()); + openstream_set_camera_label(source, utf8_name.constData()); + const int index = source_selector_->currentIndex(); + if (index >= 0) { + QSignalBlocker blocker(source_selector_); + source_selector_->setItemText(index, name); + } + status_->setText("Source name saved."); } void send(const char *path, const char *body) { obs_source_t *source = currentSource(); - if (!source) return; + if (!source) { + showNoSource(); + return; + } const bool queued = openstream_post_camera_command(source, path, body); - status_->setText(queued ? "Command queued" : "Camera is not connected"); + status_->setText(queued ? "Command queued in background." + : "Camera is not connected."); } obs_source_t *currentSource() const { - return sources_.size() == 1 ? sources_.front() : nullptr; + const int index = source_selector_->currentIndex(); + if (index < 0 || index >= static_cast(sources_.size())) return nullptr; + return sources_[static_cast(index)]; } void releaseSources() { @@ -172,63 +219,115 @@ class OpenStreamDock final : public QWidget { sources_.clear(); } + void updateSelectedSource(bool replace_name) { + obs_source_t *source = currentSource(); + const bool available = source != nullptr; + source_selector_->setEnabled(!sources_.empty()); + name_->setEnabled(available); + test_connect_->setEnabled(available); + disconnect_->setEnabled(available); + zoom_->setEnabled(available); + apply_zoom_->setEnabled(available); + lens_rear_->setEnabled(available); + lens_front_->setEnabled(available); + torch_on_->setEnabled(available); + torch_off_->setEnabled(available); + identify_->setEnabled(available); + + if (!source) { + phone_selector_->clear(); + if (!name_->hasFocus()) name_->clear(); + if (sources_.size() > 1) { + status_->setText("Select an OpenStream source."); + } else { + showNoSource(); + } + return; + } + + refreshPhones(source); + + if (replace_name || !name_->hasFocus()) { + QSignalBlocker blocker(name_); + name_->setText(QString::fromUtf8(obs_source_get_name(source))); + } + status_->setText(QString::fromUtf8(openstream_source_status(source))); + } + void refreshSources() { + QString selected_uuid; + if (source_selector_->currentIndex() >= 0) { + selected_uuid = source_selector_->currentData().toString(); + } + releaseSources(); - source_->clear(); - obs_enum_sources( - [](void *opaque, obs_source_t *source) { - auto *self = static_cast(opaque); - if (!openstream_is_camera_source(source)) return true; - self->sources_.push_back(obs_source_get_ref(source)); - return true; - }, - this); - if (obs_source_t *source = currentSource()) { - source_->setText(QString::fromUtf8(obs_source_get_name(source))); - status_->setText(QString::fromUtf8(openstream_source_status(source))); - } else if (sources_.size() > 1) { - source_->setText("Multiple legacy sources found"); - status_->setText("Keep one OpenStream source. To show it in another scene, use Add Existing. Remove extra camera sources to use these controls."); - } else { - status_->setText("Add an OpenStream V1 source to control your phone here."); + { + QSignalBlocker blocker(source_selector_); + source_selector_->clear(); + obs_enum_sources( + [](void *opaque, obs_source_t *source) { + auto *self = static_cast(opaque); + if (!openstream_is_camera_source(source)) return true; + self->sources_.push_back(obs_source_get_ref(source)); + return true; + }, + this); + + for (obs_source_t *source : sources_) { + const char *raw_name = obs_source_get_name(source); + const char *raw_uuid = obs_source_get_uuid(source); + const QString name = raw_name && raw_name[0] + ? QString::fromUtf8(raw_name) + : QStringLiteral("OpenStream source"); + const QString uuid = raw_uuid ? QString::fromUtf8(raw_uuid) : QString(); + source_selector_->addItem(name, uuid); + } + + int selected_index = source_selector_->findData(selected_uuid); + if (selected_index < 0 && source_selector_->count() > 0) { + selected_index = 0; + } + source_selector_->setCurrentIndex(selected_index); } + updateSelectedSource(false); } - void checkForUpdates() { - updates_ = new QNetworkAccessManager(this); - QNetworkRequest request(QUrl( - "https://github.com/YashasVM/OpenStream/releases/latest/download/openstream-obs-update.json")); - request.setTransferTimeout(5000); - QNetworkReply *reply = updates_->get(request); - connect(reply, &QNetworkReply::finished, this, [this, reply] { - const QByteArray payload = reply->readAll(); - const bool ok = reply->error() == QNetworkReply::NoError; - reply->deleteLater(); - if (!ok || payload.size() > 64 * 1024) return; - const QJsonObject manifest = QJsonDocument::fromJson(payload).object(); - if (manifest.value("schemaVersion").toInt() != 1) return; - const QString available = manifest.value("version").toString(); - QString current = QString::fromUtf8(OPENSTREAM_VERSION); - if (current.startsWith(QChar('v'))) current.remove(0, 1); - const QUrl release(manifest.value("releaseUrl").toString()); - if (available.isEmpty() || compareSemver(available, current) <= 0 || - !release.isValid() || release.scheme() != "https" || - release.host() != "github.com" || - !release.path().startsWith("/YashasVM/OpenStream/releases/tag/")) return; - updateUrl_ = release; - update_->setText("OpenStream " + available + " is available. Close OBS before running the installer."); - updateButton_->show(); - }); + void refreshPhones(obs_source_t *source) { + if (!source) { + phone_selector_->clear(); + phone_selector_->setEnabled(false); + return; + } + const QString selected = phone_selector_->currentData().toString(); + QSignalBlocker blocker(phone_selector_); + phone_selector_->clear(); + phone_selector_->addItem("Choose a discovered phone", QString()); + const size_t count = openstream_camera_phone_count(source); + for (size_t index = 0; index < count; ++index) { + const QString id = QString::fromUtf8(openstream_camera_phone_id(source, index)); + const QString label = QString::fromUtf8(openstream_camera_phone_name(source, index)); + phone_selector_->addItem(label, id); + } + int selected_index = phone_selector_->findData(selected); + if (selected_index < 0) selected_index = 0; + phone_selector_->setCurrentIndex(selected_index); + phone_selector_->setEnabled(phone_selector_->count() > 0); } - QLabel *source_ = nullptr; + QComboBox *source_selector_ = nullptr; + QComboBox *phone_selector_ = nullptr; QLabel *status_ = nullptr; + QLineEdit *name_ = nullptr; + QPushButton *test_connect_ = nullptr; + QPushButton *disconnect_ = nullptr; QDoubleSpinBox *zoom_ = nullptr; + QPushButton *apply_zoom_ = nullptr; + QPushButton *lens_rear_ = nullptr; + QPushButton *lens_front_ = nullptr; + QPushButton *torch_on_ = nullptr; + QPushButton *torch_off_ = nullptr; + QPushButton *identify_ = nullptr; QTimer *refresh_ = nullptr; - QLabel *update_ = nullptr; - QPushButton *updateButton_ = nullptr; - QNetworkAccessManager *updates_ = nullptr; - QUrl updateUrl_; std::vector sources_; }; diff --git a/obs-plugin/src/openstream-source.cpp b/obs-plugin/src/openstream-source.cpp index f30b6632..9e6cea49 100644 --- a/obs-plugin/src/openstream-source.cpp +++ b/obs-plugin/src/openstream-source.cpp @@ -20,9 +20,9 @@ #include #include "async-control-client.hpp" +#include "openstream-control-api.hpp" #include "media-clock.hpp" #include "control-json.hpp" -#include "solo-camera-lease.hpp" #include "socket-send.hpp" #include @@ -101,9 +101,12 @@ constexpr auto kReconnectReservationWindow = std::chrono::seconds(45); constexpr uint64_t kReconnectRecoveryVideoFrames = 30; // Keep the historical source IDs below for scene compatibility, but expose the // installed release as V1 in the OBS UI and logs. -constexpr const char *kOpenStreamSourceName = "shin"; +constexpr const char *kOpenStreamSourceName = "OpenStream Camera"; constexpr const char *kDiscoveryMulticastAddress = "239.255.43.99"; constexpr const char *kPhoneDiscoveryPrefix = "SHIN_PHONE/1 "; +// Legacy 1.x phones used the OpenStream namespace. Keep this adapter at the +// discovery boundary; the engine continues to use one PhoneDevice model. +constexpr const char *kLegacyPhoneDiscoveryPrefix = "OPENSTREAM_PHONE/1 "; #ifdef _WIN32 using SocketHandle = SOCKET; @@ -539,7 +542,11 @@ class PhoneDiscoveryReceiver { if (selected_id.empty() || selected_id == kAutoPhoneId) { std::optional deprioritized; for (const auto &entry : core.devices_) { - if (entry.second.busy && entry.second.reserved_by != source_instance_id) { + // Automatic mode is phone initiated: an OBS source may only adopt a + // phone that explicitly advertised this source as its reservation. + // This prevents a default source from randomly grabbing a nearby + // available phone. + if (entry.second.reserved_by != source_instance_id) { continue; } if (!deprioritized_id.empty() && entry.second.instance_id == deprioritized_id) { @@ -561,6 +568,8 @@ class PhoneDiscoveryReceiver { private: static constexpr auto kDeviceTtl = std::chrono::seconds(5); + // Bounded registry per AGENTS.md 3/16: capacity + overflow policy declared. + static constexpr size_t kMaxDevices = 64; struct Core { std::atomic running{false}; @@ -589,7 +598,7 @@ class PhoneDiscoveryReceiver { static void run_shared(Core *core) { SocketHandle socket = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (socket == kInvalidSocket) { - blog(LOG_WARNING, "[shin] Could not create phone discovery socket"); + blog(LOG_WARNING, "[OpenStream] Could not create phone discovery socket"); std::lock_guard lock(core->mutex); core->running = false; return; @@ -614,7 +623,7 @@ class PhoneDiscoveryReceiver { local.sin_port = htons(kDiscoveryPort); local.sin_addr.s_addr = INADDR_ANY; if (bind(socket, reinterpret_cast(&local), sizeof(local)) != 0) { - blog(LOG_WARNING, "[shin] Could not bind phone discovery UDP port"); + blog(LOG_WARNING, "[OpenStream] Could not bind phone discovery UDP port"); close_socket(socket); std::lock_guard lock(core->mutex); core->running = false; @@ -650,11 +659,15 @@ class PhoneDiscoveryReceiver { continue; } std::string payload(buffer, buffer + received); - if (payload.rfind(kPhoneDiscoveryPrefix, 0) != 0) { + const bool current_protocol = payload.rfind(kPhoneDiscoveryPrefix, 0) == 0; + const bool legacy_protocol = payload.rfind(kLegacyPhoneDiscoveryPrefix, 0) == 0; + if (!current_protocol && !legacy_protocol) { continue; } - const std::string json = payload.substr(std::strlen(kPhoneDiscoveryPrefix)); - if (json_string_value(json, "type").value_or("") != "dev.shin.phone") { + const char *prefix = current_protocol ? kPhoneDiscoveryPrefix : kLegacyPhoneDiscoveryPrefix; + const std::string json = payload.substr(std::strlen(prefix)); + const std::string type = json_string_value(json, "type").value_or(""); + if (type != "dev.shin.phone" && type != "dev.openstream.phone") { continue; } char packet_host[INET_ADDRSTRLEN] = {}; @@ -692,10 +705,22 @@ class PhoneDiscoveryReceiver { { std::lock_guard lock(core->mutex); pruneExpiredLocked(*core); + // Overflow policy: evict eldest (oldest last_seen) with a warning, + // never grow unbounded. + if (core->devices_.size() >= kMaxDevices && + core->devices_.find(device.instance_id) == core->devices_.end()) { + auto eldest = core->devices_.begin(); + for (auto it = core->devices_.begin(); it != core->devices_.end(); ++it) { + if (it->second.last_seen < eldest->second.last_seen) eldest = it; + } + blog(LOG_WARNING, "[OpenStream] Discovery cap (%zu) reached; evicting %s", + kMaxDevices, eldest->first.c_str()); + core->devices_.erase(eldest); + } core->devices_[device.instance_id] = device; } blog(LOG_INFO, - "[shin] Discovered phone %s at %s:%d%s", + "[OpenStream] Discovered phone %s at %s:%d%s", device.name.c_str(), device.host.c_str(), device.port, @@ -738,8 +763,14 @@ class DiscoveryAdvertiser { } } + void set_source_name(std::string name) { + std::lock_guard lock(mutex_); + source_name_ = std::move(name); + } + private: std::string beacon_payload() const { + std::lock_guard lock(mutex_); const std::string host = first_pairing_host(); std::ostringstream payload; payload << "SHIN/1 {" @@ -762,7 +793,7 @@ class DiscoveryAdvertiser { void run() { SocketHandle socket = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (socket == kInvalidSocket) { - blog(LOG_WARNING, "[shin] Could not create discovery UDP socket"); + blog(LOG_WARNING, "[OpenStream] Could not create discovery UDP socket"); return; } @@ -810,6 +841,7 @@ class DiscoveryAdvertiser { } std::atomic stop_requested_ = false; + mutable std::mutex mutex_; std::thread worker_; int listener_port_ = 9100; int latency_ms_ = 120; @@ -838,6 +870,7 @@ struct OpenStreamSource { int latency_ms = 120; int bitrate_mbps = kDefaultBitrateMbps; bool listener_enabled = true; + bool enabled = false; std::atomic listener_running = false; std::atomic phone_connected = false; std::atomic slot_busy = false; @@ -869,6 +902,7 @@ struct OpenStreamSource { std::string active_slot_label; std::string active_selected_phone_id = PhoneDiscoveryReceiver::kAutoPhoneId; std::optional active_phone; + std::string reservation_token; uint64_t frames_output = 0; uint64_t stale_video_frames = 0; uint64_t stale_audio_frames = 0; @@ -881,7 +915,6 @@ struct OpenStreamSource { // The map holds shared ownership so a lookup can keep the source alive across // the lock boundary. std::map> g_source_contexts; -SoloCameraLease g_camera_lease; // Locked lookup returning shared ownership. Callers hold the returned // shared_ptr for the duration of start/stop/post work, then re-validate @@ -919,6 +952,23 @@ bool queue_lifecycle_request(const std::shared_ptr &ctx, return true; } +void persist_connection_enabled(const std::shared_ptr &ctx, + bool enabled) { + if (!ctx) return; + { + std::lock_guard lock(ctx->settings_mutex); + ctx->enabled = enabled; + ctx->listener_enabled = enabled; + } + if (!ctx->source) return; + obs_data_t *settings = obs_source_get_settings(ctx->source); + if (!settings) return; + obs_data_set_bool(settings, "enabled", enabled); + obs_data_set_bool(settings, "listener_enabled", enabled); + obs_source_update(ctx->source, settings); + obs_data_release(settings); +} + void run_lifecycle_worker(OpenStreamSource *ctx) { for (;;) { std::optional request; @@ -954,12 +1004,12 @@ bool queue_control_command(OpenStreamSource *ctx, const std::string &path, const int port = phone->control_port; const bool queued = client->post([host, port, path, body] { if (!send_control_command(host, port, path, body)) { - blog(LOG_WARNING, "[shin] Camera command %s failed", path.c_str()); + blog(LOG_WARNING, "[OpenStream] Camera command %s failed", path.c_str()); } }); if (!queued) { blog(LOG_WARNING, - "[shin] Camera command %s dropped: control queue is full or stopping", + "[OpenStream] Camera command %s dropped: control queue is full or stopping", path.c_str()); } return queued; @@ -1140,8 +1190,11 @@ bool reserve_phone(OpenStreamSource *ctx, PhoneDevice &phone) { std::ostringstream body; { std::lock_guard lock(ctx->settings_mutex); - phone.reservation_token = - ctx->instance_id + "-" + std::to_string(os_gettime_ns()); + if (ctx->reservation_token.empty()) { + ctx->reservation_token = + ctx->instance_id + "-" + std::to_string(os_gettime_ns()); + } + phone.reservation_token = ctx->reservation_token; body << "{\"sourceInstanceId\":\"" << json_escape(ctx->instance_id) << "\"," << "\"reservationToken\":\"" << json_escape(phone.reservation_token) << "\"," << "\"slotId\":\"" << json_escape(ctx->slot_id) << "\"," @@ -1166,12 +1219,12 @@ void queue_release_phone(OpenStreamSource *ctx, const PhoneDevice &phone) { if (send_control_command(host, port, "/release", body)) { return true; } - blog(LOG_WARNING, "[shin] Camera reservation release failed; retrying"); + blog(LOG_WARNING, "[OpenStream] Camera reservation release failed; retrying"); return false; }); if (!queued) { blog(LOG_WARNING, - "[shin] Camera reservation release could not be queued: control executor stopped"); + "[OpenStream] Camera reservation release could not be queued: control executor stopped"); } } @@ -1251,9 +1304,12 @@ void openstream_stop_worker(OpenStreamSource *ctx) { if (reserved_phone.has_value()) { queue_release_phone(ctx, *reserved_phone); } + { + std::lock_guard settings_lock(ctx->settings_mutex); + ctx->reservation_token.clear(); + } set_active_phone(ctx, std::nullopt); set_slot_status(ctx, "Offline"); - g_camera_lease.release(ctx); } bool open_video_decoder(AVFormatContext *format_ctx, @@ -1262,16 +1318,16 @@ bool open_video_decoder(AVFormatContext *format_ctx, const int stream_result = avformat_find_stream_info(format_ctx, nullptr); if (stream_result < 0) { blog(LOG_WARNING, - "[shin] Could not read stream info: %s", + "[OpenStream] Could not read stream info: %s", av_error(stream_result).c_str()); return false; } const int best_stream = av_find_best_stream( - format_ctx, type, -1, -1, nullptr, 0); + format_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); if (best_stream < 0) { blog(LOG_WARNING, - "[shin] No video stream found in SRT input: %s", + "[OpenStream] No video stream found in SRT input: %s", av_error(best_stream).c_str()); return false; } @@ -1280,19 +1336,19 @@ bool open_video_decoder(AVFormatContext *format_ctx, const AVCodec *decoder = avcodec_find_decoder(stream->codecpar->codec_id); if (!decoder) { blog(LOG_WARNING, - "[shin] No FFmpeg decoder found for codec id %d", + "[OpenStream] No FFmpeg decoder found for codec id %d", stream->codecpar->codec_id); return false; } - if (!is_audio) { - // Hardware decode probe with explicit software fallback (AGENTS.md rules - // 9/18: never enable a software codec silently; HW must have an explicit - // fallback and warning). Full zero-copy AVHWFramesContext wiring is still - // TODO; today we probe device availability so the fallback is explicit and - // logged instead of silent. This never re-encodes ISO video (rule 2). + // Hardware decode probe with explicit software fallback (AGENTS.md rules + // 9/18: never enable a software codec silently; HW must have an explicit + // fallback and warning). Full zero-copy AVHWFramesContext wiring is still + // TODO; today we probe device availability so the fallback is explicit and + // logged instead of silent. This never re-encodes ISO video (rule 2). + { const char *codec_name = avcodec_get_name(stream->codecpar->codec_id); - blog(LOG_INFO, "[shin] Probing hardware decode for codec %s", + blog(LOG_INFO, "[OpenStream] Probing hardware decode for codec %s", codec_name ? codec_name : "unknown"); bool hw_device_available = false; #ifdef _WIN32 @@ -1303,33 +1359,33 @@ bool open_video_decoder(AVFormatContext *format_ctx, if (av_hwdevice_ctx_create(&probe, hw_type, nullptr, nullptr, 0) == 0) { av_buffer_unref(&probe); hw_device_available = true; - blog(LOG_INFO, "[shin] HW decode device %s available", hw_name); + blog(LOG_INFO, "[OpenStream] HW decode device %s available", hw_name); break; } } #else for (const char *hw_name : {"vaapi", "qsv", "videotoolbox", "drm"}) { -#endif const AVHWDeviceType hw_type = av_hwdevice_find_type_by_name(hw_name); if (hw_type == AV_HWDEVICE_TYPE_NONE) continue; AVBufferRef *probe = nullptr; if (av_hwdevice_ctx_create(&probe, hw_type, nullptr, nullptr, 0) == 0) { av_buffer_unref(&probe); hw_device_available = true; - blog(LOG_INFO, "[shin] HW decode device %s available", hw_name); + blog(LOG_INFO, "[OpenStream] HW decode device %s available", hw_name); break; } } +#endif if (!hw_device_available) { blog(LOG_WARNING, - "[shin] No hardware decode device available; using explicit " + "[OpenStream] No hardware decode device available; using explicit " "software decode fallback (codec=%s)", codec_name ? codec_name : "unknown"); } else { // TODO(hw-frames): attach AVHWFramesContext/get_format to decoder_ctx // for zero-copy output; until then fall back explicitly with warning. blog(LOG_WARNING, - "[shin] Hardware device present but zero-copy HW frame path " + "[OpenStream] Hardware device present but zero-copy HW frame path " "not wired yet; using explicit software decode fallback (codec=%s)", codec_name ? codec_name : "unknown"); } @@ -1337,63 +1393,39 @@ bool open_video_decoder(AVFormatContext *format_ctx, CodecContextPtr codec_ctx(avcodec_alloc_context3(decoder)); if (!codec_ctx) { - blog(LOG_WARNING, "[shin] Could not allocate decoder context"); + blog(LOG_WARNING, "[OpenStream] Could not allocate decoder context"); return false; } int result = avcodec_parameters_to_context(codec_ctx.get(), stream->codecpar); if (result < 0) { blog(LOG_WARNING, - "[shin] Could not copy decoder parameters: %s", + "[OpenStream] Could not copy decoder parameters: %s", av_error(result).c_str()); return false; } - if (!is_audio) { - codec_ctx->flags |= AV_CODEC_FLAG_LOW_DELAY; - } + codec_ctx->flags |= AV_CODEC_FLAG_LOW_DELAY; result = avcodec_open2(codec_ctx.get(), decoder, nullptr); if (result < 0) { blog(LOG_WARNING, - "[shin] Could not open decoder: %s", + "[OpenStream] Could not open decoder: %s", av_error(result).c_str()); return false; } - *stream_index = best_stream; + *video_stream_index = best_stream; *decoder_ctx = std::move(codec_ctx); - if (is_audio) { - blog(LOG_INFO, - "[OpenStream] Opened audio decoder: %s, %d Hz, %d channels", - avcodec_get_name(stream->codecpar->codec_id), - stream->codecpar->sample_rate, - stream->codecpar->ch_layout.nb_channels); - } return true; } -bool open_video_decoder(AVFormatContext *format_ctx, - int *video_stream_index, - CodecContextPtr *decoder_ctx) { - const int stream_result = avformat_find_stream_info(format_ctx, nullptr); - if (stream_result < 0) { - blog(LOG_WARNING, - "[OpenStream] Could not read stream info: %s", - av_error(stream_result).c_str()); - return false; - } - - return open_decoder(format_ctx, AVMEDIA_TYPE_VIDEO, video_stream_index, - decoder_ctx); -} - bool open_audio_decoder(AVFormatContext *format_ctx, int *audio_stream_index, CodecContextPtr *decoder_ctx) { const int best_stream = av_find_best_stream( format_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); if (best_stream < 0) { - blog(LOG_INFO, "[shin] No audio stream found (video-only mode)"); + blog(LOG_INFO, "[OpenStream] No audio stream found (video-only mode)"); *audio_stream_index = -1; return false; } @@ -1402,7 +1434,7 @@ bool open_audio_decoder(AVFormatContext *format_ctx, const AVCodec *decoder = avcodec_find_decoder(stream->codecpar->codec_id); if (!decoder) { blog(LOG_WARNING, - "[shin] No audio decoder found for codec id %d", + "[OpenStream] No audio decoder found for codec id %d", stream->codecpar->codec_id); *audio_stream_index = -1; return false; @@ -1423,7 +1455,7 @@ bool open_audio_decoder(AVFormatContext *format_ctx, result = avcodec_open2(codec_ctx.get(), decoder, nullptr); if (result < 0) { blog(LOG_WARNING, - "[shin] Could not open audio decoder: %s", + "[OpenStream] Could not open audio decoder: %s", av_error(result).c_str()); *audio_stream_index = -1; return false; @@ -1432,7 +1464,7 @@ bool open_audio_decoder(AVFormatContext *format_ctx, *audio_stream_index = best_stream; *decoder_ctx = std::move(codec_ctx); blog(LOG_INFO, - "[shin] Opened audio decoder: %s, %d Hz, %d channels", + "[OpenStream] Opened audio decoder: %s, %d Hz, %d channels", avcodec_get_name(stream->codecpar->codec_id), stream->codecpar->sample_rate, stream->codecpar->ch_layout.nb_channels); @@ -1499,7 +1531,7 @@ bool output_decoded_frame(OpenStreamSource *ctx, yuv_frame.color_matrix, yuv_frame.color_range_min, yuv_frame.color_range_max)) { - blog(LOG_WARNING, "[shin] Could not calculate OBS YUV color parameters"); + blog(LOG_WARNING, "[OpenStream] Could not calculate OBS YUV color parameters"); return false; } @@ -1508,7 +1540,7 @@ bool output_decoded_frame(OpenStreamSource *ctx, if (frames_output == 1 || frames_output % 300 == 0) { const char *format_name = av_get_pix_fmt_name(source_format); blog(LOG_INFO, - "[shin] Output %" PRIu64 " decoded YUV frame(s) to OBS (%dx%d, source format=%s)", + "[OpenStream] Output %" PRIu64 " decoded YUV frame(s) to OBS (%dx%d, source format=%s)", frames_output, width, height, @@ -1531,7 +1563,7 @@ bool output_decoded_frame(OpenStreamSource *ctx, nullptr, nullptr); if (!scaled) { - blog(LOG_WARNING, "[shin] Could not create BGRA converter"); + blog(LOG_WARNING, "[OpenStream] Could not create BGRA converter"); return false; } if (scaled != current_sws) { @@ -1554,7 +1586,7 @@ bool output_decoded_frame(OpenStreamSource *ctx, dst_data, dst_linesize); if (scaled_rows != height) { - blog(LOG_WARNING, "[shin] Incomplete frame conversion"); + blog(LOG_WARNING, "[OpenStream] Incomplete frame conversion"); return false; } @@ -1577,7 +1609,7 @@ bool output_decoded_frame(OpenStreamSource *ctx, if (frames_output == 1 || frames_output % 300 == 0) { const char *format_name = av_get_pix_fmt_name(source_format); blog(LOG_INFO, - "[shin] Output %" PRIu64 " decoded BGRA frame(s) to OBS (%dx%d, source format=%s)", + "[OpenStream] Output %" PRIu64 " decoded BGRA frame(s) to OBS (%dx%d, source format=%s)", frames_output, width, height, @@ -1630,7 +1662,7 @@ uint64_t decode_packets(OpenStreamSource *ctx, FramePtr frame(av_frame_alloc()); FramePtr audio_frame(av_frame_alloc()); if (!packet || !frame || !audio_frame) { - blog(LOG_WARNING, "[shin] Could not allocate decode packet/frame"); + blog(LOG_WARNING, "[OpenStream] Could not allocate decode packet/frame"); return 0; } @@ -1655,7 +1687,7 @@ uint64_t decode_packets(OpenStreamSource *ctx, } if (result < 0) { blog(LOG_WARNING, - "[shin] Could not decode frame: %s", + "[OpenStream] Could not decode frame: %s", av_error(result).c_str()); return result; } @@ -1668,14 +1700,14 @@ uint64_t decode_packets(OpenStreamSource *ctx, : std::nullopt; if (video_discontinuity) { blog(LOG_WARNING, - "[shin] MediaClock discontinuity: video gap surfaced " + "[OpenStream] MediaClock discontinuity: video gap surfaced " "(jump exceeded %lld ms; total media gaps=%" PRIu64 ")", static_cast(MediaClock::kMaxJumpNs / 1'000'000), media_clock.gap_count()); } if (!timestamp_ns) { blog(LOG_WARNING, - "[shin] Dropping video frame without a usable source timestamp (media gap surfaced)"); + "[OpenStream] Dropping video frame without a usable source timestamp (media gap surfaced)"); av_frame_unref(frame.get()); continue; } @@ -1683,7 +1715,7 @@ uint64_t decode_packets(OpenStreamSource *ctx, const uint64_t dropped = ++ctx->stale_video_frames; if (dropped == 1 || dropped % 60 == 0) { blog(LOG_WARNING, - "[shin] Dropped stale video frame(s): %" PRIu64 + "[OpenStream] Dropped stale video frame(s): %" PRIu64 " (receiver backlog exceeded %u ms; media gap surfaced)", dropped, static_cast(kMaximumMediaBacklogNs / 1'000'000)); @@ -1698,6 +1730,9 @@ uint64_t decode_packets(OpenStreamSource *ctx, &sws_ctx, &bgra_buffer)) { ++video_frames_output; + if (video_frames_output == 1) { + set_slot_status(ctx, "Live"); + } } av_frame_unref(frame.get()); } @@ -1739,14 +1774,14 @@ uint64_t decode_packets(OpenStreamSource *ctx, : std::nullopt; if (audio_discontinuity) { blog(LOG_WARNING, - "[shin] MediaClock discontinuity: audio gap surfaced " + "[OpenStream] MediaClock discontinuity: audio gap surfaced " "(jump exceeded %lld ms; total media gaps=%" PRIu64 ")", static_cast(MediaClock::kMaxJumpNs / 1'000'000), media_clock.gap_count()); } if (!timestamp_ns) { blog(LOG_WARNING, - "[shin] Dropping audio frame without a usable source timestamp (media gap surfaced)"); + "[OpenStream] Dropping audio frame without a usable source timestamp (media gap surfaced)"); av_frame_unref(audio_frame.get()); continue; } @@ -1754,7 +1789,7 @@ uint64_t decode_packets(OpenStreamSource *ctx, const uint64_t dropped = ++ctx->stale_audio_frames; if (dropped == 1 || dropped % 100 == 0) { blog(LOG_WARNING, - "[shin] Dropped stale audio frame(s): %" PRIu64 + "[OpenStream] Dropped stale audio frame(s): %" PRIu64 " (audio gap surfaced)", dropped); } @@ -1776,7 +1811,7 @@ uint64_t decode_packets(OpenStreamSource *ctx, ++audio_frames_output; if (audio_frames_output == 1 || audio_frames_output % 1000 == 0) { blog(LOG_INFO, - "[shin] Output %" PRIu64 " decoded audio frame(s) (%d Hz, %d ch)", + "[OpenStream] Output %" PRIu64 " decoded audio frame(s) (%d Hz, %d ch)", audio_frames_output, sample_rate, channels); @@ -1797,7 +1832,7 @@ uint64_t decode_packets(OpenStreamSource *ctx, } if (read_result < 0) { blog(LOG_INFO, - "[shin] SRT input ended or disconnected: %s", + "[OpenStream] SRT input ended or disconnected: %s", av_error(read_result).c_str()); break; } @@ -1815,7 +1850,7 @@ uint64_t decode_packets(OpenStreamSource *ctx, av_packet_unref(packet.get()); if (result < 0) { blog(LOG_WARNING, - "[shin] Could not send packet to decoder: %s", + "[OpenStream] Could not send packet to decoder: %s", av_error(result).c_str()); continue; } @@ -1882,7 +1917,7 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str effective_phone_id = reconnect_phone_id; } else { blog(LOG_INFO, - "[shin] Reconnect hold expired; allowing %s to choose another phone", + "[OpenStream] Reconnect hold expired; allowing %s to choose another phone", ctx->slot_label.c_str()); expired_reconnect_phone_id = reconnect_phone_id; reconnect_phone_id.clear(); @@ -1900,11 +1935,11 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str break; } if (auto_phone_selection && !reconnect_phone_id.empty()) { - blog(LOG_INFO, "[shin] Waiting for previously connected Android phone"); + blog(LOG_INFO, "[OpenStream] Waiting for previously connected Android phone"); } else if (auto_phone_selection) { - blog(LOG_INFO, "[shin] Waiting for available Android phone"); + blog(LOG_INFO, "[OpenStream] Waiting for available Android phone"); } else { - blog(LOG_INFO, "[shin] Waiting for selected Android phone"); + blog(LOG_INFO, "[OpenStream] Waiting for selected Android phone"); } std::this_thread::sleep_for(std::chrono::milliseconds(1000)); } @@ -1916,12 +1951,16 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str break; } reserved_phone = phone; - set_slot_status(ctx, "Reserved"); set_active_phone(ctx, phone); srt_url = "srt://" + phone->host + ":" + std::to_string(phone->port) + "?mode=caller&latency=" + std::to_string(phone->latency_ms); + // Distinguish control-OK vs media-blocked: reserve can succeed on TCP + // 9101 while SRT 9100 is firewalled/isolated. Surface the media target + // so "Reserved" is diagnosable instead of stuck. + set_slot_status(ctx, "Reserved — waiting for SRT media to " + phone->host + ":" + + std::to_string(phone->port)); blog(LOG_INFO, - "[shin] Connecting source to phone %s at %s", + "[OpenStream] Connecting source to phone %s at %s (control OK; waiting for media)", phone->name.c_str(), srt_url.c_str()); } else { @@ -1938,7 +1977,7 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str AVFormatContext *raw_format_ctx = nullptr; raw_format_ctx = avformat_alloc_context(); if (!raw_format_ctx) { - blog(LOG_WARNING, "[shin] Could not allocate FFmpeg format context"); + blog(LOG_WARNING, "[OpenStream] Could not allocate FFmpeg format context"); break; } raw_format_ctx->interrupt_callback.callback = ffmpeg_interrupt_callback; @@ -1953,7 +1992,7 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str av_dict_set_int(&options, "connect_timeout", kSrtConnectTimeoutMs, 0); blog(LOG_INFO, - "[shin] Opening Android stream at %s", + "[OpenStream] Opening Android stream at %s", srt_url.c_str()); const AVInputFormat *mpegts_input = av_find_input_format("mpegts"); int result = @@ -1962,7 +2001,7 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str if (result < 0) { if (!ctx->stop_requested.load()) { blog(LOG_WARNING, - "[shin] Could not open SRT input: %s", + "[OpenStream] Could not open SRT input: %s", av_error(result).c_str()); avformat_close_input(&raw_format_ctx); if (reserved_phone.has_value()) { @@ -1982,7 +2021,7 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str FormatContextPtr format_ctx(raw_format_ctx); raw_format_ctx = nullptr; ctx->phone_connected = true; - set_slot_status(ctx, "Live"); + set_slot_status(ctx, "Receiving video"); ctx->stale_video_frames = 0; ctx->stale_audio_frames = 0; @@ -2011,7 +2050,7 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str const AVCodecParameters *codecpar = format_ctx->streams[video_stream_index]->codecpar; blog(LOG_INFO, - "[shin] Receiving %dx%d video stream codec=%s%s", + "[OpenStream] Receiving %dx%d video stream codec=%s%s", codecpar->width, codecpar->height, avcodec_get_name(codecpar->codec_id), @@ -2027,7 +2066,7 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str if (!ctx->stop_requested.load()) { hold_phone_for_reconnect(reserved_phone); set_slot_status(ctx, "Reconnecting"); - blog(LOG_INFO, "[shin] Holding %s for reconnect", + blog(LOG_INFO, "[OpenStream] Holding %s for reconnect", ctx->slot_label.c_str()); std::this_thread::sleep_for(std::chrono::milliseconds(500)); continue; @@ -2040,8 +2079,22 @@ void openstream_worker(OpenStreamSource *ctx, std::string base_srt_url, std::str ctx->listener_running = false; ctx->phone_connected = false; set_slot_status(ctx, "Offline"); + // Release-before-clear: the phone must see /release while we still hold its + // token. queue_release_phone is bounded/non-blocking (urgent capacity 4, + // drop-newest), so worker exit never stalls on an unreachable phone. + // Destroy order guarantees the executor is still running here: + // openstream_destroy joins this worker via openstream_stop_worker before + // camera_controls->stop(), so the release is queued, not lost. + std::optional final_phone_to_release; + { + std::lock_guard lock(ctx->settings_mutex); + final_phone_to_release = ctx->active_phone; + } + if (final_phone_to_release.has_value()) { + queue_release_phone(ctx, *final_phone_to_release); + } set_active_phone(ctx, std::nullopt); - blog(LOG_INFO, "[shin] Listener worker exited"); + blog(LOG_INFO, "[OpenStream] Listener worker exited"); } void openstream_start_worker(OpenStreamSource *ctx) { @@ -2095,12 +2148,6 @@ void openstream_start_worker(OpenStreamSource *ctx) { openstream_stop_worker(ctx); - if (!g_camera_lease.acquire(ctx)) { - set_slot_status(ctx, "Another shin camera is active. Use Add Existing to reuse it, or stop it before retrying."); - blog(LOG_WARNING, "[shin] Additional camera session blocked: solo-camera mode permits one active source"); - return; - } - ctx->active_srt_url = srt_url; ctx->active_listener_port = listener_port; ctx->active_latency_ms = latency_ms; @@ -2132,7 +2179,14 @@ void openstream_update(void *data, obs_data_t *settings) { bool should_start = false; { std::lock_guard lock(ctx->settings_mutex); - ctx->listener_enabled = obs_data_get_bool(settings, "listener_enabled"); + // Older saved scenes only have listener_enabled. An explicit enabled + // value takes precedence, including enabled=false. + ctx->enabled = obs_data_has_user_value(settings, "enabled") + ? obs_data_get_bool(settings, "enabled") + : obs_data_get_bool(settings, "listener_enabled"); + ctx->listener_enabled = ctx->enabled; + obs_data_set_bool(settings, "enabled", ctx->enabled); + obs_data_set_bool(settings, "listener_enabled", ctx->enabled); ctx->device_name = obs_data_get_string(settings, "device_name"); int requested_port = static_cast(obs_data_get_int(settings, "listener_port")); if (requested_port <= 0) { @@ -2180,7 +2234,7 @@ void openstream_update(void *data, obs_data_t *settings) { ctx->slot_id, ctx->slot_label, ctx->instance_id); - ctx->pairing_hint = "Open shin on your phone, choose " + ctx->slot_label + + ctx->pairing_hint = "Open OpenStream on your phone, choose " + ctx->slot_label + ", and keep both devices on the same Wi-Fi. Pairing URL is in Advanced."; const std::vector phones = ctx->phone_discovery.devices(); ctx->phone_target_hint = "Waiting for a phone to choose " + ctx->slot_label; @@ -2207,7 +2261,7 @@ void openstream_update(void *data, obs_data_t *settings) { obs_data_set_string(settings, "phone_target_hint", ctx->phone_target_hint.c_str()); obs_data_set_string(settings, "pairing_hint", ctx->pairing_hint.c_str()); obs_data_set_string(settings, "pairing_url", ctx->pairing_url.c_str()); - should_start = ctx->listener_enabled; + should_start = ctx->enabled; } if (should_start) { queue_lifecycle_request(lookup_source_context_by_raw(ctx), true); @@ -2278,7 +2332,10 @@ void openstream_destroy(void *data) { } void openstream_defaults(obs_data_t *settings) { - obs_data_set_default_bool(settings, "listener_enabled", true); + obs_data_set_default_bool(settings, "enabled", false); + // Retain the old key for saved scenes and third party callers. Connection + // actions update the canonical enabled key below. + obs_data_set_default_bool(settings, "listener_enabled", false); obs_data_set_default_string(settings, "device_name", "Phone Camera"); obs_data_set_default_string(settings, "slot_id", ""); obs_data_set_default_string(settings, "source_instance_id", ""); @@ -2286,8 +2343,8 @@ void openstream_defaults(obs_data_t *settings) { obs_data_set_default_string(settings, "slot_status", "Waiting for phone"); obs_data_set_default_string(settings, "srt_url", "shin:auto"); obs_data_set_default_string(settings, "selected_phone_id", PhoneDiscoveryReceiver::kAutoPhoneId); - obs_data_set_default_string(settings, "phone_target_hint", "Open shin on your phone to pair"); - obs_data_set_default_string(settings, "pairing_hint", "Open shin on your phone and choose this OBS computer. Keep both devices on the same Wi-Fi."); + obs_data_set_default_string(settings, "phone_target_hint", "Open OpenStream on your phone to pair"); + obs_data_set_default_string(settings, "pairing_hint", "Open OpenStream on your phone and choose this OBS computer. Keep both devices on the same Wi-Fi."); obs_data_set_default_string(settings, "pairing_url", "shin://connect"); obs_data_set_default_bool(settings, "show_advanced", false); obs_data_set_default_bool(settings, "manual_receive", false); @@ -2297,47 +2354,6 @@ void openstream_defaults(obs_data_t *settings) { obs_data_set_default_double(settings, "cam_zoom", 1.0); } -// Fixed camera-command buttons: one table + one callback instead of four -// near-identical lambdas. obs_properties_add_button takes a C function -// pointer (no captures), so the per-button path/body is recovered from the -// property name. -struct CameraCommandButton { - const char *id; - const char *label; - const char *path; - const char *body; - const char *done_log; -}; - -constexpr CameraCommandButton kCameraCommandButtons[] = { - {"cam_torch_on", "Torch On", "/torch", "{\"enabled\":true}", "Torch ON"}, - {"cam_torch_off", "Torch Off", "/torch", "{\"enabled\":false}", "Torch OFF"}, - {"cam_lens_back", "Rear Camera", "/lens", "{\"lens\":\"1×\"}", - "Switch to back camera"}, - {"cam_lens_front", "Front Camera", "/lens", "{\"lens\":\"Front\"}", - "Switch to front camera"}, -}; - -bool camera_command_button_clicked(obs_properties_t *, obs_property_t *prop, - void *data) { - const char *name = prop ? obs_property_name(prop) : nullptr; - const CameraCommandButton *button = nullptr; - if (name) { - for (const auto &candidate : kCameraCommandButtons) { - if (std::strcmp(candidate.id, name) == 0) { - button = &candidate; - break; - } - } - } - if (!button) return false; - auto *ctx = static_cast(data); - if (!ctx) return false; - if (!queue_control_command(ctx, button->path, button->body)) return false; - blog(LOG_INFO, "[OpenStream] %s", button->done_log); - return true; -} - obs_properties_t *openstream_properties(void *data) { obs_properties_t *props = obs_properties_create(); auto *ctx = static_cast(data); @@ -2440,12 +2456,13 @@ obs_properties_t *openstream_properties(void *data) { "Refreshes the discovered phone list without closing this properties window."); obs_property_t *connect_button = - obs_properties_add_button(slot_group, "connect", "Start / Retry Connection", [](obs_properties_t *, obs_property_t *, void *data) { + obs_properties_add_button(slot_group, "connect", "Test connection", [](obs_properties_t *, obs_property_t *, void *data) { auto *raw = static_cast(data); const auto owned = lookup_source_context_by_raw(raw); if (!owned) { return false; } + persist_connection_enabled(owned, true); return queue_lifecycle_request(owned, true); }); obs_property_set_long_description( @@ -2453,17 +2470,18 @@ obs_properties_t *openstream_properties(void *data) { "Connects your phone, or retries the current connection."); obs_property_t *disconnect_button = - obs_properties_add_button(slot_group, "disconnect", "Stop Camera", [](obs_properties_t *, obs_property_t *, void *data) { + obs_properties_add_button(slot_group, "disconnect", "Disconnect / release phone", [](obs_properties_t *, obs_property_t *, void *data) { auto *raw = static_cast(data); const auto owned = lookup_source_context_by_raw(raw); if (!owned) { return false; } + persist_connection_enabled(owned, false); return queue_lifecycle_request(owned, false); }); obs_property_set_long_description( disconnect_button, - "Stops this OBS source from listening without removing it from the scene."); + "Stops the stream and releases the phone reservation (use when stuck on Reserved)."); obs_properties_add_group(props, "slot_setup", "1. Camera", OBS_GROUP_NORMAL, slot_group); @@ -2513,38 +2531,8 @@ obs_properties_t *openstream_properties(void *data) { return false; }, static_cast(data)); - obs_properties_add_button(camera_group, "cam_torch_on", "Torch On", [](obs_properties_t *, obs_property_t *, void *data) { - auto *ctx = static_cast(data); - if (!ctx) return false; - if (!queue_control_command(ctx, "/torch", "{\"enabled\":true}")) return false; - blog(LOG_INFO, "[shin] Torch ON"); - return true; - }); - - obs_properties_add_button(camera_group, "cam_torch_off", "Torch Off", [](obs_properties_t *, obs_property_t *, void *data) { - auto *ctx = static_cast(data); - if (!ctx) return false; - if (!queue_control_command(ctx, "/torch", "{\"enabled\":false}")) return false; - blog(LOG_INFO, "[shin] Torch OFF"); - return true; - }); - - obs_properties_add_button(camera_group, "cam_lens_back", "Rear Camera", [](obs_properties_t *, obs_property_t *, void *data) { - auto *ctx = static_cast(data); - if (!ctx) return false; - if (!queue_control_command(ctx, "/lens", "{\"lens\":\"1×\"}")) return false; - blog(LOG_INFO, "[shin] Switch to back camera"); - return true; - }); - - obs_properties_add_button(camera_group, "cam_lens_front", "Front Camera", [](obs_properties_t *, obs_property_t *, void *data) { - auto *ctx = static_cast(data); - if (!ctx) return false; - if (!queue_control_command(ctx, "/lens", "{\"lens\":\"Front\"}")) return false; - blog(LOG_INFO, "[shin] Switch to front camera"); - return true; - }); - + // Minimal live controls: Zoom + Identify only. Torch/lens live in the dock + // (peer-bound, bounded send) so the two surfaces do not duplicate commands. obs_properties_add_button(camera_group, "identify_camera", "Identify Phone", [](obs_properties_t *, obs_property_t *, void *data) { auto *ctx = static_cast(data); if (!ctx) return false; @@ -2553,13 +2541,13 @@ obs_properties_t *openstream_properties(void *data) { << "\"subtitle\":\"" << json_escape(ctx->device_name) << "\"}"; const bool sent = queue_control_command(ctx, "/identify", body.str()); blog(LOG_INFO, - "[shin] Identify %s%s", + "[OpenStream] Identify %s%s", ctx->slot_label.c_str(), sent ? "" : " failed"); return sent; }); - obs_properties_add_group(props, "camera_controls", "2. Live Camera Controls", OBS_GROUP_NORMAL, camera_group); + obs_properties_add_group(props, "camera_controls", "2. Live Camera Controls (Zoom + Test)", OBS_GROUP_NORMAL, camera_group); obs_properties_add_text(props, "credit", "About", OBS_TEXT_INFO); return props; @@ -2613,6 +2601,7 @@ bool openstream_start_camera_source(obs_source_t *source) { return forward_to_camera( source, [&](const std::shared_ptr &owned) { if (!owned) return false; + persist_connection_enabled(owned, true); return queue_lifecycle_request(owned, true); }); } @@ -2621,10 +2610,88 @@ bool openstream_stop_camera_source(obs_source_t *source) { return forward_to_camera( source, [&](const std::shared_ptr &owned) { if (!owned) return false; + persist_connection_enabled(owned, false); return queue_lifecycle_request(owned, false); }); } +size_t openstream_camera_phone_count(obs_source_t *source) { + return forward_to_camera(source, [](const std::shared_ptr &owned) { + return owned ? owned->phone_discovery.devices().size() : 0u; + }); +} + +const char *openstream_camera_phone_id(obs_source_t *source, size_t index) { + thread_local std::string value; + value.clear(); + forward_to_camera(source, [&](const std::shared_ptr &owned) { + if (!owned) return 0; + const auto phones = owned->phone_discovery.devices(); + if (index < phones.size()) value = phones[index].instance_id; + return 0; + }); + return value.c_str(); +} + +const char *openstream_camera_phone_name(obs_source_t *source, size_t index) { + thread_local std::string value; + value.clear(); + forward_to_camera(source, [&](const std::shared_ptr &owned) { + if (!owned) return 0; + const auto phones = owned->phone_discovery.devices(); + if (index < phones.size()) value = phone_label(phones[index]); + return 0; + }); + return value.c_str(); +} + +bool openstream_select_camera_phone(obs_source_t *source, const char *phone_id) { + if (!phone_id || !phone_id[0]) return false; + return forward_to_camera(source, [&](const std::shared_ptr &owned) { + if (!owned) return false; + const auto phones = owned->phone_discovery.devices(); + const auto found = std::find_if(phones.begin(), phones.end(), [&](const PhoneDevice &phone) { + return phone.instance_id == phone_id; + }); + if (found == phones.end()) return false; + { + std::lock_guard lock(owned->settings_mutex); + owned->selected_phone_id = phone_id; + } + if (!owned->source) return true; + obs_data_t *settings = obs_source_get_settings(owned->source); + if (!settings) return true; + obs_data_set_string(settings, "selected_phone_id", phone_id); + obs_source_update(owned->source, settings); + obs_data_release(settings); + return true; + }); +} + +bool openstream_set_camera_label(obs_source_t *source, const char *label) { + if (!label || !label[0] || std::strlen(label) > 128) return false; + return forward_to_camera(source, [&](const std::shared_ptr &owned) { + if (!owned) return false; + { + std::lock_guard lock(owned->settings_mutex); + owned->device_name = label; + owned->slot_label = label; + owned->pairing_hint = "Open OpenStream on your phone, choose " + owned->slot_label + + ", and keep both devices on the same Wi-Fi."; + } + owned->discovery.set_source_name(label); + if (owned->source) { + obs_data_t *settings = obs_source_get_settings(owned->source); + if (settings) { + obs_data_set_string(settings, "device_name", label); + obs_data_set_string(settings, "slot_label", label); + obs_data_release(settings); + } + } + return true; + }); +} + const char *openstream_source_status(obs_source_t *source) { thread_local std::string status; return forward_to_camera( @@ -2646,15 +2713,21 @@ bool obs_module_load(void) { if (WSAStartup(MAKEWORD(2, 2), &data) == 0) { g_winsock_started = true; } else { - blog(LOG_WARNING, "[shin] WSAStartup failed; discovery may not advertise"); + blog(LOG_WARNING, "[OpenStream] WSAStartup failed; discovery may not advertise"); } #endif obs_register_source(&openstream_source_info); - blog(LOG_INFO, "[shin] OBS plugin loaded: protocol 1, video + audio + remote controls"); +#if defined(__linux__) + openstream_register_dock(); +#endif + blog(LOG_INFO, "[OpenStream] OBS plugin loaded: protocol 1, video + audio + remote controls"); return true; } void obs_module_unload(void) { +#if defined(__linux__) + openstream_unregister_dock(); +#endif #ifdef _WIN32 if (g_winsock_started) { WSACleanup(); diff --git a/obs-plugin/src/solo-camera-lease.hpp b/obs-plugin/src/solo-camera-lease.hpp deleted file mode 100644 index a2acb911..00000000 --- a/obs-plugin/src/solo-camera-lease.hpp +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include - -// One active camera per OBS process. This is an ownership gate, not a queue: -// capacity 1, conflicting acquisitions are rejected and surfaced by the caller. -// Opaque owners are compared only; the lease never dereferences an OBS object. -class SoloCameraLease { - public: - bool acquire(const void *owner) { - if (!owner) return false; - const void *expected = nullptr; - return owner_.compare_exchange_strong(expected, owner) || expected == owner; - } - - void release(const void *owner) { - if (!owner) return; - owner_.compare_exchange_strong(owner, nullptr); - } - - private: - std::atomic owner_{nullptr}; -}; diff --git a/obs-plugin/tests/test_obs_reservation_exit_contract.py b/obs-plugin/tests/test_obs_reservation_exit_contract.py new file mode 100644 index 00000000..8f360a64 --- /dev/null +++ b/obs-plugin/tests/test_obs_reservation_exit_contract.py @@ -0,0 +1,25 @@ +from pathlib import Path + + +SOURCE = Path(__file__).resolve().parents[1] / "src" / "openstream-source.cpp" + + +def test_worker_exit_releases_active_phone_before_clearing_it() -> None: + source = SOURCE.read_text(encoding="utf-8") + worker_start = source.index("void openstream_worker(") + cleanup_start = source.index( + "ctx->listener_running = false;", + worker_start, + ) + cleanup_end = source.index( + 'blog(LOG_INFO, "[OpenStream] Listener worker exited")', + cleanup_start, + ) + cleanup = source[cleanup_start:cleanup_end] + + assert "final_phone_to_release" in cleanup + assert "queue_release_phone(ctx, *final_phone_to_release);" in cleanup + assert cleanup.index("queue_release_phone(ctx, *final_phone_to_release);") < cleanup.index( + "set_active_phone(ctx, std::nullopt);" + ) + diff --git a/obs-plugin/tests/test_solo_camera.cpp b/obs-plugin/tests/test_solo_camera.cpp deleted file mode 100644 index ec885f00..00000000 --- a/obs-plugin/tests/test_solo_camera.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include "solo-camera-lease.hpp" -#include "contract_helpers.hpp" - -#include -#include -#include -#include - -using openstream_test::require; - -int main() { - SoloCameraLease lease; - int first = 0; - int second = 0; - require(!lease.acquire(nullptr), "null owner accepted"); - require(lease.acquire(&first), "first camera rejected"); - require(lease.acquire(&first), "idempotent acquire rejected"); - require(!lease.acquire(&second), "second camera started"); - lease.release(&second); - require(!lease.acquire(&second), "non-owner released the live camera"); - lease.release(&first); - require(lease.acquire(&second), "explicit retry after stop rejected"); - lease.release(&first); - require(!lease.acquire(&first), "stale stop released the new camera"); - lease.release(&second); - - // All contenders hold their acquisition until every attempt has completed. - // Exactly one source may start, even when scene loading starts them together. - std::atomic ready{0}; - std::atomic start{false}; - std::atomic acquired{0}; - int owners[16]{}; - std::vector contenders; - for (int &owner : owners) { - contenders.emplace_back([&, token = &owner] { - ++ready; - while (!start.load()) std::this_thread::yield(); - if (lease.acquire(token)) ++acquired; - }); - } - while (ready.load() != 16) std::this_thread::yield(); - start = true; - for (auto &thread : contenders) thread.join(); - require(acquired.load() == 1, "concurrent starts admitted multiple cameras"); - for (int &owner : owners) lease.release(&owner); - require(lease.acquire(&first), "lease leaked after cleanup"); - std::cout << "Solo camera ownership tests passed\n"; -} diff --git a/obs-plugin/tests/test_source_settings.cpp b/obs-plugin/tests/test_source_settings.cpp index 72a4b904..57d65664 100644 --- a/obs-plugin/tests/test_source_settings.cpp +++ b/obs-plugin/tests/test_source_settings.cpp @@ -2,15 +2,141 @@ // The dock is not needed for this test; libobs data/properties APIs are. #include "../src/openstream-source.cpp" #include "contract_helpers.hpp" +#include +#include +#include +#include +#include +#include #include +#include #include +#include +#include using openstream_test::require; void openstream_register_dock() {} void openstream_unregister_dock() {} +struct ReleaseProbe { + int listener = -1; + int port = 0; + std::promise request; + std::thread worker; +}; + +void start_release_probe(ReleaseProbe &probe) { + probe.listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + require(probe.listener >= 0, "could not create release probe socket"); + + timeval timeout = {}; + timeout.tv_sec = 2; + require(::setsockopt(probe.listener, + SOL_SOCKET, + SO_RCVTIMEO, + &timeout, + sizeof(timeout)) == 0, + "could not bound release probe accept"); + + sockaddr_in address = {}; + address.sin_family = AF_INET; + address.sin_port = htons(0); + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + require(::bind(probe.listener, + reinterpret_cast(&address), + sizeof(address)) == 0, + "could not bind release probe socket"); + socklen_t address_length = sizeof(address); + require(::getsockname(probe.listener, + reinterpret_cast(&address), + &address_length) == 0, + "could not read release probe port"); + probe.port = ntohs(address.sin_port); + require(::listen(probe.listener, 1) == 0, "could not listen for release probe"); + + const int listener = probe.listener; + probe.worker = std::thread([listener, &request = probe.request] { + sockaddr_in peer = {}; + socklen_t peer_length = sizeof(peer); + const int client = ::accept(listener, + reinterpret_cast(&peer), + &peer_length); + if (client < 0) { + request.set_value({}); + return; + } + + std::string received; + char buffer[1024] = {}; + while (received.size() < 16 * 1024) { + const ssize_t count = ::recv(client, buffer, sizeof(buffer), 0); + if (count <= 0) break; + received.append(buffer, static_cast(count)); + const size_t header_end = received.find("\r\n\r\n"); + if (header_end == std::string::npos) continue; + const size_t length_header = received.find("Content-Length:"); + if (length_header == std::string::npos) break; + const size_t length_start = received.find_first_of("0123456789", length_header + 15); + if (length_start == std::string::npos) break; + const size_t length_end = received.find_first_not_of("0123456789", length_start); + const size_t content_length = std::stoul(received.substr( + length_start, + length_end == std::string::npos ? std::string::npos : length_end - length_start)); + if (received.size() >= header_end + 4 + content_length) break; + } + + constexpr char response[] = + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 11\r\n" + "Connection: close\r\n" + "\r\n" + "{\"ok\":true}"; + ::send(client, response, sizeof(response) - 1, 0); + ::close(client); + request.set_value(std::move(received)); + }); +} + +void test_worker_releases_reservation_on_exit() { + ReleaseProbe probe; + start_release_probe(probe); + std::future request = probe.request.get_future(); + + OpenStreamSource context; + context.instance_id = "source-a"; + context.stop_requested = true; + + PhoneDevice phone; + phone.host = "127.0.0.1"; + phone.control_port = probe.port; + phone.reservation_token = "source-a-token"; + set_active_phone(&context, phone); + + openstream_worker(&context, "shin:auto", PhoneDiscoveryReceiver::kAutoPhoneId); + + const auto wait_result = request.wait_for(std::chrono::seconds(3)); + const std::string captured = wait_result == std::future_status::ready ? request.get() : ""; + context.camera_controls->stop(); + probe.worker.join(); + ::close(probe.listener); + + require(wait_result == std::future_status::ready, + "worker exit left the phone reservation without a release request"); + require(captured.find("POST /release HTTP/1.1") != std::string::npos, + "worker exit sent the wrong reservation control request"); + require(captured.find("sourceInstanceId\":\"source-a\"") != std::string::npos, + "release request lost the source identity"); + require(captured.find("reservationToken\":\"source-a-token\"") != std::string::npos, + "release request lost the reservation token"); + require(!context.active_phone.has_value(), + "worker exit kept a stale active reservation after release"); +} + int main() { + test_worker_releases_reservation_on_exit(); + require(std::string(openstream_source_info.id) == "shin_phone_source", "shin source identity changed"); obs_data_t *settings = obs_data_create(); @@ -21,6 +147,21 @@ int main() { openstream_update(context.get(), settings); require(context->srt_url == "shin:auto", "automatic pairing default changed"); require(context->slot_label == "Phone Camera", "new source has a production slot label"); + require(!context->enabled, "new source unexpectedly starts enabled"); + + obs_data_t *legacy_settings = obs_data_create(); + obs_data_set_bool(legacy_settings, "listener_enabled", true); + openstream_update(context.get(), legacy_settings); + require(context->enabled, "legacy listener_enabled setting was not migrated"); + require(obs_data_get_bool(legacy_settings, "enabled"), + "migrated enabled setting was not persisted"); + + obs_data_set_bool(legacy_settings, "enabled", false); + openstream_update(context.get(), legacy_settings); + require(!context->enabled, + "explicit disabled value was overridden by the legacy setting"); + obs_data_release(legacy_settings); + obs_data_set_bool(settings, "manual_receive", true); obs_data_set_int(settings, "listener_port", 9876); obs_data_set_int(settings, "latency_ms", 160); @@ -34,14 +175,6 @@ int main() { require(context->srt_url == "shin:auto", "automatic pairing cannot be restored"); require(context->slot_label == "Legacy CAM B" && context->slot_id == "saved-legacy-id", "legacy scene identity was lost"); - int existing_camera = 0; - require(g_camera_lease.acquire(&existing_camera), "could not simulate existing camera"); - openstream_start_worker(context.get()); - require(!context->worker.joinable() && !context->listener_running.load(), - "production start bypassed the one-camera gate"); - require(context->slot_status.find("Another shin camera") != std::string::npos, - "blocked camera has no explanation"); - g_camera_lease.release(&existing_camera); auto *properties = openstream_properties(context.get()); require(obs_properties_get(properties, "manual_receive") != nullptr, "manual receive has no user-facing control"); diff --git a/tests/test_openstream_dock_contract.py b/tests/test_openstream_dock_contract.py new file mode 100644 index 00000000..9b09eecf --- /dev/null +++ b/tests/test_openstream_dock_contract.py @@ -0,0 +1,73 @@ +"""Fast contracts for the legacy OBS dock and the native decoder merge seam. + +The Linux release target intentionally does not compile the legacy OBS frontend +dock, so these checks stay source-level. The native source is still compiled +directly by the plugin build verification. +""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +DOCK = (ROOT / "obs-plugin/src/openstream-dock.cpp").read_text(encoding="utf-8") +SOURCE = (ROOT / "obs-plugin/src/openstream-source.cpp").read_text(encoding="utf-8") + + +class OpenStreamPluginContractTests(unittest.TestCase): + def test_dock_is_minimal_and_has_no_ui_thread_update_networking(self) -> None: + for required in ( + '"Test / connect"', + '"Disconnect / release"', + '"Apply zoom"', + '"openstreamSourceName"', + 'obs_source_set_name', + 'openstream_start_camera_source', + 'openstream_stop_camera_source', + 'send("/zoom"', + 'send("/lens"', + 'send("/torch"', + 'send("/identify"', + ): + self.assertIn(required, DOCK) + + for removed in ( + "QNetworkAccessManager", + "QNetworkRequest", + "QNetworkReply", + "checkForUpdates", + "QDesktopServices", + "View update", + ): + self.assertNotIn(removed, DOCK) + + # Dock buttons are bounded connect/disconnect plus peer-bound camera + # controls (zoom, lens x2, torch x2, identify). Naming is a local OBS + # source metadata edit, not a camera or network operation. Every camera + # action goes through send() into the bounded control slot, never + # through UI-thread networking. + self.assertEqual(DOCK.count("new QPushButton"), 8) + self.assertNotIn("obs_source_remove", DOCK) + + def test_video_decoder_has_one_coherent_definition(self) -> None: + self.assertEqual( + len(re.findall(r"\bbool\s+open_video_decoder\s*\(", SOURCE)), 1 + ) + start = SOURCE.index("bool open_video_decoder") + end = SOURCE.index("bool open_audio_decoder", start) + body = SOURCE[start:end] + + self.assertIn("AVMEDIA_TYPE_VIDEO", body) + self.assertIn("*video_stream_index = best_stream", body) + for broken_identifier in ( + r"\btype\b", + r"\bis_audio\b", + r"\bstream_index\b", + r"\bopen_decoder\b", + ): + self.assertIsNone(re.search(broken_identifier, body)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prod_hardening_contract.py b/tests/test_prod_hardening_contract.py new file mode 100644 index 00000000..b8c4ebdc --- /dev/null +++ b/tests/test_prod_hardening_contract.py @@ -0,0 +1,113 @@ +"""Prod-hardening contracts: #40 crash fixes merged into #41 reservation model. + +Unified design (prod-ready): +- Android never crashes on missing native lib / null CameraManager; teardown + and lens restart stay off the UI thread (AGENTS.md 6). +- ReservationState stays authoritative: pending advertised-not-busy, confirmed + busy, 45s lease split pending-rollback vs confirmed-release. +- OBS discovery registry is bounded (64, evict-eldest with warning). +- Reserved names its SRT target so TCP-OK vs SRT-blocked is diagnosable. +- Dock keeps full peer-bound controls (8 buttons via bounded send); + properties stay minimal (naming/testing/disconnect/zoom/identify, no + torch/lens duplication). +- Docs use canonical ports 51615/239.255.43.99/9100/9101. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def test_encoder_has_no_build_breakers(): + src = read("android/app/src/main/java/dev/openstream/app/encoder/MediaCodecVideoEncoder.kt") + assert "resolvedSelection" not in src + assert "format.codecConfigBytes()" in src + assert "EncoderSelection" not in src + assert "mimeTypes" not in src + + +def test_advertiser_imports_formatter(): + src = read("android/app/src/main/java/dev/openstream/app/discovery/PhoneDiscoveryAdvertiser.kt") + assert "import android.text.format.Formatter" in src + assert "Formatter.formatIpAddress" in src + + +def test_native_load_is_guarded(): + src = read("android/app/src/main/java/dev/openstream/app/stream/SrtStreamClient.kt") + assert "loadError" in src + assert "isAvailable" in src + assert "SrtNativeBridge.isAvailable" in src + + +def test_teardown_never_blocks_ui_thread(): + app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + assert "shinActivityStop" in app or "shinStopServer" in app + assert "Thread(" in app + assert "Looper.getMainLooper" in app + + +def test_camera_manager_is_null_safe(): + cam = read("android/app/src/main/java/dev/openstream/app/camera/Camera2Controller.kt") + assert "applicationContext" in cam + assert "cameraManager ?:" in cam + assert "runCatching { selectCameraId" in cam + + +def test_control_bind_errors_surfaced(): + server = read("android/app/src/main/java/dev/openstream/app/control/CameraControlServer.kt") + assert "onError" in server + assert "clientLock" in server + app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + assert "onError" in app + + +def test_reservation_model_preserved(): + app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + assert "ReservationState" in app + assert "advertisedReservationId" in app + assert "isBusy" in app or "isPhoneBusy" in app + + +def test_obs_discovery_registry_is_bounded(): + src = read("obs-plugin/src/openstream-source.cpp") + assert "kMaxDevices" in src + assert "Discovery cap" in src + assert "erase(eldest)" in src + + +def test_reserved_state_names_srt_target(): + src = read("obs-plugin/src/openstream-source.cpp") + assert "Reserved — waiting for SRT media to " in src + assert "control OK; waiting for media" in src + + +def test_properties_stay_minimal_without_duplication(): + src = read("obs-plugin/src/openstream-source.cpp") + assert '"Test connection"' in src + assert '"Disconnect / release phone"' in src + assert '"Zoom"' in src + assert '"Identify Phone"' in src + assert '"Torch On"' not in src + assert '"Torch Off"' not in src + assert '"Rear Camera"' not in src + assert '"Front Camera"' not in src + + +def test_dock_keeps_full_peer_bound_controls(): + dock = read("obs-plugin/src/openstream-dock.cpp") + for required in ('send("/zoom"', 'send("/lens"', 'send("/torch"', 'send("/identify"'): + assert required in dock + assert dock.count("new QPushButton") == 8 + + +def test_docs_use_canonical_ports(): + arch = read("docs/architecture.md") + assert "51615" in arch + assert "239.255.43.99" in arch + assert ":9100" in arch + assert ":9101" in arch + assert "51515" not in arch diff --git a/tests/test_production_contracts.py b/tests/test_production_contracts.py index 610b8c49..1bc9f343 100644 --- a/tests/test_production_contracts.py +++ b/tests/test_production_contracts.py @@ -140,8 +140,8 @@ def test_android_and_obs_release_artifacts_stay_atomic() -> None: assert "python -m pytest -q" in android_workflow assert ":app:lintDebug" in android_workflow assert "openstream-android.apk.sha256" in android_workflow - assert "git log -1 --format=%ct" in android_workflow - assert "git log -1 --format=%ct" in release_workflow + assert "android/gradle.properties" in release_docs + assert "openstream.versionName" in release_workflow assert "openstream-android-update.json" not in android_workflow assert "openstream-android-update.json" not in release_workflow assert not (ROOT / "android/app/src/main/java/dev/openstream/app/update/AppUpdater.kt").exists() diff --git a/tests/test_repo_contract.py b/tests/test_repo_contract.py index 0fc83607..18179163 100644 --- a/tests/test_repo_contract.py +++ b/tests/test_repo_contract.py @@ -67,7 +67,7 @@ def test_obs_plugin_registers_shin_source_and_discovery() -> None: assert "openstream_phone_v8_source" not in source assert "openstream_legacy_source_info" not in source assert "obs_register_source" in source - assert 'kOpenStreamSourceName = "shin"' in source + assert 'kOpenStreamSourceName = "OpenStream Camera"' in source assert "listener_enabled" in source assert "discovery_broadcast_addresses" in source assert "kDiscoveryMulticastAddress" in source @@ -108,12 +108,12 @@ def test_obs_plugin_pairs_the_selected_phone() -> None: assert "control_phone(ctx)" in source -def test_obs_solo_camera_preserves_legacy_identity_and_advanced_transport() -> None: +def test_obs_source_ownership_and_advanced_transport() -> None: source = read("obs-plugin/src/openstream-source.cpp") assert "cam_label_for_index" not in source assert "next_available_slot_label_locked" not in source - assert "g_camera_lease.acquire(ctx)" in source - assert "g_camera_lease.release(ctx)" in source + assert "g_camera_lease" not in source + assert '#include "solo-camera-lease.hpp"' not in source assert '"Phone Camera"' in source assert "source_instance_id" in source assert "slot_id" in source @@ -148,7 +148,7 @@ def test_slot_reservation_allows_owned_busy_phone_and_reconnect_hold() -> None: source = read("obs-plugin/src/openstream-source.cpp") app = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") advertiser = read("android/app/src/main/java/dev/openstream/app/discovery/PhoneDiscoveryAdvertiser.kt") - assert "entry.second.busy && entry.second.reserved_by != source_instance_id" in source + assert "entry.second.reserved_by != source_instance_id" in source assert "found->second.busy && found->second.reserved_by != source_instance_id" in source assert "set_slot_status(ctx, \"Reconnecting\")" in source assert "set_active_phone(ctx, reserved_phone)" in source @@ -188,7 +188,7 @@ def test_android_discovery_ui_parses_and_displays_obs_slots() -> None: assert "renderObsSlots" in app assert "reserveForSlot" in app assert "slotAvailabilityLabel" in app - assert "device.busy && reservedBy != device.sourceInstanceId" in app + assert "device.busy && advertisedReservationId != device.sourceInstanceId" in app assert "compareBy { it.displayLabel }" in discovery assert "obsSlotList" in layout assert 'name="status_waiting">Choose your OBS computer<' in strings @@ -216,10 +216,12 @@ def test_android_control_server_supports_source_reservations() -> None: assert "reservationProvider" in control assert "reserveForSource" in app assert "releaseForSource" in app - assert "phoneConnected || reservedBy != null" in app + assert "selectForSource" in app + assert "isPhoneBusy()" in app + assert "advertisedReservationId" in app assert "private var activeStreamBitrate" in app assert "useStreamBitrate(bitrateMbps)" in app - assert "reserveForSource(device.sourceInstanceId, device.displayLabel, device.bitrateMbps)" in app + assert "selectForSource(device.sourceInstanceId, device.displayLabel, device.bitrateMbps)" in app assert "val bitrateMbps = if (json.has(\"bitrateMbps\"))" in control @@ -291,8 +293,8 @@ def test_release_workflows_build_streaming_apk_and_plugin_package() -> None: assert "OPENSTREAM_RELEASE_STORE_PASSWORD" in release_workflow assert "OPENSTREAM_RELEASE_KEY_ALIAS" in release_workflow assert "OPENSTREAM_RELEASE_KEY_PASSWORD" in release_workflow - assert "OPENSTREAM_VERSION_NAME" in release_workflow - assert "OPENSTREAM_VERSION_CODE" in release_workflow + assert "openstream.versionName" in release_workflow + assert "android/gradle.properties" in release_docs assert "OPENSTREAM_SKIP_INSTALL=1" in obs_workflow assert "OPENSTREAM_PLUGIN_PACKAGE_DIR" in obs_workflow assert "openstream-obs-windows-x64.zip" in obs_workflow @@ -330,12 +332,10 @@ def test_release_build_fails_without_signing_and_keystores_are_ignored() -> None assert "Release builds require OPENSTREAM_RELEASE_KEYSTORE" in app_gradle assert "openstream.versionName" in app_gradle assert "openstream.versionCode" in app_gradle - assert '"1.0.1"' in app_gradle - version_code = re.search( - r"openStreamVersionCode.*?\.orElse\(\"(\d+)\"\)", - app_gradle, - re.DOTALL, - ) + assert 'openstream.versionName' in app_gradle + assert 'openstream.versionCode' in app_gradle + gradle_properties = read("android/gradle.properties") + version_code = re.search(r"^openstream\.versionCode=(\d+)$", gradle_properties, re.MULTILINE) assert version_code is not None assert int(version_code.group(1)) > 0 assert "*.keystore" in gitignore @@ -347,22 +347,29 @@ def test_legacy_android_and_restored_obs_metadata_are_explicit() -> None: cmake = read("obs-plugin/CMakeLists.txt") installer = read("tools/installer/openstream-obs-plugin.iss") - assert '"1.0.1"' in app_gradle + gradle_properties = read("android/gradle.properties") + assert "openstream.versionName=1.0.1" in gradle_properties assert "project(openstream_obs_plugin VERSION 1.0.1" in cmake assert '#define OpenStreamVersion "1.0.1"' in installer -def test_shin_release_is_isolated_and_has_no_obs_dock_dependency() -> None: +def test_openstream_linux_release_includes_native_obs_dock_dependencies() -> None: app_gradle = read("android/app/build.gradle.kts") cmake = read("obs-plugin/CMakeLists.txt") build = read("build_plugin_linux.sh") - installer = read("tools/installer/install-shin-plugin-linux.sh") - - assert 'applicationId = "dev.shin.app"' in app_gradle - assert "src/openstream-dock.cpp" not in cmake - assert 'OUTPUT_NAME "shin-obs"' in cmake - assert "OBS_FRONTEND_LIBRARY" not in cmake - assert "Qt6::Widgets" not in cmake - assert "shin-obs-linux-x86_64.tar.gz" in build + installer = read("tools/installer/install-openstream-plugin-linux.sh") + + assert 'applicationId = "dev.openstream.app"' in app_gradle + assert "src/openstream-dock.cpp" in cmake + assert 'PREFIX ""' in cmake + assert "OBS_FRONTEND_LIBRARY" in cmake + assert "Qt6::Widgets" in cmake + source = read("obs-plugin/src/openstream-source.cpp") + assert "openstream_register_dock();" in source + assert "openstream_unregister_dock();" in source + workflow = read(".github/workflows/obs-plugin-linux.yml") + assert 'grep -E "libobs-frontend-api\\.so"' in workflow + assert 'grep -E "libQt6Widgets"' in workflow + assert "openstream-obs-linux-x86_64.tar.gz" in build + assert "plugins/openstream-obs/bin/64bit/openstream-obs.so" in installer assert "plugins/shin-obs/bin/64bit/shin-obs.so" in installer - assert "plugins/openstream-obs" not in installer diff --git a/tests/test_reservation_contract.py b/tests/test_reservation_contract.py index f4028f41..c23baf75 100644 --- a/tests/test_reservation_contract.py +++ b/tests/test_reservation_contract.py @@ -15,15 +15,24 @@ def test_reservation_expires_if_media_never_connects() -> None: reserve_start = source.index("private fun reserveForSource") release_start = source.index("private fun releaseForSource", reserve_start) reserve = source[reserve_start:release_start] + select_start = source.index("private fun selectForSource") + select_end = source.index("private fun reserveForSource", select_start) + select = source[select_start:select_end] listener_start = source.index("private fun startPhoneServerIfAllowed") listener_end = source.index("private fun isListenerActive", listener_start) listener = source[listener_start:listener_end] schedule_start = source.index("private fun scheduleReservationRelease") cancel_start = source.index("private fun cancelReservationRelease", schedule_start) schedule = source[schedule_start:cancel_start] + pending_start = source.index("private fun schedulePendingRelease") + pending_end = source.index("private fun scheduleReservationRelease", pending_start) + pending = source[pending_start:pending_end] connected_branch = "if (phoneConnected) {\n cancelReservationRelease()\n } else {\n scheduleReservationRelease()\n }" assert connected_branch in reserve + assert "schedulePendingRelease(sourceInstanceId)" in select + assert "reservationState.beginSelection" in select + assert "reservationState.confirm" in reserve connected_index = listener.index("phoneConnected = true") cancel_index = listener.index("cancelReservationRelease()", connected_index) @@ -32,10 +41,11 @@ def test_reservation_expires_if_media_never_connects() -> None: assert "mainHandler.postDelayed(releaseReservationRunnable!!, RECONNECT_RESERVATION_MS)" in schedule assert "if (!phoneConnected &&" in schedule - assert "reservedBy == sourceInstanceId &&" in schedule + assert "reservationState.confirmedSourceInstanceId == sourceInstanceId" in schedule assert "reservationGeneration == generation" in schedule - assert "reservedBy = null" in schedule - assert "reservedSlotLabel = null" in schedule + assert "reservationState.release(sourceInstanceId)" in schedule + assert "mainHandler.postDelayed(releaseReservationRunnable!!, RECONNECT_RESERVATION_MS)" in pending + assert "reservationState.rollbackPending(sourceInstanceId)" in pending def test_reservation_renewal_invalidates_an_already_started_expiry() -> None: @@ -49,14 +59,16 @@ def test_reservation_renewal_invalidates_an_already_started_expiry() -> None: schedule = source[schedule_start:cancel_start] generation_increment = reserve.index("reservationGeneration += 1") - reservation_write = reserve.index("reservedBy = sourceInstanceId") - assert generation_increment < reservation_write + reservation_write = reserve.index("reservationState.confirm(") + assert generation_increment > 0 + assert reservation_write > 0 + assert reservation_write < generation_increment capture = schedule.index("val generation = reservationGeneration") cancel = schedule.index("cancelReservationRelease()") callback = schedule.index("releaseReservationRunnable = Runnable") generation_guard = schedule.index("reservationGeneration == generation", callback) - clear = schedule.index("reservedBy = null", callback) + clear = schedule.index("reservationState.release(sourceInstanceId)", callback) assert capture < cancel < callback < generation_guard < clear @@ -74,6 +86,23 @@ def test_reservation_release_is_bound_to_generation_token() -> None: assert '"stale":true' in control +def test_android_disconnect_clears_selection_without_restarting_listener() -> None: + source = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + button = source[source.index("btnStop.setOnClickListener") :] + button = button[: button.index("// Tap the screen-off overlay")] + assert "stopPhoneServer(clearReservation = true)" in button + assert "startPreviewIfAllowed()" in button + assert "startPhoneServerIfAllowed()" not in button + assert 'android:text="Disconnect"' in read("android/app/src/main/res/layout/activity_main.xml") + + +def test_android_reservation_timeouts_update_the_visible_state() -> None: + source = read("android/app/src/main/java/dev/openstream/app/MainActivity.kt") + assert 'statusText.text = "Connection timed out"' in source + assert 'statusDetail.text = "Choose an OBS slot to try again"' in source + assert 'statusText.text = "Disconnected"' in source + + def test_reservation_captures_controller_peer(): reserve = function_body(SERVER, "handleReserve") assert "controllerAddress: String" in reserve @@ -261,6 +290,6 @@ def test_obs_slot_render_cache_renders_initial_empty_state_and_tracks_bitrate(): assert "private var lastObsSlotRenderKeys: List? = null" in source assert ( '"${device.sourceInstanceId}|${device.displayLabel}|${device.busy}|' - '${device.bitrateMbps}|${reservedBy == device.sourceInstanceId}|$phoneConnected"' + '${device.bitrateMbps}|${advertisedReservationId == device.sourceInstanceId}|$phoneConnected"' in source ) diff --git a/tests/test_srt_contract.py b/tests/test_srt_contract.py index aee3f79c..909f020e 100644 --- a/tests/test_srt_contract.py +++ b/tests/test_srt_contract.py @@ -255,8 +255,13 @@ def test_media_send_cannot_cross_disconnect_or_reconnect_boundary(): assert "SrtNativeBridge.sendVideo(\n accessUnit.data,\n accessUnit.presentationTimeUs,\n accessUnit.flags,\n generation," in video assert "SrtNativeBridge.sendAudio(\n accessUnit.data,\n accessUnit.presentationTimeUs,\n accessUnit.flags,\n generation," in audio bridge = source[source.index("private object SrtNativeBridge") :] - assert "external fun sendVideo(\n data: ByteArray,\n presentationTimeUs: Long,\n flags: Int,\n sessionGeneration: Long," in bridge - assert "external fun sendAudio(\n data: ByteArray,\n presentationTimeUs: Long,\n flags: Int,\n sessionGeneration: Long," in bridge + # Safe wrappers (fun sendVideo/sendAudio) must forward generation to private + # native methods; direct external funs are also accepted for legacy builds. + assert ("nativeSendVideo(" in bridge or "external fun sendVideo(" in bridge) + assert ("nativeSendAudio(" in bridge or "external fun sendAudio(" in bridge) + assert "sessionGeneration: Long," in bridge + # Load failure must never crash: library load is guarded and surfaced. + assert "loadError" in bridge and "isAvailable" in bridge failure = block_after(source, "private fun markNativeSendFailure") assert "if (sessionGeneration.get() == generation)" in failure assert failure.index("if (sessionGeneration.get() == generation)") < failure.index( diff --git a/tests/test_video_transport_contract.py b/tests/test_video_transport_contract.py index 4246c212..85603022 100644 --- a/tests/test_video_transport_contract.py +++ b/tests/test_video_transport_contract.py @@ -65,8 +65,8 @@ def test_video_encoder_callback_thread_is_reaped_on_stop_and_failed_start(): ] assert "private var callbackThread: HandlerThread? = null" in source - thread_setup = start[start.index('val thread = HandlerThread("OpenStreamEncoder")') :] - assert 'HandlerThread("OpenStreamEncoder").apply { start() }' in thread_setup + thread_setup = start[start.index('val thread = HandlerThread("shinEncoder")') :] + assert 'HandlerThread("shinEncoder").apply { start() }' in thread_setup assert "callbackThread = thread" in thread_setup assert "val handler = Handler(thread.looper)" in thread_setup @@ -195,6 +195,26 @@ def test_send_failures_preserve_transport_generation_for_recovery(): assert "SrtSendResult(sent, generation, recoveryRequired = !sent)" in send +def test_video_encoder_selection_is_hardware_avc_only_with_explicit_failure(): + source = VIDEO_ENCODER.read_text(encoding="utf-8") + choose = source[source.index("private fun chooseEncoder") :] + + # AVC-only target: no HEVC preference branch, explicit hardware AVC failure. + assert "MIMETYPE_VIDEO_AVC" in choose + assert "isHardwareAccelerated" in choose + assert "isSoftwareOnly" in choose + assert "COLOR_FormatSurface" in choose + assert "BITRATE_MODE_CBR" in choose + assert "areSizeAndRateSupported" in choose + assert "No hardware surface encoder can satisfy" in choose + assert "needs a hardware AVC encoder" in choose + # Never enable a software codec silently: failure throws, it does not fall back. + assert "throw IllegalStateException" in choose + # Log tag is unified so hardware-skip diagnostics are greppable in one place. + assert '"shinEncoder"' in choose + assert '"OpenStreamEncoder"' not in choose + + def test_main_activity_drops_stale_failure_before_destructive_recovery(): activity = MAIN_ACTIVITY.read_text(encoding="utf-8") diff --git a/tools/installer/install-openstream-plugin-linux.sh b/tools/installer/install-openstream-plugin-linux.sh index 49e3ed6e..697e296a 100755 --- a/tools/installer/install-openstream-plugin-linux.sh +++ b/tools/installer/install-openstream-plugin-linux.sh @@ -79,7 +79,7 @@ fi # Install-OpenStreamPlugin.ps1 Get-OpenStreamPluginCopies; do not unify). remove_stale_names() { local dir="$1" - for name in openstream-beta-obs.so libopenstream-obs.so; do + for name in openstream-beta-obs.so libopenstream-obs.so shin-obs.so; do if [[ -f "${dir}/${name}" ]]; then rm -f "${dir}/${name}" echo "Removed stale copy: ${dir}/${name}" @@ -134,6 +134,11 @@ else mkdir -p "${dest_dir}" atomic_install "${PLUGIN_SO}" "${dest_dir}" remove_stale_names "${dest_dir}" + legacy_shin="${config_home}/obs-studio/plugins/shin-obs/bin/64bit/shin-obs.so" + if [[ -f "${legacy_shin}" ]]; then + rm -f "${legacy_shin}" + echo "Removed stale copy: ${legacy_shin}" + fi # Legacy flat per-user copy from early manual installs. legacy_flat="${config_home}/obs-studio/plugins/openstream-obs.so" if [[ -f "${legacy_flat}" ]]; then diff --git a/tools/installer/install-shin-plugin-linux.sh b/tools/installer/install-shin-plugin-linux.sh deleted file mode 100755 index e3ca06fd..00000000 --- a/tools/installer/install-shin-plugin-linux.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bash -# Installs the shin OBS plugin module on Linux. -# Mirrors tools/installer/Install-shinPlugin.ps1 semantics: -# replaces known plugin copies (canonical + legacy beta names) instead of -# leaving stale modules behind. -# -# ./install-shin-plugin-linux.sh [--so PATH] [--system] [--dest-dir DIR] -# -# Default (no flags): per-user install, no root required: -# ${XDG_CONFIG_HOME:-$HOME/.config}/obs-studio/plugins/shin-obs/bin/64bit/shin-obs.so -# --system: system-wide install into the detected OBS plugin dir -# (e.g. /usr/lib/obs-plugins); requires root. -# --dest-dir DIR: install shin-obs.so into DIR instead of the user or -# system plugin dir. Testing hook: nothing outside DIR is touched. -# -# The install is atomic: the module is staged to a temp file inside the -# destination directory and renamed over the target, so the installed -# library is never deleted before the new copy succeeds. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -PLUGIN_SO="" -SYSTEM=0 -DEST_DIR="" -while [[ $# -gt 0 ]]; do - case "$1" in - --so) - PLUGIN_SO="${2:?--so needs a path}" - shift 2 - ;; - --so=*) - PLUGIN_SO="${1#--so=}" - shift - ;; - --system) - SYSTEM=1 - shift - ;; - --dest-dir) - DEST_DIR="${2:?--dest-dir needs a path}" - shift 2 - ;; - --dest-dir=*) - DEST_DIR="${1#--dest-dir=}" - shift - ;; - *) - echo "ERROR: unknown argument: $1" >&2 - exit 1 - ;; - esac -done - -if [[ "${SYSTEM}" -eq 1 && -n "${DEST_DIR}" ]]; then - echo "ERROR: --system and --dest-dir are mutually exclusive." >&2 - exit 1 -fi - -# Packaged no-argument resolution: a sibling shin-obs.so next to this -# script (extracted tarball layout) wins over the repository build fallback. -if [[ -z "${PLUGIN_SO}" ]]; then - if [[ -f "${SCRIPT_DIR}/shin-obs.so" ]]; then - PLUGIN_SO="${SCRIPT_DIR}/shin-obs.so" - else - PLUGIN_SO="${SCRIPT_DIR}/../../obs-plugin/build/shin-obs.so" - fi -fi -if [[ ! -f "${PLUGIN_SO}" ]]; then - echo "ERROR: plugin module not found: ${PLUGIN_SO}" >&2 - echo "Build it first: ./build_plugin_linux.sh" >&2 - exit 1 -fi - -# Removes only other-named stale modules (never the install target itself, -# which is replaced atomically by rename below). -remove_stale_names() { - local dir="$1" - for name in shin-beta-obs.so libshin-obs.so; do - if [[ -f "${dir}/${name}" ]]; then - rm -f "${dir}/${name}" - echo "Removed stale copy: ${dir}/${name}" - fi - done -} - -# Atomic stage-then-rename inside the destination directory. -atomic_install() { - local src="$1" - local dir="$2" - local target="${dir}/shin-obs.so" - local tmp="" - tmp="$(mktemp "${dir}/.shin-obs.so.XXXXXX")" - install -m 0644 "${src}" "${tmp}" - mv -f "${tmp}" "${target}" - echo "Installed plugin: ${target}" -} - -if [[ -n "${DEST_DIR}" ]]; then - mkdir -p "${DEST_DIR}" - atomic_install "${PLUGIN_SO}" "${DEST_DIR}" - remove_stale_names "${DEST_DIR}" -elif [[ "${SYSTEM}" -eq 1 ]]; then - candidates=() - if command -v obs >/dev/null 2>&1; then - obs_bin="$(readlink -f "$(command -v obs)")" - candidates+=("$(dirname "${obs_bin}")/../lib/obs-plugins") - fi - candidates+=( - /usr/lib/obs-plugins - /usr/lib64/obs-plugins - /usr/lib/x86_64-linux-gnu/obs-plugins - /usr/local/lib/obs-plugins - ) - dest_dir="" - for candidate in "${candidates[@]}"; do - if [[ -d "${candidate}" ]]; then - dest_dir="${candidate}" - break - fi - done - if [[ -z "${dest_dir}" ]]; then - echo "ERROR: no OBS system plugin dir found." >&2 - exit 1 - fi - atomic_install "${PLUGIN_SO}" "${dest_dir}" - remove_stale_names "${dest_dir}" -else - config_home="${XDG_CONFIG_HOME:-${HOME}/.config}" - dest_dir="${config_home}/obs-studio/plugins/shin-obs/bin/64bit" - mkdir -p "${dest_dir}" - atomic_install "${PLUGIN_SO}" "${dest_dir}" - remove_stale_names "${dest_dir}" - # Legacy flat per-user copy from early manual installs. - legacy_flat="${config_home}/obs-studio/plugins/shin-obs.so" - if [[ -f "${legacy_flat}" ]]; then - rm -f "${legacy_flat}" - echo "Removed stale copy: ${legacy_flat}" - fi -fi - -echo "Restart OBS Studio, then add a shin source." diff --git a/tools/shin_pairing_probe.py b/tools/openstream_pairing_probe.py similarity index 88% rename from tools/shin_pairing_probe.py rename to tools/openstream_pairing_probe.py index c6d76db4..551e97a5 100755 --- a/tools/shin_pairing_probe.py +++ b/tools/openstream_pairing_probe.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Advertise a bounded synthetic shin phone for Linux OBS smoke tests.""" +"""Advertise a bounded synthetic OpenStream phone for Linux OBS smoke tests.""" from __future__ import annotations @@ -65,8 +65,8 @@ def advertise() -> None: beacon = { "type": "dev.shin.phone", "version": 1, - "name": "shin probe", - "instanceId": "shin-probe-phone", + "name": "OpenStream probe", + "instanceId": "openstream-probe-phone", "host": "127.0.0.1", "listenerPort": args.media_port, "controlPort": args.control_port, @@ -84,7 +84,7 @@ def advertise() -> None: finally: sock.close() - thread = threading.Thread(target=advertise, name="shin-probe-advertiser", daemon=True) + thread = threading.Thread(target=advertise, name="openstream-probe-advertiser", daemon=True) thread.start() deadline = time.monotonic() + args.duration try: @@ -95,9 +95,9 @@ def advertise() -> None: thread.join(timeout=1) server.server_close() if not state.reserve_seen.is_set(): - print("FAIL: OBS did not reserve the shin phone", flush=True) + print("FAIL: OBS did not reserve the OpenStream phone", flush=True) return 1 - print("PASS: OBS discovered and reserved the shin phone", flush=True) + print("PASS: OBS discovered and reserved the OpenStream phone", flush=True) return 0