diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index ff4d5396..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,3 +0,0 @@ -* @tulir - -imessage/bluebubbles/ @cnuss @trek-boldly-go diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index ac5ead9b..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [tulir, trek-boldly-go, cnuss] diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index 02ed2844..00000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: Bug report -about: If something is definitely wrong in the bridge (rather than just a setup issue), - file a bug report. Remember to include relevant logs. -labels: bug - ---- diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 89a2e3c0..00000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,7 +0,0 @@ -contact_links: - - name: Troubleshooting docs & FAQ - url: https://docs.mau.fi/bridges/general/troubleshooting.html - about: Check this first if you're having problems setting up the bridge. - - name: Support room - url: https://matrix.to/#/#imessage:maunium.net - about: For setup issues not answered by the troubleshooting docs, ask in the Matrix room. diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md deleted file mode 100644 index 264e67ff..00000000 --- a/.github/ISSUE_TEMPLATE/enhancement.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: Enhancement request -about: Submit a feature request or other suggestion -labels: enhancement - ---- diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a0f9d4c5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + groups: + go-deps: + patterns: ["*"] + commit-message: + prefix: "deps(go):" + + - package-ecosystem: cargo + directory: /pkg/rustpushgo + schedule: + interval: weekly + groups: + rust-deps: + patterns: ["*"] + commit-message: + prefix: "deps(rust):" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: "ci:" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..4d57c03e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,169 @@ +name: CI + +on: + push: + branches: [master, refactor] + pull_request: + branches: [master, refactor] + # Manual trigger — useful when bumping third_party/rustpush-upstream.sha + # locally and wanting to re-run the full verify pipeline on a branch + # without pushing a test commit. + workflow_dispatch: {} + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ─────────────────────────────────────────────────────────────── + # Lint – quick static analysis + # ─────────────────────────────────────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: go vet (imessage) + run: go vet ./imessage/... + + - name: go vet (bbctl) + run: go vet ./cmd/bbctl/... + + # ─────────────────────────────────────────────────────────────── + # Unit tests – pure-Go packages (no Rust/CGO bridge needed) + # ─────────────────────────────────────────────────────────────── + test-imessage: + name: Test imessage/ + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Run tests + run: go test -count=1 ./imessage/... + + # NOTE: pkg/connector/ tests require the Rust static library (librustpushgo.a) + # and are run as part of the build jobs below (after `make build`). + + # ─────────────────────────────────────────────────────────────── + # Full build – Rust + Go on macOS and Linux + # ─────────────────────────────────────────────────────────────── + build-macos: + name: Build (macOS) + # Manual-dispatch only. macOS runs on the Apple Silicon GitHub-hosted + # runner; every push/PR doesn't need to burn a macOS slot since Linux + # already catches the vast majority of breakage. Trigger this job + # explicitly via Actions → CI → "Run workflow" when you want macOS + # parity coverage (e.g. after bumping third_party/rustpush-upstream.sha + # or touching platform-gated code in pkg/rustpushgo or local_config). + if: github.event_name == 'workflow_dispatch' + runs-on: macos-14 # Apple Silicon (ARM64) + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: | + brew install libolm protobuf + + - name: Cache Cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + pkg/rustpushgo/target + key: cargo-macos-arm64-${{ hashFiles('**/Cargo.lock', 'third_party/rustpush-upstream.sha') }} + restore-keys: cargo-macos-arm64- + + - name: Verify pinned rustpush SHA file + run: | + PIN_FILE=third_party/rustpush-upstream.sha + if [ ! -f "$PIN_FILE" ]; then + echo "::error::$PIN_FILE is missing — refactor branch requires a pinned OpenBubbles/rustpush SHA" + exit 1 + fi + PIN=$(tr -d '[:space:]' < "$PIN_FILE") + if [ -z "$PIN" ]; then + echo "::error::$PIN_FILE is empty" + exit 1 + fi + echo "Pinned rustpush SHA: $PIN" + + - name: Build + run: make build + + - name: Test pkg/connector/ + env: + CGO_CFLAGS: -I/opt/homebrew/include + CGO_LDFLAGS: -L/opt/homebrew/lib + run: go test -count=1 ./pkg/connector/... + + build-linux: + name: Build (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install system dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq \ + build-essential cmake pkg-config git curl \ + libolm-dev libclang-dev libssl-dev libunicorn-dev \ + protobuf-compiler sqlite3 + + - name: Cache Cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + pkg/rustpushgo/target + key: cargo-linux-${{ hashFiles('**/Cargo.lock', 'third_party/rustpush-upstream.sha') }} + restore-keys: cargo-linux- + + - name: Verify pinned rustpush SHA file + run: | + PIN_FILE=third_party/rustpush-upstream.sha + if [ ! -f "$PIN_FILE" ]; then + echo "::error::$PIN_FILE is missing — refactor branch requires a pinned OpenBubbles/rustpush SHA" + exit 1 + fi + PIN=$(tr -d '[:space:]' < "$PIN_FILE") + if [ -z "$PIN" ]; then + echo "::error::$PIN_FILE is empty" + exit 1 + fi + echo "Pinned rustpush SHA: $PIN" + + - name: Build + run: make build + + - name: Test pkg/connector/ + run: go test -count=1 ./pkg/connector/... diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 81cabe59..00000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Go - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - go-version: ["1.21", "1.22"] - name: Lint ${{ matrix.go-version == '1.22' && '(latest)' || '(old)' }} - - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - cache: true - - - name: Install libolm - run: sudo apt-get install libolm-dev libolm3 - - - name: Install goimports - run: | - go install golang.org/x/tools/cmd/goimports@latest - export PATH="$HOME/go/bin:$PATH" - - - name: Install pre-commit - run: pip install pre-commit - - - name: Lint - run: pre-commit run -a - - - name: Build - run: go build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..f5d47eea --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,211 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + # ─────────────────────────────────────────────────────────────── + # Run the full test suite before releasing + # ─────────────────────────────────────────────────────────────── + test: + name: Pre-release tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Install libolm-dev + run: sudo apt-get update -qq && sudo apt-get install -y -qq libolm-dev + + - name: Test imessage/ + run: go test -v -count=1 ./imessage/... + + # pkg/connector/ tests require the Rust lib – they run in the build jobs + + # ─────────────────────────────────────────────────────────────── + # Build macOS universal binary + app bundle + # ─────────────────────────────────────────────────────────────── + build-macos: + name: Build macOS (arm64) + needs: test + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: brew install libolm protobuf + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + pkg/rustpushgo/target + key: cargo-release-macos-arm64-${{ hashFiles('**/Cargo.lock') }} + restore-keys: cargo-release-macos-arm64- + + - name: Build + run: make build + + - name: Test pkg/connector/ + env: + CGO_CFLAGS: -I/opt/homebrew/include + CGO_LDFLAGS: -L/opt/homebrew/lib + run: go test -v -count=1 ./pkg/connector/... + + - name: Create tarball + run: | + tar czf mautrix-imessage-v2-macos-arm64.tar.gz \ + mautrix-imessage-v2.app/ bbctl \ + scripts/install.sh scripts/install-beeper.sh scripts/reset-bridge.sh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: macos-arm64 + path: mautrix-imessage-v2-macos-arm64.tar.gz + + # ─────────────────────────────────────────────────────────────── + # Build Linux amd64 binary + # ─────────────────────────────────────────────────────────────── + build-linux: + name: Build Linux + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install system dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq \ + build-essential cmake pkg-config git curl \ + libolm-dev libclang-dev libssl-dev libunicorn-dev \ + protobuf-compiler sqlite3 + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + pkg/rustpushgo/target + key: cargo-release-linux-${{ hashFiles('**/Cargo.lock') }} + restore-keys: cargo-release-linux- + + - name: Build + run: make build + + - name: Create tarball + run: | + tar czf mautrix-imessage-v2-linux-amd64.tar.gz \ + mautrix-imessage-v2 bbctl \ + scripts/install-linux.sh scripts/install-beeper-linux.sh \ + scripts/bootstrap-linux.sh scripts/reset-bridge.sh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: linux-amd64 + path: mautrix-imessage-v2-linux-amd64.tar.gz + + # ─────────────────────────────────────────────────────────────── + # Build extract-key tool (macOS only) + # ─────────────────────────────────────────────────────────────── + build-extract-key: + name: Build extract-key (macOS) + needs: test + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: tools/extract-key/go.mod + check-latest: true + + - name: Build extract-key + run: make extract-key + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: extract-key-macos + path: extract-key + + # ─────────────────────────────────────────────────────────────── + # Create GitHub Release + # ─────────────────────────────────────────────────────────────── + release: + name: Create Release + needs: [build-macos, build-linux, build-extract-key] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Generate changelog + id: changelog + run: | + # Get previous tag + PREV_TAG=$(git tag --sort=-version:refname | head -2 | tail -1) + if [ -z "$PREV_TAG" ]; then + PREV_TAG=$(git rev-list --max-parents=0 HEAD) + fi + echo "## Changes" > changelog.md + echo "" >> changelog.md + git log --pretty=format:"- %s (%h)" "$PREV_TAG"..HEAD >> changelog.md + echo "" >> changelog.md + echo "" >> changelog.md + echo "## Checksums" >> changelog.md + echo '```' >> changelog.md + sha256sum artifacts/**/*.tar.gz artifacts/**//extract-key >> changelog.md + echo '```' >> changelog.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + body_path: changelog.md + draft: false + prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} + generate_release_notes: true + files: | + artifacts/macos-arm64/mautrix-imessage-v2-macos-arm64.tar.gz + artifacts/linux-amd64/mautrix-imessage-v2-linux-amd64.tar.gz + artifacts/extract-key-macos/extract-key diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..0bf9c489 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,63 @@ +name: Security + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + - cron: '0 6 * * 1' # Every Monday at 06:00 UTC + +permissions: + security-events: write + +jobs: + govulncheck: + name: Go vulnerability check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + check-latest: true + + - name: Install libolm-dev + run: sudo apt-get update -qq && sudo apt-get install -y -qq libolm-dev + + # Only scan pure-Go packages; packages importing rustpushgo need + # the Rust static library which is impractical to build here. + - name: Run govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./imessage/... ./cmd/bbctl/... + + cargo-audit: + name: Rust dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Fetch and prepare rustpush source + run: make ensure-rustpush-source + + - name: Install cargo-audit + run: cargo install cargo-audit + + - name: Audit Rust dependencies + working-directory: pkg/rustpushgo + run: | + # Current rustpush upstream transitively includes known advisories. + # Track and remove ignores when upstream dependency versions are bumped. + cargo audit \ + --ignore RUSTSEC-2024-0421 \ + --ignore RUSTSEC-2025-0009 \ + --ignore RUSTSEC-2024-0336 \ + --ignore RUSTSEC-2025-0018 \ + --ignore RUSTSEC-2025-0141 \ + --ignore RUSTSEC-2024-0384 \ + --ignore RUSTSEC-2024-0436 \ + --ignore RUSTSEC-2025-0010 \ + --ignore RUSTSEC-2025-0134 diff --git a/.gitignore b/.gitignore index adbcdd3e..1bc495d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,53 @@ -.idea +# IDE / editor +.idea/ +.vscode/ +# Config & runtime data (generated per-install) *.yaml !.pre-commit-config.yaml !example-config.yaml !example-registration.yaml - *.session *.json *.db* *.log +# Built binaries /mautrix-imessage +/mautrix-imessage-v2 +/mautrix-imessage.app +/extract-key +/extract-key-intel /start +/data/ +/build/ +/bbctl + +# Local external worktrees / generated app workspace +/third_party/rustpush-upstream/ +/openbubbles-app-rustpush/ +/rustpush-master/ + +# Rust build artifacts +librustpushgo.a +**/target/ +state/ +rustpush/certs/fairplay/ + + +# Development/test prompt files +ENDTOEND_TEST_PROMPT.md +RUSTPUSH_INTEGRATION_PROMPT.md +prompts/ +mautrix-imessage-v2.app/ +.build-commit +.DS_Store +.apl.history +tools/apl/ +tools/extract-key-app/.build/ +tools/extract-key-app/ExtractKey.app/ +tools/nac-relay-app/.build/ +tools/nac-relay-app/NACRelay.app/ +!rustpush/open-absinthe/src/bin/ +_todo/ +.gocache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index d01ead9a..00000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -include: -- project: 'mautrix/ci' - file: '/go.yml' - -# Don't use the default macOS job in mautrix/ci -build macos arm64: - rules: - - when: never - -# TODO use build universal for all bridges? (i.e. move it to mautrix/ci) -build universal: - stage: build - tags: - - macos - - arm64 - variables: - MACOSX_DEPLOYMENT_TARGET: "11.0" - before_script: - - export PATH=/opt/homebrew/bin:$PATH - - export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') - - export GO_LDFLAGS="-X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" - - export CPATH=$(brew --prefix)/include - script: - # Build arm64 binary - - MACOSX_DEPLOYMENT_TARGET=11.0 LIBRARY_PATH=/opt/homebrew/lib go build -ldflags "$GO_LDFLAGS" -o $BINARY_NAME-arm64 - - install_name_tool -change /opt/homebrew/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib $BINARY_NAME-arm64 - - install_name_tool -add_rpath @executable_path $BINARY_NAME-arm64 - - install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib $BINARY_NAME-arm64 - - install_name_tool -add_rpath /usr/local/opt/libolm/lib $BINARY_NAME-arm64 - - install_name_tool -add_rpath /usr/local/lib $BINARY_NAME-arm64 - # Build amd64 binary - - MACOSX_DEPLOYMENT_TARGET=10.13 LIBRARY_PATH=/usr/local/lib CGO_ENABLED=1 GOARCH=amd64 go build -ldflags "$GO_LDFLAGS" -o $BINARY_NAME-amd64 - - install_name_tool -change /usr/local/lib/libolm.3.dylib @rpath/libolm.3.dylib $BINARY_NAME-amd64 - - install_name_tool -add_rpath @executable_path $BINARY_NAME-amd64 - - install_name_tool -add_rpath /usr/local/opt/libolm/lib $BINARY_NAME-amd64 - - install_name_tool -add_rpath /usr/local/lib $BINARY_NAME-amd64 - # Create universal libolm and bridge binary - - lipo -create -output libolm.3.dylib /opt/homebrew/opt/libolm/lib/libolm.3.dylib /usr/local/lib/libolm.3.dylib - - lipo -create -output $BINARY_NAME $BINARY_NAME-arm64 $BINARY_NAME-amd64 - artifacts: - paths: - - $BINARY_NAME - - example-config.yaml - - libolm.3.dylib diff --git a/.idea/icon.svg b/.idea/icon.svg deleted file mode 100644 index a9d1a767..00000000 --- a/.idea/icon.svg +++ /dev/null @@ -1,40 +0,0 @@ - - - - iMessage logo - - - - - - - - - - - - image/svg+xml - - iMessage logo - 02/04/2018 - - - Apple, Inc. - - - - - CMetalCore - - - https://upload.wikimedia.org/wikipedia/commons/8/85/IMessage_icon.png - - - - - - - - - - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 7225cf90..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,19 +0,0 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: - - id: trailing-whitespace - exclude_types: [markdown] - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - - - repo: https://github.com/tekwizely/pre-commit-golang - rev: v1.0.0-rc.1 - hooks: - - id: go-imports-repo - - - repo: https://github.com/beeper/pre-commit-go - rev: v0.2.2 - hooks: - - id: zerolog-ban-msgf diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..acb73277 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Dev notes + +## FFI boundary (Go ↔ Rust) + +**Never hand-edit** `pkg/rustpushgo/rustpushgo.go` or `rustpushgo.h`. Always regenerate: + +```bash +make bindings # requires uniffi-bindgen-go on PATH +make build +``` + +Install `uniffi-bindgen-go` (must match UniFFI 0.25.0): +```bash +cargo install uniffi-bindgen-go --git https://github.com/AO-AO/uniffi-bindgen-go --tag v0.2.2+v0.25.0 +``` diff --git a/Dockerfile.ci b/Dockerfile.ci deleted file mode 100644 index 88bd04ac..00000000 --- a/Dockerfile.ci +++ /dev/null @@ -1,14 +0,0 @@ -FROM alpine:3.19 - -ENV UID=1337 \ - GID=1337 - -RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq - -ARG EXECUTABLE=./mautrix-imessage -COPY $EXECUTABLE /usr/bin/mautrix-imessage -COPY ./example-config.yaml /opt/mautrix-imessage/example-config.yaml -COPY ./docker-run.sh /docker-run.sh -VOLUME /data - -CMD ["/docker-run.sh"] diff --git a/Info.plist b/Info.plist new file mode 100644 index 00000000..3093df90 --- /dev/null +++ b/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleIdentifier + com.lrhodin.mautrix-imessage + CFBundleName + mautrix-imessage + CFBundleDisplayName + iMessage Bridge + CFBundleExecutable + mautrix-imessage-v2 + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + CFBundlePackageType + APPL + CFBundleInfoDictionaryVersion + 6.0 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSContactsUsageDescription + mautrix-imessage needs access to your contacts to display names for bridged iMessage conversations. + + diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..713c07fe --- /dev/null +++ b/Makefile @@ -0,0 +1,319 @@ +APP_NAME := mautrix-imessage-v2 +CMD_PKG := mautrix-imessage +BUNDLE_ID := com.lrhodin.mautrix-imessage +VERSION := 0.1.0 +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +DATA_DIR ?= $(HOME)/.local/share/mautrix-imessage +UNAME_S := $(shell uname -s) + +RUST_LIB := librustpushgo.a +RUST_SRC := $(shell find pkg/rustpushgo/src -name '*.rs' -o -name '*.m' -o -name '*.h' 2>/dev/null) \ + pkg/rustpushgo/build.rs \ + $(shell find nac-validation/src -name '*.rs' -o -name '*.m' -o -name '*.h' 2>/dev/null) \ + nac-validation/build.rs +# rustpush source root (default: upstream worktree checkout). +# Override at invocation time, e.g.: +# make RUSTPUSH_DIR=rustpush-master build +RUSTPUSH_DIR ?= third_party/rustpush-upstream +# rustpush source strategy: +# fork (default): clone cameronaaron/rustpush which has all bridge-compat +# fixes already applied — no runtime patching needed. +# upstream: clone raw OpenBubbles/rustpush (no patches; for testing only). +# Pinned OpenBubbles/rustpush commit. Edit this file manually to bump, then +# test locally before committing. The Makefile reads the SHA on build and +# checks out that exact commit — no auto-bump, no branch drift, no fork refs. +RUSTPUSH_PIN_FILE := third_party/rustpush-upstream.sha +RUSTPUSH_PIN := $(shell cat $(RUSTPUSH_PIN_FILE) 2>/dev/null) +RUSTPUSH_SRC:= $(shell find $(RUSTPUSH_DIR)/src $(RUSTPUSH_DIR)/apple-private-apis $(RUSTPUSH_DIR)/open-absinthe/src -name '*.rs' -o -name '*.s' 2>/dev/null) $(wildcard $(RUSTPUSH_DIR)/open-absinthe/build.rs) +CARGO_FILES := $(shell find . -name 'Cargo.toml' -o -name 'Cargo.lock' 2>/dev/null | grep -v target) +GO_SRC := $(shell find pkg/ cmd/ -name '*.go' 2>/dev/null) + +BBCTL := bbctl +LDFLAGS := -X main.Tag=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) + +# Track the git commit so ldflags changes trigger a Go rebuild. +# .build-commit is updated whenever HEAD changes. +COMMIT_FILE := .build-commit +PREV_COMMIT := $(shell cat $(COMMIT_FILE) 2>/dev/null) +ifneq ($(COMMIT),$(PREV_COMMIT)) + $(shell echo $(COMMIT) > $(COMMIT_FILE)) +endif + +.PHONY: build clean install install-beeper uninstall reset rust bindings check-deps check-deps-linux + +# =========================================================================== +# Path validation – spaces in the working directory break CGO linker flags +# and #cgo LDFLAGS ${SRCDIR} expansion. Detect early with a clear message. +# =========================================================================== +ifneq ($(word 2,$(CURDIR)),) + $(error The project path "$(CURDIR)" contains spaces. CGO and the linker cannot handle spaces in library paths. Please clone or move the project to a path without spaces, e.g.: /home/$$USER/imessage) +endif + +# =========================================================================== +# Platform detection +# =========================================================================== + +ifeq ($(UNAME_S),Darwin) + # macOS paths and settings + export PATH := /opt/homebrew/bin:/opt/homebrew/sbin:$(PATH) + APP_BUNDLE := $(APP_NAME).app + BINARY := $(APP_BUNDLE)/Contents/MacOS/$(APP_NAME) + INFO_PLIST := $(APP_BUNDLE)/Contents/Info.plist + CGO_CFLAGS := -I/opt/homebrew/include + CGO_LDFLAGS := -L/opt/homebrew/lib -L$(CURDIR) + CARGO_ENV := MACOSX_DEPLOYMENT_TARGET=13.0 +else + # Linux: include Go and Rust installed by bootstrap + export PATH := /usr/local/go/bin:$(HOME)/.cargo/bin:$(PATH) + BINARY := $(APP_NAME) + CGO_CFLAGS := + CGO_LDFLAGS := -L$(CURDIR) + CARGO_ENV := +endif + +# =========================================================================== +# Dependency checks +# =========================================================================== + +# macOS: auto-install via Homebrew +check-deps: +ifeq ($(UNAME_S),Darwin) + @if ! command -v brew >/dev/null 2>&1; then \ + echo "Installing Homebrew..."; \ + NONINTERACTIVE=1 /bin/bash -c "$$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; \ + eval "$$(/opt/homebrew/bin/brew shellenv)"; \ + fi; \ + missing=""; \ + command -v go >/dev/null 2>&1 || missing="$$missing go"; \ + command -v cargo >/dev/null 2>&1 || missing="$$missing rust"; \ + command -v protoc >/dev/null 2>&1|| missing="$$missing protobuf"; \ + command -v tmux >/dev/null 2>&1 || missing="$$missing tmux"; \ + [ -f /opt/homebrew/include/olm/olm.h ] || [ -f /usr/local/include/olm/olm.h ] || missing="$$missing libolm"; \ + pkg-config --exists libheif 2>/dev/null || missing="$$missing libheif"; \ + if [ -n "$$missing" ]; then \ + echo "Installing dependencies:$$missing"; \ + brew install $$missing; \ + fi +else + @scripts/bootstrap-linux.sh +endif + +# =========================================================================== +# Rust static library +# =========================================================================== + +# On Linux, enable hardware-key feature (open-absinthe x86 NAC emulator). +# On macOS, native AAAbsintheContext is used — no unicorn/cmake needed. +ifeq ($(UNAME_S),Darwin) + CARGO_FEATURES := +else + CARGO_FEATURES := --features hardware-key +endif + +UPSTREAM_REPO := https://github.com/OpenBubbles/rustpush.git +FAIRPLAY_CERTS := 4056631661436364584235346952193 \ + 4056631661436364584235346952194 \ + 4056631661436364584235346952195 \ + 4056631661436364584235346952196 \ + 4056631661436364584235346952197 \ + 4056631661436364584235346952198 \ + 4056631661436364584235346952199 \ + 4056631661436364584235346952200 \ + 4056631661436364584235346952201 \ + 4056631661436364584235346952208 + +.PHONY: ensure-rustpush-source + +# Prepare rustpush sources the same way upstream CI does: checkout with +# submodules present and fake FairPlay certs available for build-time signing. +ensure-rustpush-source: + @if [ "$(RUSTPUSH_DIR)" = "rustpush-master" ]; then \ + if [ ! -f rustpush-master/apple-private-apis/icloud-auth/Cargo.toml ]; then \ + if [ -f $(FALLBACK_RUSTPUSH_DIR)/apple-private-apis/icloud-auth/Cargo.toml ]; then \ + echo "Hydrating rustpush-master/apple-private-apis from $(FALLBACK_RUSTPUSH_DIR)..."; \ + mkdir -p rustpush-master/apple-private-apis; \ + rm -rf rustpush-master/apple-private-apis/icloud-auth rustpush-master/apple-private-apis/omnisette; \ + cp -R $(FALLBACK_RUSTPUSH_DIR)/apple-private-apis/icloud-auth rustpush-master/apple-private-apis/; \ + cp -R $(FALLBACK_RUSTPUSH_DIR)/apple-private-apis/omnisette rustpush-master/apple-private-apis/; \ + else \ + echo "error: rustpush-master is missing apple-private-apis and fallback $(FALLBACK_RUSTPUSH_DIR) copy is unavailable" >&2; exit 1; \ + fi; \ + fi; \ + if [ ! -f rustpush-master/open-absinthe/Cargo.toml ]; then \ + if [ -f $(FALLBACK_RUSTPUSH_DIR)/open-absinthe/Cargo.toml ]; then \ + echo "Hydrating rustpush-master/open-absinthe from $(FALLBACK_RUSTPUSH_DIR)..."; \ + rm -rf rustpush-master/open-absinthe; \ + cp -R $(FALLBACK_RUSTPUSH_DIR)/open-absinthe rustpush-master/; \ + else \ + echo "error: rustpush-master is missing open-absinthe and fallback $(FALLBACK_RUSTPUSH_DIR) copy is unavailable" >&2; exit 1; \ + fi; \ + fi; \ + if [ ! -d rustpush-master/certs/fairplay ]; then \ + echo "Generating rustpush-master FairPlay cert stubs..."; \ + mkdir -p rustpush-master/certs/fairplay; \ + for name in $(FAIRPLAY_CERTS); do \ + cp rustpush-master/certs/legacy-fairplay/fairplay.crt rustpush-master/certs/fairplay/$$name.crt; \ + cp rustpush-master/certs/legacy-fairplay/fairplay.pem rustpush-master/certs/fairplay/$$name.pem; \ + done; \ + fi; \ + elif [ "$(RUSTPUSH_DIR)" = "third_party/rustpush-upstream" ]; then \ + if [ -z "$(RUSTPUSH_PIN)" ]; then \ + echo "error: $(RUSTPUSH_PIN_FILE) is missing or empty — required to pin rustpush SHA" >&2; exit 1; \ + fi; \ + export GIT_CONFIG_COUNT=1; \ + export GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf"; \ + export GIT_CONFIG_VALUE_0="git@github.com:"; \ + if [ ! -d third_party/rustpush-upstream/.git ]; then \ + echo "Cloning OpenBubbles/rustpush at pinned SHA $(RUSTPUSH_PIN)..."; \ + mkdir -p third_party; \ + git clone $(UPSTREAM_REPO) third_party/rustpush-upstream || exit 1; \ + git -C third_party/rustpush-upstream checkout $(RUSTPUSH_PIN) || exit 1; \ + git -C third_party/rustpush-upstream submodule sync --recursive || exit 1; \ + git -C third_party/rustpush-upstream submodule update --init --recursive || exit 1; \ + fi; \ + current=$$(git -C third_party/rustpush-upstream rev-parse HEAD 2>/dev/null || echo none); \ + if [ "$$current" != "$(RUSTPUSH_PIN)" ]; then \ + echo "Checking out pinned rustpush SHA $(RUSTPUSH_PIN) (was $$current)..."; \ + git -C third_party/rustpush-upstream remote set-url origin $(UPSTREAM_REPO); \ + echo "Discarding any local mods to third_party/rustpush-upstream before checkout..."; \ + git -C third_party/rustpush-upstream reset --hard HEAD || exit 1; \ + git -C third_party/rustpush-upstream clean -fd || exit 1; \ + git -C third_party/rustpush-upstream fetch --all --tags --prune || exit 1; \ + git -C third_party/rustpush-upstream checkout $(RUSTPUSH_PIN) || { echo "error: failed to checkout pinned SHA $(RUSTPUSH_PIN)" >&2; exit 1; }; \ + git -C third_party/rustpush-upstream submodule sync --recursive || exit 1; \ + git -C third_party/rustpush-upstream submodule update --init --recursive || exit 1; \ + fi; \ + if [ ! -d third_party/rustpush-upstream/certs/fairplay ]; then \ + echo "Generating FairPlay cert stubs..."; \ + mkdir -p third_party/rustpush-upstream/certs/fairplay; \ + for name in $(FAIRPLAY_CERTS); do \ + cp third_party/rustpush-upstream/certs/legacy-fairplay/fairplay.crt \ + third_party/rustpush-upstream/certs/fairplay/$$name.crt; \ + cp third_party/rustpush-upstream/certs/legacy-fairplay/fairplay.pem \ + third_party/rustpush-upstream/certs/fairplay/$$name.pem; \ + done; \ + fi; \ + if [ -d rustpush/open-absinthe ] && [ -f rustpush/open-absinthe/Cargo.toml ]; then \ + if ! diff -rq rustpush/open-absinthe third_party/rustpush-upstream/open-absinthe >/dev/null 2>&1; then \ + echo "Overlaying our open-absinthe (native NAC wiring) onto $(RUSTPUSH_DIR)/open-absinthe..."; \ + rm -rf third_party/rustpush-upstream/open-absinthe; \ + cp -Rp rustpush/open-absinthe third_party/rustpush-upstream/open-absinthe; \ + fi; \ + fi; \ + if grep -q '^mod activation;' $(RUSTPUSH_DIR)/src/lib.rs 2>/dev/null; then \ + echo "Making rustpush activation module public (needed by RelayOSConfig)..."; \ + sed -i.bak 's/^mod activation;/pub mod activation;/' $(RUSTPUSH_DIR)/src/lib.rs && rm -f $(RUSTPUSH_DIR)/src/lib.rs.bak; \ + fi; \ + fi + +# `ensure-rustpush-source` is an order-only prereq (the `|` separator): +# it runs before the recipe when needed, but its phony "always-dirty" +# timestamp doesn't force $(RUST_LIB) to rebuild on every `make` invocation. +# Only actual Rust source changes / Cargo.toml changes should trigger a +# rebuild; the pinned SHA + submodule setup is idempotent once done. +$(RUST_LIB): $(RUST_SRC) $(RUSTPUSH_SRC) $(CARGO_FILES) | ensure-rustpush-source + cd pkg/rustpushgo && $(CARGO_ENV) cargo build --release $(CARGO_FEATURES) + cp pkg/rustpushgo/target/release/librustpushgo.a . + +rust: $(RUST_LIB) + +# =========================================================================== +# Go bindings +# =========================================================================== + +bindings: $(RUST_LIB) + cd pkg/rustpushgo && uniffi-bindgen-go target/release/librustpushgo.a --library --out-dir .. + python3 scripts/patch_bindings.py + +# =========================================================================== +# Build +# =========================================================================== + +ifeq ($(UNAME_S),Darwin) +build: check-deps $(RUST_LIB) $(BINARY) $(BBCTL) + codesign --force --deep --sign - $(APP_BUNDLE) + @echo "Built $(APP_BUNDLE) + $(BBCTL) ($(VERSION)-$(COMMIT))" + +$(BINARY): $(GO_SRC) $(shell find . -name '*.m' -o -name '*.h' 2>/dev/null | grep -v target) go.mod go.sum $(RUST_LIB) $(COMMIT_FILE) + @mkdir -p $(APP_BUNDLE)/Contents/MacOS + @cp Info.plist $(INFO_PLIST) + CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" \ + go build -ldflags '$(LDFLAGS)' -o $(BINARY) ./cmd/$(CMD_PKG)/ +else +build: check-deps $(RUST_LIB) $(BINARY) $(BBCTL) + @echo "Built $(BINARY) + $(BBCTL) ($(VERSION)-$(COMMIT))" + +$(BINARY): $(GO_SRC) go.mod go.sum $(RUST_LIB) $(COMMIT_FILE) + CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" \ + go build -ldflags '$(LDFLAGS)' -o $(BINARY) ./cmd/$(CMD_PKG)/ +endif + +$(BBCTL): $(GO_SRC) go.mod go.sum + go build -o $(BBCTL) ./cmd/bbctl/ + +# =========================================================================== +# Install / uninstall (macOS) +# =========================================================================== + +install: build +ifeq ($(UNAME_S),Darwin) + @scripts/install.sh "$(BINARY)" "$(DATA_DIR)" "$(BUNDLE_ID)" +else + @scripts/install-linux.sh "$(BINARY)" "$(DATA_DIR)" +endif + +install-beeper: build +ifeq ($(UNAME_S),Darwin) + @scripts/install-beeper.sh "$(BINARY)" "$(DATA_DIR)" "$(BUNDLE_ID)" "$(CURDIR)/$(BBCTL)" +else + @scripts/install-beeper-linux.sh "$(BINARY)" "$(DATA_DIR)" "$(CURDIR)/$(BBCTL)" +endif + +reset: +ifeq ($(UNAME_S),Darwin) + @scripts/reset-bridge.sh "$(BUNDLE_ID)" +else + @scripts/reset-bridge.sh +endif + +uninstall: +ifeq ($(UNAME_S),Darwin) + -launchctl unload ~/Library/LaunchAgents/$(BUNDLE_ID).plist 2>/dev/null + rm -f ~/Library/LaunchAgents/$(BUNDLE_ID).plist + @echo "LaunchAgent removed. App bundle at $(APP_BUNDLE) left in place." +else + @echo "On Linux, stop the service and remove the binary manually." +endif + +# =========================================================================== +# Extract-key (hardware key extraction tool) +# =========================================================================== +# extract-key uses CGO with Objective-C and macOS frameworks (Foundation, +# IOKit, DiskArbitration), so it can only be compiled on macOS. +# It has its own go.mod (Go 1.20) so it can build on macOS 10.13 High Sierra. +# +# On older Macs without Go installed, use the self-contained build script: +# cd tools/extract-key && ./build.sh + +.PHONY: extract-key + +ifeq ($(UNAME_S),Darwin) +extract-key: + cd tools/extract-key && go build -trimpath -o ../../extract-key . +else +extract-key: + @echo "Error: extract-key must be built on macOS." >&2 + @echo "It uses Objective-C and macOS frameworks (IOKit, Foundation, DiskArbitration)." >&2 + @echo "" >&2 + @echo "On the target Mac (including High Sierra 10.13+):" >&2 + @echo " cd tools/extract-key && ./build.sh" >&2 + @exit 1 +endif + +clean: +ifeq ($(UNAME_S),Darwin) + rm -rf $(APP_NAME).app +endif + rm -f $(APP_NAME) $(BBCTL) $(RUST_LIB) extract-key tools/extract-key/extract-key + cd pkg/rustpushgo && cargo clean 2>/dev/null || true diff --git a/README.md b/README.md index 215522fe..8341b1c2 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,358 @@ -# mautrix-imessage -A Matrix-iMessage puppeting bridge. The bridge can run on a Mac to bridge -iMessage or an Android phone to bridge SMS. All features are available when -using a Mac with SIP disabled, while a normal Mac can be used for basic -bridging. A [websocket proxy](https://github.com/mautrix/wsproxy) -is required to receive appservice events from the homeserver. - -## Documentation -All setup and usage instructions are located on -[docs.mau.fi](https://docs.mau.fi/bridges/go/imessage/index.html): - -* Bridge setup: - [macOS](https://docs.mau.fi/bridges/go/imessage/mac/setup.html), - [macOS (without SIP)](https://docs.mau.fi/bridges/go/imessage/mac-nosip/setup.html), - [Android SMS](https://docs.mau.fi/bridges/go/imessage/android/setup.html) - -### Features & Roadmap -[ROADMAP.md](https://github.com/mautrix/imessage/blob/master/ROADMAP.md) -contains a general overview of what is supported by the bridge. - -## Discussion -Matrix room: [#imessage:maunium.net](https://matrix.to/#/#imessage:maunium.net) +# mautrix-imessage (v2) + +**Important note for Beeper Users as of 2/25/2026** + +This update changes how bbctl is built and managed. If you have an existing installation, you must delete your bridge-manager directory before running make install-beeper again, otherwise the build will fail. + + - macOS / Linux: + `rm -rf ~/.local/share/mautrix-imessage/bridge-manager` + `make install-beeper` +*** +A Matrix-iMessage puppeting bridge. Send and receive iMessages from any Matrix client. + +This is the **v2** rewrite using [rustpush](https://github.com/OpenBubbles/rustpush) and [bridgev2](https://mau.fi/blog/megabridge-twilio/) — it connects directly to Apple's iMessage servers without SIP bypass, Barcelona, or relay servers. + +**Features**: text, images, video, audio, files, reactions/tapbacks, edits, unsends, typing indicators, read receipts, group chats, SMS forwarding, and contact name resolution. + +**Platforms**: macOS (full features) and Linux (via hardware key extracted from a Mac once). Please note, Contact Key Verification must be disabled for the bridge to function. + +## Quick Start (macOS) + +macOS 13+ required (Ventura or later). Sign into iCloud on the Mac running the bridge (Settings → Apple ID) — this lets Apple recognize the device so login works without 2FA prompts. + +### With Beeper + +```bash +git clone https://github.com/lrhodin/imessage.git +cd imessage +make install-beeper +``` + +The installer handles everything: Homebrew, dependencies, building, Beeper login, iMessage login, config, and LaunchAgent setup. + +### With a Self-Hosted Homeserver + +```bash +git clone https://github.com/lrhodin/imessage.git +cd imessage +make install +``` + +The installer auto-installs Homebrew and dependencies if needed, asks three questions (homeserver URL, domain, your Matrix ID), generates config files, handles iMessage login, and starts the bridge as a LaunchAgent. It will pause and tell you exactly what to add to your `homeserver.yaml` to register the bridge. + +## Quick Start (Linux) + +The bridge runs on Linux using a hardware key extracted once from a real Mac. No Mac needed at runtime for Intel keys; **Apple Silicon Macs** require the NAC relay (a small background process on the Mac). + +### Prerequisites + +Ubuntu 22.04+ (or equivalent). Only `git`, `make`, and `sudo` are needed — the build installs everything else: + +```bash +sudo apt install -y git make +``` + +### Step 1: Extract hardware key (one-time, on your Mac) + +**Option A: CLI (macOS 13+ with Go)** + +```bash +git clone https://github.com/lrhodin/imessage.git +cd imessage +go run tools/extract-key/main.go +``` + +**Option B: GUI app (macOS 10.15+ Catalina)** + +*Please consider this bleeding edge and minimally tested, use at your own risk for now.* It is suggested to compare output with option A until this method has been tested more. + +Build the SwiftUI extraction app on any Mac (Intel or Apple Silicon), then run it on the Intel Mac: + +```bash +git clone https://github.com/lrhodin/imessage.git +cd imessage/tools/extract-key-app +./build.sh +# Copy ExtractKey.app to the Intel Mac and double-click it. +``` + +The app reads hardware identifiers, displays them, and lets you copy or save the base64 key. If the Mac is missing encrypted IOKit properties (`_enc` fields), the app offers an **Enrich Key** button to compute them on the spot — no extra steps needed. + +> **Gatekeeper**: Because the app is ad-hoc signed (not notarized by Apple), macOS will block it on first launch. To open it: +> +> - **macOS 13+ (Ventura)**: Double-click the app. When the warning appears, go to **System Settings → Privacy & Security**, scroll down, and click **Open Anyway**. +> - **macOS 10.15–12**: Right-click (or Control-click) the app and choose **Open** from the context menu. Click **Open** in the dialog that appears. +> - **Terminal**: Run `xattr -cr ExtractKey.app` to strip the quarantine flag, then double-click normally. + +**Option C: older Macs (macOS 10.13 High Sierra through 12) without Go** + +Cross-compile on any Mac that has Go, then copy the binary over: + +```bash +# On your newer Mac (with Go installed): +git clone https://github.com/lrhodin/imessage.git +cd imessage +make extract-key-intel + +# Copy to the older Mac: +scp extract-key-intel user@old-mac:~/ + +# On the older Mac: +cd ~ && ./extract-key-intel +``` + +This reads hardware identifiers (serial, MLB, ROM, etc.) and outputs a base64 key. The Mac is not modified and can continue to be used normally. + +**Enriching keys from older Macs**: Keys extracted from older Intel Macs may be missing encrypted IOKit properties (`_enc` fields). The GUI app (Option A) can compute these automatically with the **Enrich Key** button. If using the CLI instead, you can enrich on your Linux bridge server: + +```bash +cd rustpush/open-absinthe +cargo run --bin enrich_hw_key -- --file ~/hwkey.b64 > ~/hwkey-enriched.b64 +``` + +This derives the missing `_enc` fields from plaintext values using the XNU encryption function (x86_64 Linux only). The GUI app runs the same function directly on the Mac. + +**Apple Silicon Macs** lack the encrypted IOKit properties needed by the x86_64 NAC emulator. You must also run the NAC relay — a small HTTP server that generates Apple validation data using the Mac's native `AAAbsintheContext` framework. + +**Option 1: GUI app (recommended)** + +Build and run the menubar app — it bundles the relay, key extraction, and status monitoring in one place: + +```bash +cd tools/nac-relay-app +./build.sh +open NACRelay.app +``` + +The app appears as an antenna icon in the menubar (no dock icon). It auto-starts the relay on launch, shows the relay address and auth info, and lets you extract the hardware key with relay credentials embedded — all from the popover UI. Click **Extract Hardware Key**, then **Copy Key** to get the base64 key. + +**Option 2: CLI** + +```bash +go build -o ~/bin/nac-relay ./tools/nac-relay/ +~/bin/nac-relay --setup +``` + +This installs a LaunchAgent that starts on login and auto-restarts if it crashes. + +The relay auto-generates a self-signed TLS certificate and a random bearer token on first start, stored in `~/Library/Application Support/nac-relay/`. All endpoints (except `/health`) require the token. The bridge verifies the relay's certificate fingerprint (Go side) and authenticates with the token (both Go and Rust sides). + +```bash +# Check it's running +tail -f /tmp/nac-relay.log +``` + +**Extract the key with the relay URL (CLI only — the GUI app does this automatically):** + +```bash +go run tools/extract-key/main.go -relay https://:5001/validation-data +``` + +The `extract-key` tool reads the token and certificate fingerprint from `relay-info.json` (written by the relay) and embeds them in the hardware key automatically. The relay must be running before you run `extract-key`. + +If the bridge runs outside your LAN (e.g., cloud VM), forward port 5001 TCP to your Mac's local IP. Lock the allowed source IPs to your bridge server's IP for defense in depth — the relay is also protected by TLS + bearer token auth. + +**Intel Macs**: The NAC relay is not needed. The bridge runs the x86_64 NAC emulator locally on Linux using hardware data from the extracted key. Chat history starts from when you log in and contacts appear by phone number / email. + +### Step 2: Build and install the bridge (on Linux) + +#### With Beeper + +```bash +git clone https://github.com/lrhodin/imessage.git +cd imessage +make install-beeper +``` + +#### With a Self-Hosted Homeserver + +```bash +git clone https://github.com/lrhodin/imessage.git +cd imessage +make install +``` + +On first run expect ~3 minutes for the Rust library to compile. + +### Step 3: Login + +DM the bridge bot and choose the **"Apple ID (External Key)"** login flow: + +1. Paste your hardware key (base64) +2. Enter your Apple ID and password +3. Enter the 2FA code sent to your trusted devices + +The bridge registers with Apple's servers and starts receiving iMessages. + +## Login + +Follow the prompts: Apple ID → password → 2FA (if needed) → handle selection. If the Mac is signed into iCloud with the same Apple ID, login completes without 2FA. + +If your Apple ID has multiple identities registered (e.g. a phone number and an email address), you'll be asked which one to use for outgoing messages. This is what recipients see your messages "from". To change it later, set `preferred_handle` in the config (see [Configuration](#configuration)) or log in again. + +> **Tip:** In a DM with the bot, commands don't need a prefix. In a regular room, use `!im login`, `!im help`, etc. + +### SMS Forwarding + +To bridge SMS (green bubble) messages, enable forwarding on your iPhone: + +**Settings → Messages → Text Message Forwarding** → toggle on the bridge device. + +### Chatting + +Incoming iMessages automatically create Matrix rooms. If Full Disk Access is granted (macOS), existing conversations from Messages.app are also synced. + +To start a **new** conversation: + +``` +resolve +15551234567 +``` + +This creates a portal room. Messages you send there are delivered as iMessages. + +## How It Works + +The bridge connects directly to Apple's iMessage servers using [rustpush](https://github.com/OpenBubbles/rustpush) with local NAC validation (no SIP bypass, no relay server). On macOS with Full Disk Access, it also reads `chat.db` for message history backfill and contact name resolution. + +On Linux, NAC validation uses one of two paths: + +- **Intel key**: [open-absinthe](rustpush/open-absinthe/) emulates Apple's `IMDAppleServices` x86_64 binary via unicorn-engine, hooking IOKit/CoreFoundation calls and feeding them hardware data from the extracted key +- **Apple Silicon key + relay**: The bridge fetches validation data from a NAC relay running on the Mac, which calls Apple's native `AAAbsintheContext` framework + +```mermaid +flowchart TB + subgraph macos["🖥 macOS"] + HS1[Homeserver] -- appservice --> Bridge1[mautrix-imessage] + Bridge1 -- FFI --> RP1[rustpush] + RP1 -- IOKit/AAAbsinthe --> NAC1[Local NAC] + end + subgraph linux["🐧 Linux"] + HS2[Homeserver] -- appservice --> Bridge2[mautrix-imessage] + Bridge2 -- FFI --> RP2[rustpush] + RP2 -- unicorn-engine --> NAC2[open-absinthe] + RP2 -. "Apple Silicon key (HTTPS + token)" .-> Relay[NAC Relay on Mac] + end + Client1[Matrix client] <--> HS1 + Client2[Matrix client] <--> HS2 + RP1 <--> Apple[Apple IDS / APNs] + RP2 <--> Apple + + style macos fill:#f0f4ff,stroke:#4a6fa5,stroke-width:2px,color:#1a1a2e + style linux fill:#f0fff4,stroke:#4aa56f,stroke-width:2px,color:#1a1a2e + style Apple fill:#1a1a2e,stroke:#1a1a2e,color:#fff + style Client1 fill:#fff,stroke:#999,color:#333 + style Client2 fill:#fff,stroke:#999,color:#333 + style Relay fill:#ffe0b2,stroke:#e65100,color:#333 +``` + +### Real-time and backfill + +**Real-time messages** flow through Apple's push notification service (APNs) via rustpush and appear in Matrix immediately. + +**CloudKit backfill** (optional, off by default) syncs your iMessage history from iCloud on first login. Enable it during `make install` or by setting `cloudkit_backfill: true` in config. When enabled, the login flow will ask for your device PIN to join the iCloud Keychain trust circle, which grants access to Messages in iCloud. The backfill window is controlled by `initial_sync_days` (default: 1 year). + +## Management + +### macOS + +```bash +# View logs +tail -f data/bridge.stdout.log + +# Restart (auto-restarts via KeepAlive) +launchctl kickstart -k gui/$(id -u)/com.lrhodin.mautrix-imessage + +# Stop until next login +launchctl bootout gui/$(id -u)/com.lrhodin.mautrix-imessage + +# Uninstall +make uninstall +``` + +### Linux + +```bash +# If using systemd (from make install / make install-beeper) +systemctl --user status mautrix-imessage +journalctl --user -u mautrix-imessage -f +systemctl --user restart mautrix-imessage + +# If running directly +./mautrix-imessage-v2 -c data/config.yaml +``` + +### NAC Relay (macOS) + +```bash +# View logs +tail -f /tmp/nac-relay.log + +# Restart +launchctl kickstart -k gui/$(id -u)/com.imessage.nac-relay + +# Stop +launchctl bootout gui/$(id -u)/com.imessage.nac-relay +``` + +## Configuration + +Config lives in `data/config.yaml` (generated during install). To reconfigure from scratch: + +```bash +rm -rf data +make install # or make install-beeper +``` + +Key options: + +| Field | Default | What it does | +|-------|---------|-------------| +| `network.cloudkit_backfill` | `false` | Enable CloudKit message history backfill (requires device PIN during login) | +| `network.initial_sync_days` | `365` | How far back to backfill on first login (only when backfill is enabled) | +| `network.displayname_template` | First/Last name | How bridged contacts appear in Matrix | +| `network.preferred_handle` | *(from login)* | Outgoing identity (`tel:+15551234567` or `mailto:user@example.com`) | +| `backfill.max_initial_messages` | `50000` | Max messages to backfill per chat (auto-tuned when backfill enabled) | +| `encryption.allow` | `true` | Enable end-to-bridge encryption | +| `database.type` | `sqlite3-fk-wal` | `sqlite3-fk-wal` or `postgres` | + +## Development + +```bash +make build # Build .app bundle (macOS) or binary (Linux) +make rust # Build Rust library only +make bindings # Regenerate Go FFI bindings (needs uniffi-bindgen-go) +make clean # Remove build artifacts +``` + +### Source layout + +``` +cmd/mautrix-imessage/ # Entrypoint +pkg/connector/ # bridgev2 connector + ├── connector.go # bridge lifecycle + platform detection + ├── client.go # send/receive/reactions/edits/typing + ├── login.go # Apple ID + external key login flows + ├── chatdb.go # chat.db backfill + contacts (macOS) + ├── ids.go # identifier/portal ID conversion + ├── capabilities.go # supported features + └── config.go # bridge config schema +pkg/rustpushgo/ # Rust FFI wrapper (uniffi) +rustpush/ # OpenBubbles/rustpush (vendored) + └── open-absinthe/ # NAC emulator (unicorn-engine, cross-platform) +nac-validation/ # Local NAC via AppleAccount.framework (macOS) +tools/ + ├── extract-key/ # Hardware key extraction CLI (Go, run on Mac) + ├── extract-key-app/ # Hardware key extraction GUI (SwiftUI, x86_64, run on Mac) + ├── nac-relay/ # NAC validation relay CLI (Go, run on Mac) + └── nac-relay-app/ # NAC relay menubar app (SwiftUI, arm64, wraps nac-relay) +rustpush/open-absinthe/ + └── src/bin/enrich_hw_key # Enrich keys missing _enc fields (x86_64 Linux CLI) +imessage/ # macOS chat.db + Contacts reader +``` + +## Chat With Us + +**Chat with us on Matrix**: [Join our Room Here](https://matrix.to/#/#imessage-rustpush:beeper.com) + +## License + +AGPL-3.0 — see [LICENSE](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 5fdc4152..00000000 --- a/ROADMAP.md +++ /dev/null @@ -1,67 +0,0 @@ -# Features & roadmap -✔️ = feature is supported -❌ = feature is not yet supported -🛑 = feature is not possible - -Note that Barcelona, which the mac-nosip connector uses, is no longer maintained. - -## Matrix → iMessage -| Feature | mac | mac-nosip | bluebubbles | -|----------------------|-----|-----------|-------------| -| Plain text | ✔️ | ✔️ | ✔️ | -| Media/files | ✔️ | ✔️ | ✔️ | -| Replies | 🛑 | ✔️ | ✔️† | -| Reactions | 🛑 | ✔️ | ✔️ | -| Edits | 🛑 | ❌ | ✔️* | -| Unsends | 🛑 | ❌ | ✔️* | -| Redactions | 🛑 | ✔️ | ✔️ | -| Read receipts | 🛑 | ✔️ | ✔️ | -| Typing notifications | 🛑 | ✔️ | ✔️ | - -† BlueBubbles had bugs with replies until v1.9.5 - -\* macOS Ventura or higher is required - -## iMessage → Matrix -| Feature | mac | mac-nosip | bluebubbles | -|----------------------------------|-----|-----------|-------------| -| Plain text | ✔️ | ✔️ | ✔️ | -| Media/files | ✔️ | ✔️ | ✔️ | -| Replies | ✔️ | ✔️ | ✔️ | -| Tapbacks | ✔️ | ✔️ | ✔️ | -| Edits | ❌ | ❌ | ✔️ (BlueBubbles Server >= 1.9.6) | -| Unsends | ❌ | ❌ | ✔️ (BlueBubbles Server >= 1.9.6) | -| Own read receipts | ✔️ | ✔️ | ✔️ | -| Other read receipts | ✔️ | ✔️ | ✔️ | -| Typing notifications | 🛑 | ✔️ | ✔️ | -| User metadata | ✔️ | ✔️ | ✔️ | -| Group metadata | ✔️ | ✔️ | ✔️ | -| Group Participants Added/Removed | ✔️ | ✔️ | ✔️ | -| Backfilling history | ✔️‡ | ✔️‡ | ✔️‡ | - -‡ Backfilling tapbacks is not yet supported - -## Android SMS -The android-sms connector is deprecated in favor of [mautrix-gmessages](https://github.com/mautrix/gmessages). - -#### Supported -* Plain text (SMS) -* Media (MMS) -* Group chats -* Backfilling history from the Android SMS database. -* Storing messages in the Android SMS database - (so you can still switch to a different SMS app). - -#### Not supported -* RCS (there's no API for it, it's exclusive to Google's Messages app). -* Any features that SMS/MMS don't support - (replies, reactions, read receipts, typing notifications). - -## Misc -* [x] Automatic portal creation - * [x] At startup - * [x] When receiving message -* [ ] Private chat creation by inviting Matrix puppet of iMessage user to new room -* [ ] Option to use own Matrix account for messages sent from other iMessage clients - * [x] Automatically with shared secret login - * [ ] Manually with `login-matrix` command diff --git a/backfillqueue.go b/backfillqueue.go deleted file mode 100644 index bbd307cc..00000000 --- a/backfillqueue.go +++ /dev/null @@ -1,332 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2023 Tulir Asokan, Sumner Evans -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/database" - "go.mau.fi/mautrix-imessage/imessage" -) - -type BackfillQueue struct { - BackfillQuery *database.BackfillQuery - reCheckChannel chan bool - log log.Logger -} - -func (bq *BackfillQueue) ReCheck() { - bq.log.Infofln("Sending re-checks to channel") - go func() { - bq.reCheckChannel <- true - }() -} - -func (bq *BackfillQueue) GetNextBackfill(userID id.UserID) *database.Backfill { - for { - if backfill := bq.BackfillQuery.GetNext(userID); backfill != nil { - backfill.MarkDispatched() - return backfill - } - - select { - case <-bq.reCheckChannel: - case <-time.After(time.Minute): - } - } -} - -func (user *User) HandleBackfillRequestsLoop(ctx context.Context) { - log := zerolog.Ctx(ctx).With().Str("component", "backfill_requests_loop").Logger() - - for { - if count, err := user.bridge.DB.Backfill.Count(ctx, user.MXID); err != nil { - user.setBackfillError(log, err, "Failed to get the number of backfills") - return - } else if incompleteCount, err := user.bridge.DB.Backfill.IncompleteCount(ctx, user.MXID); err != nil { - user.setBackfillError(log, err, "Failed to get the number of incomplete backfills") - return - } else if count > 0 && incompleteCount == 0 { - log.Info(). - Int("num_backfills", count). - Msg("No incomplete backfills, setting status to done") - user.setBackfillDone(log) - time.Sleep(5 * time.Second) - continue - } - - log.Info().Msg("Getting backfill request") - req := user.BackfillQueue.GetNextBackfill(user.MXID) - log.Info().Interface("req", req).Msg("Handling backfill request") - - portal := user.bridge.GetPortalByGUID(req.PortalGUID) - user.backfillInChunks(req, portal) - req.MarkDone() - } -} - -func (user *User) EnqueueImmediateBackfill(txn dbutil.Execable, priority int, portal *Portal, earliestBridgedTimestamp time.Time) { - maxMessages := user.bridge.Config.Bridge.Backfill.Immediate.MaxEvents - initialBackfill := user.bridge.DB.Backfill.NewWithValues(user.MXID, priority, portal.GUID, nil, &earliestBridgedTimestamp, maxMessages, maxMessages, 0) - initialBackfill.Insert(txn) -} - -func (user *User) EnqueueDeferredBackfills(txn dbutil.Execable, portals []*Portal, startIdx int) { - now := time.Now() - numPortals := len(portals) - for stageIdx, backfillStage := range user.bridge.Config.Bridge.Backfill.Deferred { - for portalIdx, portal := range portals { - var startDate *time.Time = nil - if backfillStage.StartDaysAgo > 0 { - startDaysAgo := now.AddDate(0, 0, -backfillStage.StartDaysAgo) - startDate = &startDaysAgo - } - backfillMessages := user.bridge.DB.Backfill.NewWithValues( - user.MXID, startIdx+stageIdx*numPortals+portalIdx, portal.GUID, startDate, nil, backfillStage.MaxBatchEvents, -1, backfillStage.BatchDelay) - backfillMessages.Insert(txn) - } - } -} - -type BackfillStatus string - -const ( - BackfillStatusUnknown BackfillStatus = "unknown" - BackfillStatusLockedByAnotherDevice BackfillStatus = "locked_by_another_device" - BackfillStatusRunning BackfillStatus = "running" - BackfillStatusError BackfillStatus = "error" - BackfillStatusDone BackfillStatus = "done" -) - -type BackfillInfo struct { - Status BackfillStatus `json:"status"` - Error string `json:"error,omitempty"` -} - -func (user *User) GetBackfillInfo() BackfillInfo { - backfillInfo := BackfillInfo{Status: BackfillStatusUnknown} - if user.backfillStatus != "" { - backfillInfo.Status = user.backfillStatus - } - if user.backfillError != nil { - backfillInfo.Error = user.backfillError.Error() - } - return backfillInfo -} - -func (user *User) setBackfillError(log zerolog.Logger, err error, msg string) { - log.Err(err).Msg(msg) - user.backfillStatus = BackfillStatusError - user.backfillError = fmt.Errorf("%s: %w", msg, err) -} - -type BackfillStateAccountData struct { - DeviceID id.DeviceID `json:"device_id"` - Done bool `json:"done"` -} - -func (user *User) setBackfillDone(log zerolog.Logger) { - log.Info(). - Str("device_id", user.bridge.Config.Bridge.DeviceID). - Msg("Setting backfill state account data to done") - err := user.bridge.Bot.SetAccountData("fi.mau.imessage.backfill_state", &BackfillStateAccountData{ - DeviceID: id.DeviceID(user.bridge.Config.Bridge.DeviceID), - Done: true, - }) - if err != nil { - user.setBackfillError(log, err, "failed to set backfill state account data") - return - } - user.backfillStatus = BackfillStatusDone -} - -func (user *User) runOnlyBackfillMode() { - log := user.bridge.ZLog.With().Str("mode", "backfill_only").Logger() - ctx := log.WithContext(context.Background()) - - // Start the backfill queue. We always want this running so that the - // desktop app can request the backfill status. - user.handleHistorySyncsLoop(ctx) - - if !user.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) { - user.setBackfillError(log, nil, "The homeserver does not support Beeper's batch send endpoint") - return - } - - if user.bridge.Config.Bridge.DeviceID == "" { - user.setBackfillError(log, nil, "No device ID set in the config") - return - } - - var backfillState BackfillStateAccountData - err := user.bridge.Bot.GetAccountData("fi.mau.imessage.backfill_state", &backfillState) - if err != nil { - if !errors.Is(err, mautrix.MNotFound) { - user.setBackfillError(log, err, "Error fetching backfill state account data") - return - } - } else if backfillState.DeviceID.String() != user.bridge.Config.Bridge.DeviceID { - user.backfillStatus = BackfillStatusLockedByAnotherDevice - log.Warn(). - Str("device_id", backfillState.DeviceID.String()). - Msg("Backfill already locked for a different device") - return - } else if backfillState.Done { - log.Info(). - Str("device_id", backfillState.DeviceID.String()). - Msg("Backfill already completed") - user.backfillStatus = BackfillStatusDone - return - } - - if count, err := user.bridge.DB.Backfill.Count(ctx, user.MXID); err != nil { - user.setBackfillError(log, err, "Failed to get the number of backfills") - return - } else if incompleteCount, err := user.bridge.DB.Backfill.IncompleteCount(ctx, user.MXID); err != nil { - user.setBackfillError(log, err, "Failed to get the number of incomplete backfills") - return - } else if count > 0 && incompleteCount == 0 { - log.Info(). - Int("num_backfills", count). - Msg("No incomplete backfills, setting status to done") - user.setBackfillDone(log) - return - } else { - err = user.bridge.Crypto.ShareKeys(context.Background()) - if err != nil { - user.setBackfillError(log, err, "Error sharing keys") - } - - err = user.bridge.Bot.SetAccountData("fi.mau.imessage.backfill_state", &BackfillStateAccountData{ - DeviceID: id.DeviceID(user.bridge.Config.Bridge.DeviceID), - Done: false, - }) - if err != nil { - user.setBackfillError(log, err, "failed to set backfill state account data") - return - } - user.backfillStatus = BackfillStatusRunning - - if count == 0 { - log.Info().Msg("Starting backfill") - user.getRoomsForBackfillAndEnqueue(ctx) - } else { - log.Info(). - Int("num_backfills", count). - Int("num_incomplete_backfills", incompleteCount). - Msg("Resuming backfill") - // No need to do anything else because the history syncs loop is - // already running - } - } -} - -func (user *User) getRoomsForBackfillAndEnqueue(ctx context.Context) { - log := zerolog.Ctx(ctx).With().Str("method", "getRoomsForBackfillAndEnqueue").Logger() - - // Get every chat from the database - chats, err := user.bridge.IM.GetChatsWithMessagesAfter(imessage.AppleEpoch) - if err != nil { - user.setBackfillError(log, err, "Error retrieving all chats") - return - } - - chatGUIDs := make([]string, len(chats)) - chatInfos := make([]*imessage.ChatInfo, len(chats)) - for i, chat := range chats { - chatGUIDs[i] = chat.ChatGUID - chatInfos[i], err = user.bridge.IM.GetChatInfo(chat.ChatGUID, chat.ThreadID) - if err != nil { - user.setBackfillError(log, err, - fmt.Sprintf("Error getting chat info for %s from database", chat.ChatGUID)) - return - } - chatInfos[i].JSONChatGUID = chatInfos[i].Identifier.String() - } - - // Ask the cloud bridge to create room IDs for every one of the chats. - client := user.CustomIntent().Client - url := client.BuildURL(mautrix.BaseURLPath{ - "_matrix", "asmux", "mxauth", "appservice", user.MXID.Localpart(), "imessagecloud", - "exec", "create_rooms_for_backfill"}) - _, err = client.MakeRequest("POST", url, NewRoomBackfillRequest{ - Chats: chatInfos, - }, nil) - if err != nil { - user.setBackfillError(log, err, "Error starting creation of backfill rooms") - return - } - - // Wait for the rooms to be created. - var roomInfoResp RoomInfoForBackfillResponse - for { - url = client.BuildURL(mautrix.BaseURLPath{ - "_matrix", "asmux", "mxauth", "appservice", user.MXID.Localpart(), "imessagecloud", - "exec", "get_room_info_for_backfill"}) - _, err = client.MakeRequest("POST", url, RoomInfoForBackfillRequest{ - ChatGUIDs: chatGUIDs, - }, &roomInfoResp) - if err != nil { - user.setBackfillError(log, err, "Error requesting backfill room info") - return - } - - if roomInfoResp.Status == RoomCreationForBackfillStatusDone { - break - } else if roomInfoResp.Status == RoomCreationForBackfillStatusError { - user.setBackfillError(log, fmt.Errorf(roomInfoResp.Error), "Error requesting backfill room IDs") - return - } else if roomInfoResp.Status == RoomCreationForBackfillStatusInProgress { - log.Info().Msg("Backfill room creation still in progress, waiting 5 seconds") - time.Sleep(5 * time.Second) - } else { - user.setBackfillError(log, fmt.Errorf("Unknown status %s", roomInfoResp.Status), "Error requesting backfill room IDs") - return - } - } - - // Create all of the portals locally and enqueue backfill requests for - // all of them. - txn, err := user.bridge.DB.Begin() - { - portals := []*Portal{} - var i int - for _, chatIdentifier := range chats { - roomInfo := roomInfoResp.Rooms[chatIdentifier.ChatGUID] - portal := user.bridge.GetPortalByGUIDWithTransaction(txn, chatIdentifier.ChatGUID) - portal.MXID = roomInfo.RoomID - portal.Update(txn) - portals = append(portals, portal) - user.EnqueueImmediateBackfill(txn, i, portal, time.UnixMilli(roomInfo.EarliestBridgedTimestamp)) - i++ - } - user.EnqueueDeferredBackfills(txn, portals, i) - } - if err = txn.Commit(); err != nil { - user.setBackfillError(log, err, "Error committing backfill room IDs") - return - } -} diff --git a/bridgeinfo.go b/bridgeinfo.go deleted file mode 100644 index f9f14cb2..00000000 --- a/bridgeinfo.go +++ /dev/null @@ -1,50 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "reflect" - - "maunium.net/go/mautrix/event" -) - -const bridgeInfoProto = "fi.mau.imessage" -const bridgeInfoHandle = "fi.mau.imessage.handle" -const bridgeInfoService = "fi.mau.imessage.service" - -type CustomBridgeInfoSection struct { - event.BridgeInfoSection - - GUID string `json:"fi.mau.imessage.guid,omitempty"` - Service string `json:"fi.mau.imessage.service,omitempty"` - IsGroup bool `json:"fi.mau.imessage.is_group,omitempty"` - - SendStatusStart int64 `json:"com.beeper.send_status_start,omitempty"` - TimeoutSeconds int `json:"com.beeper.timeout_seconds,omitempty"` - DeviceID string `json:"com.beeper.device_id,omitempty"` - ThreadID string `json:"com.beeper.thread_id,omitempty"` -} - -type CustomBridgeInfoContent struct { - event.BridgeEventContent - Channel CustomBridgeInfoSection `json:"channel"` -} - -func init() { - event.TypeMap[event.StateBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) - event.TypeMap[event.StateHalfShotBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) -} diff --git a/build.sh b/build.sh deleted file mode 100755 index 1dab651c..00000000 --- a/build.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -build_tags="" - -if [[ $(arch) == "arm64" && -z "$LIBRARY_PATH" && -d /opt/homebrew ]]; then - echo "Using /opt/homebrew for LIBRARY_PATH and CPATH" - export LIBRARY_PATH=/opt/homebrew/lib - export CPATH=/opt/homebrew/include - HEIF_PATH="$LIBRARY_PATH" -else - HEIF_PATH="/usr/local/lib" -fi - -if [[ -f "$HEIF_PATH/libheif.1.dylib" ]]; then - build_tags="libheif" -fi - -go build -tags "$build_tags" -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@" diff --git a/chatmerging.go b/chatmerging.go deleted file mode 100644 index cebd9660..00000000 --- a/chatmerging.go +++ /dev/null @@ -1,271 +0,0 @@ -package main - -import ( - "os" - "strings" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/imessage" -) - -func (br *IMBridge) UpdateMerges(contacts []*imessage.Contact) { - br.Log.Infofln("Updating chat merges with %d contacts", len(contacts)) - - alreadyHandledGUIDs := make(map[string]struct{}, len(contacts)*2) - portals := make([]*Portal, 0, 16) - noPortals := make([]string, 0, 16) - collect := func(localID string) { - guid := imessage.Identifier{Service: "iMessage", LocalID: localID}.String() - if _, alreadyHandled := alreadyHandledGUIDs[guid]; alreadyHandled { - return - } - alreadyHandledGUIDs[guid] = struct{}{} - portal := br.GetPortalByGUIDIfExists(guid) - if portal == nil { - noPortals = append(noPortals, guid) - } else if portal.GUID == guid { - portals = append(portals, portal) - } // else: the ID has already been merged into something else, so ignore it for now - } - - for _, contact := range contacts { - portals = portals[:0] - noPortals = noPortals[:0] - - // Find all the portals from the contact (except ones that have already been merged into another GUID) - for _, phone := range contact.Phones { - if !strings.HasPrefix(phone, "+") { - continue - } - collect(phone) - } - for _, email := range contact.Emails { - collect(email) - } - - // If we found more than one existing portal, merge them into the best one - if len(portals) > 1 { - bestPortal := portals[0] - bestPortalIndex := 0 - for i, portal := range portals { - alreadyHandledGUIDs[portal.GUID] = struct{}{} - for _, secondaryGUID := range portal.SecondaryGUIDs { - alreadyHandledGUIDs[secondaryGUID] = struct{}{} - } - if len(portal.SecondaryGUIDs) > len(bestPortal.SecondaryGUIDs) { - bestPortal = portal - bestPortalIndex = i - } - } - portals[bestPortalIndex], portals[0] = portals[0], portals[bestPortalIndex] - bestPortal.Merge(portals[1:]) - } - // If we found any identifiers without a portal, just mark them as merged in the database. - if len(noPortals) > 1 || (len(noPortals) == 1 && len(portals) > 0) { - var targetPortal *Portal - mergeList := noPortals - if len(portals) == 0 { - targetPortal = br.GetPortalByGUID(noPortals[0]) - mergeList = noPortals[1:] - } else { - targetPortal = portals[0] - } - br.Log.Debugfln("Merging %v (with no portals created) into portal %s", mergeList, targetPortal.GUID) - br.DB.MergedChat.Set(nil, targetPortal.GUID, mergeList...) - targetPortal.addSecondaryGUIDs(mergeList) - } - } - br.Log.Infoln("Finished merging with contact list") -} - -func (portal *Portal) Merge(others []*Portal) { - roomIDs := make([]id.RoomID, 0, len(others)) - guids := make([]string, 0, len(others)) - alreadyAdded := map[string]struct{}{} - for _, other := range others { - if other == portal { - continue - } - if _, ok := alreadyAdded[other.GUID]; ok { - continue - } - if other.MXID != "" { - roomIDs = append(roomIDs, other.MXID) - } - guids = append(guids, other.GUID) - alreadyAdded[other.GUID] = struct{}{} - for _, secondaryGUID := range other.SecondaryGUIDs { - if _, ok := alreadyAdded[secondaryGUID]; !ok { - alreadyAdded[secondaryGUID] = struct{}{} - guids = append(guids, secondaryGUID) - } - } - } - if portal.MXID != "" { - roomIDs = append(roomIDs, portal.MXID) - } - var newRoomID id.RoomID - var req *mautrix.ReqBeeperMergeRoom - portal.log.Debugfln("Merging room with %v (%v)", guids, roomIDs) - if len(roomIDs) > 1 && portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { - req = &mautrix.ReqBeeperMergeRoom{ - NewRoom: *portal.getRoomCreateContent(), - Key: bridgeInfoHandle, - Rooms: roomIDs, - User: portal.MainIntent().UserID, - } - resp, err := portal.MainIntent().BeeperMergeRooms(req) - if err != nil { - portal.log.Errorfln("Failed to merge room: %v", err) - return - } - portal.log.Debugfln("Got merged room ID %s", resp.RoomID) - newRoomID = resp.RoomID - } else if len(roomIDs) > 1 { - portal.log.Debugfln("Deleting old rooms as homeserver doesn't support merging") - for _, other := range others { - other.Cleanup(false) - } - } else if len(roomIDs) == 1 && portal.MXID == "" { - portal.log.Debugfln("Using old room ID as new one") - newRoomID = roomIDs[0] - } - portal.bridge.portalsLock.Lock() - defer portal.bridge.portalsLock.Unlock() - - txn, err := portal.bridge.DB.Begin() - if err != nil { - portal.log.Errorln("Failed to begin transaction to merge rooms:", err) - return - } - portal.log.Debugln("Updating portal GUIDs in message table") - portal.bridge.DB.Message.MergePortalGUID(txn, portal.GUID, guids...) - portal.log.Debugln("Updating merged chat table") - portal.bridge.DB.MergedChat.Set(txn, portal.GUID, guids...) - for _, guid := range guids { - portal.bridge.portalsByGUID[guid] = portal - } - portal.addSecondaryGUIDs(guids) - if newRoomID != "" { - portal.log.Debugln("Updating in-memory caches") - for _, roomID := range roomIDs { - delete(portal.bridge.portalsByMXID, roomID) - } - portal.bridge.portalsByMXID[newRoomID] = portal - portal.MXID = newRoomID - portal.InSpace = false - portal.FirstEventID = "" - portal.Update(txn) - } - err = txn.Commit() - if err != nil { - portal.log.Errorln("Failed to commit room merge transaction:", err) - } else { - portal.log.Infofln("Finished merging %v -> %s / %v -> %s", guids, portal.GUID, roomIDs, newRoomID) - if newRoomID != "" { - portal.addToSpace(portal.bridge.user) - portal.bridge.user.UpdateDirectChats(map[id.UserID][]id.RoomID{portal.GetDMPuppet().MXID: {portal.MXID}}) - for _, user := range req.NewRoom.Invite { - portal.bridge.StateStore.SetMembership(portal.MXID, user, event.MembershipJoin) - } - } - } -} - -func (portal *Portal) Split(splitParts map[string][]string) { - br := portal.bridge - log := portal.log - reqParts := make([]mautrix.BeeperSplitRoomPart, len(splitParts)) - portals := make(map[string]*Portal, len(splitParts)) - portalReq := make(map[string]*mautrix.BeeperSplitRoomPart, len(splitParts)) - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - txn, err := br.DB.Begin() - if err != nil { - log.Errorln("Failed to begin transaction to split rooms:", err) - return - } - i := -1 - for primaryGUID, guids := range splitParts { - guids = append(guids, primaryGUID) - i++ - if primaryGUID == portal.GUID { - log.Debugfln("Keeping %v in current portal", guids) - reqParts[i].UserID = portal.MainIntent().UserID - reqParts[i].NewRoom = *portal.getRoomCreateContent() - reqParts[i].Values = guids - portal.LastSeenHandle = portal.GUID - portals[portal.GUID] = portal - portalReq[portal.GUID] = &reqParts[i] - continue - } - log.Debugfln("Updating merged chat mapping with %v -> %s", guids, primaryGUID) - for _, guid := range guids { - delete(br.portalsByGUID, guid) - } - partPortal := br.loadDBPortal(txn, nil, primaryGUID) - partPortal.addSecondaryGUIDs(guids) - partPortal.LastSeenHandle = primaryGUID - partPortal.preCreateDMSync(nil) - portals[partPortal.GUID] = partPortal - portalReq[partPortal.GUID] = &reqParts[i] - reqParts[i].UserID = partPortal.MainIntent().UserID - reqParts[i].NewRoom = *partPortal.getRoomCreateContent() - reqParts[i].Values = guids - for _, guid := range guids { - br.portalsByGUID[guid] = partPortal - res := br.DB.Message.SplitPortalGUID(txn, guid, portal.GUID, primaryGUID) - log.Debugfln("Moved %d messages with handle %s in portal %s to portal %s", res, guid, portal.GUID, partPortal.GUID) - } - br.DB.MergedChat.Set(txn, primaryGUID, guids...) - } - wasSplit := false - if portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { - var resp *mautrix.RespBeeperSplitRoom - resp, err = portal.MainIntent().BeeperSplitRoom(&mautrix.ReqBeeperSplitRoom{ - RoomID: portal.MXID, - Key: bridgeInfoHandle, - Parts: reqParts, - }) - if err != nil { - log.Fatalfln("Failed to split rooms: %v", err) - os.Exit(60) - } - log.Debugfln("Room splitting response: %+v", resp.RoomIDs) - wasSplit = true - delete(br.portalsByMXID, portal.MXID) - for guid, partPortal := range portals { - roomID, ok := resp.RoomIDs[guid] - if !ok { - log.Warnfln("Merge didn't return new room ID for %s", guid) - } else { - log.Debugfln("Split got room ID for %s: %s", guid, roomID) - partPortal.MXID = roomID - partPortal.FirstEventID = "" - partPortal.InSpace = false - partPortal.Update(txn) - br.portalsByMXID[roomID] = partPortal - } - } - } - err = txn.Commit() - if err != nil { - log.Errorln("Failed to commit room split transaction:", err) - } - log.Debugfln("Finished splitting room into %+v", splitParts) - for guid, partPortal := range portals { - if partPortal.MXID != "" { - partPortal.addToSpace(br.user) - br.user.UpdateDirectChats(map[id.UserID][]id.RoomID{partPortal.GetDMPuppet().MXID: {partPortal.MXID}}) - if wasSplit { - for _, user := range portalReq[guid].NewRoom.Invite { - br.StateStore.SetMembership(partPortal.MXID, user, event.MembershipJoin) - } - } - } - } -} diff --git a/clangwrap.sh b/clangwrap.sh deleted file mode 100755 index 09ffc519..00000000 --- a/clangwrap.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec "$CLANG" -v -arch $ARCH -target $ARCH-apple-ios -stdlib=libc++ -isysroot "$SDK_PATH" "$@" diff --git a/cmd/bbctl/auth.go b/cmd/bbctl/auth.go new file mode 100644 index 00000000..ad5ef96d --- /dev/null +++ b/cmd/bbctl/auth.go @@ -0,0 +1,268 @@ +package main + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/urfave/cli/v2" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" + + "github.com/beeper/bridge-manager/api/beeperapi" +) + +// EnvConfig holds credentials for one Beeper environment. +// Kept compatible with the upstream bridge-manager config format so that +// existing ~/.config/bbctl/config.json files continue to work. +type EnvConfig struct { + ClusterID string `json:"cluster_id"` + Username string `json:"username"` + AccessToken string `json:"access_token"` + BridgeDataDir string `json:"bridge_data_dir"` +} + +func (ec *EnvConfig) HasCredentials() bool { + return strings.HasPrefix(ec.AccessToken, "syt_") +} + +type EnvConfigs map[string]*EnvConfig + +func (ec EnvConfigs) Get(env string) *EnvConfig { + conf, ok := ec[env] + if !ok { + conf = &EnvConfig{} + ec[env] = conf + } + return conf +} + +type Config struct { + DeviceID id.DeviceID `json:"device_id"` + Environments EnvConfigs `json:"environments"` + Path string `json:"-"` +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return strings.ToUpper(hex.EncodeToString(b)) +} + +func loadConfig(path string) (*Config, error) { + // Migrate old config path (bbctl.json → bbctl/config.json) + baseConfigDir, _ := os.UserConfigDir() + newDefault := filepath.Join(baseConfigDir, "bbctl", "config.json") + oldDefault := filepath.Join(baseConfigDir, "bbctl.json") + if path == newDefault { + if _, err := os.Stat(oldDefault); err == nil { + _ = os.MkdirAll(filepath.Dir(newDefault), 0700) + _ = os.Rename(oldDefault, newDefault) + } + } + + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return &Config{ + DeviceID: id.DeviceID("bbctl_" + randomHex(4)), + Environments: make(EnvConfigs), + Path: path, + }, nil + } else if err != nil { + return nil, fmt.Errorf("failed to open config at %s: %w", path, err) + } + defer file.Close() + + var cfg Config + if err = json.NewDecoder(file).Decode(&cfg); err != nil { + return nil, fmt.Errorf("failed to parse config at %s: %w", path, err) + } + cfg.Path = path + if cfg.DeviceID == "" { + cfg.DeviceID = id.DeviceID("bbctl_" + randomHex(4)) + } + if cfg.Environments == nil { + cfg.Environments = make(EnvConfigs) + } + return &cfg, nil +} + +func (cfg *Config) Save() error { + if err := os.MkdirAll(filepath.Dir(cfg.Path), 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + file, err := os.OpenFile(cfg.Path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open config for writing: %w", err) + } + defer file.Close() + if err = json.NewEncoder(file).Encode(cfg); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + return nil +} + +var loginCommand = &cli.Command{ + Name: "login", + Usage: "Log into Beeper", + Before: prepareApp, + Action: cmdLogin, +} + +var logoutCommand = &cli.Command{ + Name: "logout", + Usage: "Delete bridge and log out of Beeper", + ArgsUsage: "[BRIDGE]", + Before: requiresAuth, + Action: cmdLogout, +} + +var whoamiCommand = &cli.Command{ + Name: "whoami", + Aliases: []string{"w"}, + Usage: "Show logged-in user and bridge list", + Before: requiresAuth, + Action: cmdWhoami, +} + +func readLine(prompt string) (string, error) { + fmt.Print(prompt) + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + return strings.TrimSpace(line), err +} + +func cmdLogin(ctx *cli.Context) error { + loginResp, err := beeperapi.StartLogin(baseDomain) + if err != nil { + return fmt.Errorf("failed to start login: %w", err) + } + + email, err := readLine("Email: ") + if err != nil { + return err + } + + if err = beeperapi.SendLoginEmail(baseDomain, loginResp.RequestID, email); err != nil { + return fmt.Errorf("failed to send login email: %w", err) + } + + code, err := readLine("Code (from email): ") + if err != nil { + return err + } + + apiResp, err := beeperapi.SendLoginCode(baseDomain, loginResp.RequestID, code) + if err != nil { + return fmt.Errorf("failed to verify code: %w", err) + } + + // Exchange the JWT login token for a Matrix access token (syt_...) + matrixClient, err := mautrix.NewClient(fmt.Sprintf("https://matrix.%s", baseDomain), "", "") + if err != nil { + return fmt.Errorf("failed to create matrix client: %w", err) + } + cfg := getConfig(ctx) + matrixResp, err := matrixClient.Login(ctx.Context, &mautrix.ReqLogin{ + Type: "org.matrix.login.jwt", + Token: apiResp.LoginToken, + DeviceID: cfg.DeviceID, + InitialDeviceDisplayName: "github.com/beeper/bridge-manager", + }) + if err != nil { + return fmt.Errorf("failed to exchange login token: %w", err) + } + + envCfg := cfg.Environments.Get("prod") + envCfg.AccessToken = matrixResp.AccessToken + whoami := apiResp.Whoami + if whoami == nil { + whoami, err = beeperapi.Whoami(baseDomain, matrixResp.AccessToken) + if err != nil { + return fmt.Errorf("failed to get user details: %w", err) + } + } + // The Beeper API may not have the username ready immediately after login + // for some accounts. Retry with backoff to handle propagation delay. + if whoami.UserInfo.Username == "" { + for attempt := 1; attempt <= 5; attempt++ { + fmt.Fprintf(os.Stderr, "Waiting for username from Beeper API (attempt %d/5)...\n", attempt) + time.Sleep(time.Duration(attempt) * 2 * time.Second) + whoami, err = beeperapi.Whoami(baseDomain, matrixResp.AccessToken) + if err != nil { + return fmt.Errorf("failed to get user details: %w", err) + } + if whoami.UserInfo.Username != "" { + break + } + } + if whoami.UserInfo.Username == "" { + return fmt.Errorf("Beeper API returned empty username after login — please try again in a few seconds") + } + } + envCfg.Username = whoami.UserInfo.Username + envCfg.ClusterID = whoami.UserInfo.BridgeClusterID + if err = cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Printf("Logged in as %s\n", envCfg.Username) + return nil +} + +func cmdLogout(ctx *cli.Context) error { + bridge := ctx.Args().Get(0) + if bridge == "" { + bridge = "sh-imessage" + } + + envCfg := getEnvConfig(ctx) + hungryClient := getHungryClient(ctx) + + if err := hungryClient.DeleteAppService(ctx.Context, bridge); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to delete appservice: %v\n", err) + } + if err := beeperapi.DeleteBridge(baseDomain, bridge, envCfg.AccessToken); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to delete bridge from Beeper API: %v\n", err) + } else { + fmt.Printf("Bridge '%s' deleted\n", bridge) + } + + // Invalidate the Matrix access token on the server + if mc, err := mautrix.NewClient(fmt.Sprintf("https://matrix.%s", baseDomain), "", ""); err == nil { + mc.AccessToken = envCfg.AccessToken + mc.Logout(ctx.Context) + } + + cfg := getConfig(ctx) + username := cfg.Environments.Get("prod").Username + cfg.Environments.Get("prod").AccessToken = "" + cfg.Environments.Get("prod").Username = "" + cfg.Environments.Get("prod").ClusterID = "" + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + fmt.Printf("Logged out of %s\n", username) + return nil +} + +func cmdWhoami(ctx *cli.Context) error { + envCfg := getEnvConfig(ctx) + resp, err := beeperapi.Whoami(baseDomain, envCfg.AccessToken) + if err != nil { + return fmt.Errorf("failed to get whoami: %w", err) + } + + fmt.Println(resp.UserInfo.Username) + for name, bridge := range resp.User.Bridges { + fmt.Printf(" %s %s %s\n", name, bridge.BridgeState.BridgeType, bridge.BridgeState.StateEvent) + } + return nil +} diff --git a/cmd/bbctl/delete.go b/cmd/bbctl/delete.go new file mode 100644 index 00000000..0e3c6592 --- /dev/null +++ b/cmd/bbctl/delete.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "os" + + "github.com/urfave/cli/v2" + + "github.com/beeper/bridge-manager/api/beeperapi" +) + +var deleteCommand = &cli.Command{ + Name: "delete", + Usage: "Delete a bridge registration", + ArgsUsage: "BRIDGE", + Before: requiresAuth, + Action: cmdDelete, +} + +func cmdDelete(ctx *cli.Context) error { + if ctx.NArg() == 0 { + return fmt.Errorf("you must specify a bridge name") + } + bridge := ctx.Args().Get(0) + + envCfg := getEnvConfig(ctx) + hungryClient := getHungryClient(ctx) + + if err := hungryClient.DeleteAppService(ctx.Context, bridge); err != nil { + return fmt.Errorf("failed to delete appservice: %w", err) + } + + if err := beeperapi.DeleteBridge(baseDomain, bridge, envCfg.AccessToken); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to delete bridge from Beeper API: %v\n", err) + } + + fmt.Printf("Bridge '%s' deleted\n", bridge) + return nil +} diff --git a/cmd/bbctl/main.go b/cmd/bbctl/main.go new file mode 100644 index 00000000..c4eb2b95 --- /dev/null +++ b/cmd/bbctl/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/urfave/cli/v2" + + "github.com/beeper/bridge-manager/api/beeperapi" + "github.com/beeper/bridge-manager/api/hungryapi" +) + +const baseDomain = "beeper.com" + +type contextKey int + +const ( + contextKeyConfig contextKey = iota + contextKeyEnvConfig + contextKeyHungryClient +) + +func getConfig(ctx *cli.Context) *Config { + return ctx.Context.Value(contextKeyConfig).(*Config) +} + +func getEnvConfig(ctx *cli.Context) *EnvConfig { + return ctx.Context.Value(contextKeyEnvConfig).(*EnvConfig) +} + +func getHungryClient(ctx *cli.Context) *hungryapi.Client { + val := ctx.Context.Value(contextKeyHungryClient) + if val == nil { + return nil + } + return val.(*hungryapi.Client) +} + +func getConfigPath() string { + baseDir, _ := os.UserConfigDir() + return filepath.Join(baseDir, "bbctl", "config.json") +} + +func prepareApp(ctx *cli.Context) error { + cfg, err := loadConfig(ctx.String("config")) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + envCfg := cfg.Environments.Get("prod") + newCtx := context.WithValue(ctx.Context, contextKeyConfig, cfg) + newCtx = context.WithValue(newCtx, contextKeyEnvConfig, envCfg) + if envCfg.HasCredentials() { + if envCfg.Username == "" { + fmt.Fprintf(os.Stderr, "Fetching whoami to fill missing username...\n") + for attempt := 0; attempt < 5; attempt++ { + if attempt > 0 { + fmt.Fprintf(os.Stderr, " Retrying (%d/5)...\n", attempt+1) + time.Sleep(time.Duration(attempt) * 2 * time.Second) + } + resp, whoamiErr := beeperapi.Whoami(baseDomain, envCfg.AccessToken) + if whoamiErr != nil { + return fmt.Errorf("failed to get whoami: %w", whoamiErr) + } + envCfg.Username = resp.UserInfo.Username + envCfg.ClusterID = resp.UserInfo.BridgeClusterID + if envCfg.Username != "" { + break + } + } + if envCfg.Username != "" { + if saveErr := cfg.Save(); saveErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to save config: %v\n", saveErr) + } + } else { + fmt.Fprintf(os.Stderr, "Warning: Beeper API returned empty username\n") + } + } + hungryClient := hungryapi.NewClient(baseDomain, envCfg.Username, envCfg.AccessToken) + newCtx = context.WithValue(newCtx, contextKeyHungryClient, hungryClient) + } + ctx.Context = newCtx + return nil +} + +func requiresAuth(ctx *cli.Context) error { + if err := prepareApp(ctx); err != nil { + return err + } + if !getEnvConfig(ctx).HasCredentials() { + return fmt.Errorf("you are not logged in — run 'bbctl login' first") + } + return nil +} + +func main() { + app := &cli.App{ + Name: "bbctl", + Usage: "Manage self-hosted Beeper bridges", + Version: "0.1.0", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Usage: "Path to config file", + Value: getConfigPath(), + }, + }, + Commands: []*cli.Command{ + loginCommand, + logoutCommand, + whoamiCommand, + configCommand, + stopCommand, + deleteCommand, + }, + } + if err := app.Run(os.Args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/bbctl/register.go b/cmd/bbctl/register.go new file mode 100644 index 00000000..a7c457cb --- /dev/null +++ b/cmd/bbctl/register.go @@ -0,0 +1,137 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + + "github.com/urfave/cli/v2" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/id" + + "github.com/beeper/bridge-manager/api/beeperapi" + "github.com/beeper/bridge-manager/api/hungryapi" + "github.com/beeper/bridge-manager/bridgeconfig" +) + +// imessageNetworkConfig is prepended to the bridgev2 base config. +// It sets iMessage-specific defaults without requiring a separate template file. +const imessageNetworkConfig = `network: + displayname_template: '{{if .FirstName}}{{.FirstName}}{{if .LastName}} {{.LastName}}{{end}}{{else if .Nickname}}{{.Nickname}}{{else if .Phone}}{{.Phone}}{{else if .Email}}{{.Email}}{{else}}{{.ID}}{{end}}' + cloudkit_backfill: false + backfill_source: cloudkit + video_transcoding: false + heic_conversion: false + heic_jpeg_quality: 95 +` + +var configCommand = &cli.Command{ + Name: "config", + Usage: "Register a bridge and generate its configuration file", + ArgsUsage: "BRIDGE", + Before: requiresAuth, + Action: cmdConfig, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Usage: "Bridge type (e.g. imessage-v2) — accepted but ignored, always generates iMessage config", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Value: "-", + Usage: "Output file path (- for stdout)", + }, + }, +} + +func generateSecret(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func cmdConfig(ctx *cli.Context) error { + if ctx.NArg() == 0 { + return fmt.Errorf("you must specify a bridge name") + } + bridge := ctx.Args().Get(0) + + envCfg := getEnvConfig(ctx) + if envCfg.Username == "" { + return fmt.Errorf("cannot generate config: username is empty (Beeper API may not have it ready yet — try again in a few seconds)") + } + hungryClient := getHungryClient(ctx) + + // Register (or re-fetch) the appservice with Beeper hungryserv + reg, err := hungryClient.RegisterAppService(ctx.Context, bridge, hungryapi.ReqRegisterAppService{ + Push: false, + SelfHosted: true, + }) + if err != nil { + return fmt.Errorf("failed to register appservice: %w", err) + } + // Drop the extra bot user namespace entry added by hungryserv + if len(reg.Namespaces.UserIDs) > 1 { + reg.Namespaces.UserIDs = reg.Namespaces.UserIDs[0:1] + } + + // Use the hunger client's domain-based URL (matrix.beeper.com) rather than + // the direct IP from whoami.UserInfo.HungryURL — the IP endpoint uses + // Beeper's internal CA which is not in the system trust store. + hungryURL := hungryClient.HomeserverURL.String() + + params := bridgeconfig.Params{ + HungryAddress: hungryURL, + BeeperDomain: baseDomain, + Websocket: true, + AppserviceID: reg.ID, + ASToken: reg.AppToken, + HSToken: reg.ServerToken, + BridgeName: bridge, + Username: envCfg.Username, + UserID: id.NewUserID(envCfg.Username, baseDomain), + ProvisioningSecret: generateSecret(16), + BridgeV2Name: bridgeconfig.BridgeV2Name{ + CommandPrefix: "!im", + DatabaseFileName: "mautrix-imessage", + BridgeTypeName: "iMessage", + BridgeTypeIcon: "mxc://beeper.com/imessage", + DefaultPickleKey: "beeper", + }, + } + + baseConfig, err := bridgeconfig.Generate("bridgev2", params) + if err != nil { + return fmt.Errorf("failed to generate config: %w", err) + } + + // Prepend iMessage-specific network block + output := imessageNetworkConfig + baseConfig + + // Notify Beeper that this bridge is registered and starting + err = beeperapi.PostBridgeState(baseDomain, envCfg.Username, bridge, reg.AppToken, beeperapi.ReqPostBridgeState{ + StateEvent: status.StateStarting, + Reason: "SELF_HOST_REGISTERED", + IsSelfHosted: true, + BridgeType: "imessage", + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to post bridge state: %v\n", err) + } + + // Write config to file or stdout + outputPath := ctx.String("output") + if outputPath == "-" { + fmt.Print(output) + } else { + if err = os.WriteFile(outputPath, []byte(output), 0600); err != nil { + return fmt.Errorf("failed to write config to %s: %w", outputPath, err) + } + fmt.Fprintf(os.Stderr, "Config written to %s\n", outputPath) + } + + return nil +} diff --git a/cmd/bbctl/stop.go b/cmd/bbctl/stop.go new file mode 100644 index 00000000..51e438c4 --- /dev/null +++ b/cmd/bbctl/stop.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/urfave/cli/v2" + "gopkg.in/yaml.v3" + + "maunium.net/go/mautrix/bridgev2/status" + + "github.com/beeper/bridge-manager/api/beeperapi" +) + +var stopCommand = &cli.Command{ + Name: "stop", + Usage: "Tell Beeper that the bridge is stopped (not running)", + ArgsUsage: "BRIDGE [CONFIG_PATH]", + Before: requiresAuth, + Action: cmdStop, +} + +func findBridgeConfig() (string, error) { + dataDir := os.Getenv("XDG_DATA_HOME") + if dataDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dataDir = filepath.Join(home, ".local", "share") + } + return filepath.Join(dataDir, "mautrix-imessage", "config.yaml"), nil +} + +func readASToken(configPath string) (string, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return "", err + } + var cfg struct { + ASToken string `yaml:"as_token"` + Appservice struct { + ASToken string `yaml:"as_token"` + } `yaml:"appservice"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return "", fmt.Errorf("failed to parse config: %w", err) + } + // bridgev2 configs nest the token under appservice; fall back to + // legacy top-level as_token for older config shapes. + token := cfg.Appservice.ASToken + if token == "" { + token = cfg.ASToken + } + if token == "" { + return "", fmt.Errorf("as_token not found in config") + } + return token, nil +} + +func cmdStop(ctx *cli.Context) error { + if ctx.NArg() == 0 { + return fmt.Errorf("usage: bbctl stop BRIDGE [CONFIG_PATH]") + } + bridge := ctx.Args().Get(0) + + // Config path: second arg, or auto-discover from XDG + var configPath string + if ctx.NArg() >= 2 { + configPath = ctx.Args().Get(1) + } else { + var err error + configPath, err = findBridgeConfig() + if err != nil { + return err + } + } + + asToken, err := readASToken(configPath) + if err != nil { + return fmt.Errorf("failed to read AS token from %s: %w", configPath, err) + } + + envCfg := getEnvConfig(ctx) + err = beeperapi.PostBridgeState(baseDomain, envCfg.Username, bridge, asToken, beeperapi.ReqPostBridgeState{ + StateEvent: status.StateBridgeUnreachable, + Reason: "SELF_HOST_STOPPED", + IsSelfHosted: true, + BridgeType: "imessage", + }) + if err != nil { + return fmt.Errorf("failed to post stopped state: %w", err) + } + fmt.Printf("Bridge '%s' stopped\n", bridge) + return nil +} diff --git a/cmd/mautrix-imessage/carddav_setup.go b/cmd/mautrix-imessage/carddav_setup.go new file mode 100644 index 00000000..e8a3e921 --- /dev/null +++ b/cmd/mautrix-imessage/carddav_setup.go @@ -0,0 +1,78 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// CLI subcommand for setting up external CardDAV contacts. +// Auto-discovers the CardDAV URL and encrypts the password. + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/rs/zerolog" + + "github.com/lrhodin/imessage/pkg/connector" +) + +// cardDAVSetupResult is the JSON output of the carddav-setup command. +type cardDAVSetupResult struct { + URL string `json:"url"` + PasswordEncrypted string `json:"password_encrypted"` +} + +// runCardDAVSetup handles the carddav-setup subcommand. +// Discovers the CardDAV URL and encrypts the password. +// Outputs JSON to stdout for install scripts to parse. +func runCardDAVSetup() { + fs := flag.NewFlagSet("carddav-setup", flag.ExitOnError) + email := fs.String("email", "", "Email address for CardDAV auto-discovery") + password := fs.String("password", "", "App password for CardDAV authentication") + username := fs.String("username", "", "Username (defaults to email if empty)") + url := fs.String("url", "", "CardDAV URL (skip auto-discovery)") + + fs.Parse(os.Args[2:]) + + if *email == "" || *password == "" { + fmt.Fprintln(os.Stderr, "Usage: carddav-setup --email --password [--username ] [--url ]") + os.Exit(1) + } + + log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger() + + effectiveUsername := *username + if effectiveUsername == "" { + effectiveUsername = *email + } + + // Auto-discover URL if not provided + discoveredURL := *url + if discoveredURL == "" { + var err error + discoveredURL, err = connector.DiscoverCardDAVURL(*email, effectiveUsername, *password, log) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: CardDAV auto-discovery failed: %v\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "✓ Discovered CardDAV URL: %s\n", discoveredURL) + } + + // Encrypt the password + encrypted, err := connector.EncryptCardDAVPassword(*password) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Failed to encrypt password: %v\n", err) + os.Exit(1) + } + fmt.Fprintln(os.Stderr, "✓ Password encrypted") + + // Output JSON to stdout + result := cardDAVSetupResult{ + URL: discoveredURL, + PasswordEncrypted: encrypted, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(result) +} diff --git a/cmd/mautrix-imessage/login_cli.go b/cmd/mautrix-imessage/login_cli.go new file mode 100644 index 00000000..8293fcb9 --- /dev/null +++ b/cmd/mautrix-imessage/login_cli.go @@ -0,0 +1,191 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/matrix/mxmain" + "maunium.net/go/mautrix/id" +) + +// Global reader so buffered input isn't lost between prompts (important when +// input is piped rather than typed interactively). +var stdinReader = bufio.NewReader(os.Stdin) + +func prompt(label string) string { + fmt.Fprintf(os.Stderr, "%s: ", label) + line, _ := stdinReader.ReadString('\n') + return strings.TrimSpace(line) +} + +// promptSelect displays numbered options and returns the selected value. +func promptSelect(label string, options []string) string { + fmt.Fprintf(os.Stderr, "%s:\n", label) + for i, opt := range options { + fmt.Fprintf(os.Stderr, " %d) %s\n", i+1, opt) + } + for { + fmt.Fprintf(os.Stderr, "Enter number (1-%d): ", len(options)) + line, _ := stdinReader.ReadString('\n') + trimmed := strings.TrimSpace(line) + var idx int + if _, err := fmt.Sscanf(trimmed, "%d", &idx); err == nil && idx >= 1 && idx <= len(options) { + return options[idx-1] + } + // Also accept the option value directly + for _, opt := range options { + if strings.EqualFold(trimmed, opt) { + return opt + } + } + fmt.Fprintf(os.Stderr, " Invalid choice, try again.\n") + } +} + +// promptMultiline reads lines until an empty line, concatenating and stripping +// all whitespace. Used for fields like hardware keys that are long base64 +// strings which get split across lines when pasted. +func promptMultiline(label string) string { + fmt.Fprintf(os.Stderr, "%s (paste, then press Enter on a blank line):\n", label) + var parts []string + for { + line, _ := stdinReader.ReadString('\n') + trimmed := strings.TrimSpace(line) + if trimmed == "" { + break + } + parts = append(parts, trimmed) + } + return strings.Join(parts, "") +} + +// runInteractiveLogin drives the bridge's login flow from the terminal. +// It reuses the exact same CreateLogin → SubmitUserInput code path as the +// Matrix bot, but reads input from stdin instead of Matrix messages. +func runInteractiveLogin(br *mxmain.BridgeMain) { + // Initialize the bridge (DB, connector, etc.) without starting Matrix. + br.PreInit() + repairPermissions(br) + br.Init() + + ctx := br.Log.WithContext(context.Background()) + + // Run database migrations (normally done in Start → StartConnectors, + // but we don't call Start because we don't need the Matrix connection). + if err := br.DB.Upgrade(ctx); err != nil { + fmt.Fprintf(os.Stderr, "[!] Database migration failed: %v\n", err) + os.Exit(1) + } + + // Initialize BackgroundCtx (normally set in StartConnectors). + // NewLogin needs this for LoadUserLogin. + br.Bridge.BackgroundCtx, _ = context.WithCancel(context.Background()) + br.Bridge.BackgroundCtx = br.Log.WithContext(br.Bridge.BackgroundCtx) + + // Find the admin user from permissions config. + userMXID := findAdminUser(br) + if userMXID == "" { + fmt.Fprintln(os.Stderr, "[!] No admin user found in config permissions. Cannot log in.") + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "[*] Logging in as %s\n", userMXID) + + user, err := br.Bridge.GetUserByMXID(ctx, userMXID) + if err != nil { + fmt.Fprintf(os.Stderr, "[!] Failed to get user: %v\n", err) + os.Exit(1) + } + + // Pick login flow: prefer external-key on Linux, apple-id on macOS. + flows := br.Bridge.Network.GetLoginFlows() + var flowID string + for _, f := range flows { + if f.ID == "apple-id" { + flowID = f.ID // prefer if available (macOS) + } + } + if flowID == "" && len(flows) > 0 { + flowID = flows[0].ID + } + if flowID == "" { + fmt.Fprintln(os.Stderr, "[!] No login flows available") + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "[*] Using login flow: %s\n", flowID) + + login, err := br.Bridge.Network.CreateLogin(ctx, user, flowID) + if err != nil { + fmt.Fprintf(os.Stderr, "[!] Failed to create login: %v\n", err) + os.Exit(1) + } + + step, err := login.Start(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "[!] Failed to start login: %v\n", err) + os.Exit(1) + } + + // Drive the multi-step login flow interactively. + userInput, ok := login.(bridgev2.LoginProcessUserInput) + if !ok { + fmt.Fprintln(os.Stderr, "[!] Login flow does not support user input") + os.Exit(1) + } + + for step.Type != bridgev2.LoginStepTypeComplete { + if step.Instructions != "" { + fmt.Fprintf(os.Stderr, "\n%s\n\n", step.Instructions) + } + + switch step.Type { + case bridgev2.LoginStepTypeUserInput: + input := make(map[string]string) + for _, field := range step.UserInputParams.Fields { + if strings.Contains(field.ID, "key") { + // Long base64 values get line-wrapped when pasted. + input[field.ID] = promptMultiline(field.Name) + } else if len(field.Options) > 0 { + input[field.ID] = promptSelect(field.Name, field.Options) + } else { + input[field.ID] = prompt(field.Name) + } + } + step, err = userInput.SubmitUserInput(ctx, input) + if err != nil { + fmt.Fprintf(os.Stderr, "[!] Login step failed: %v\n", err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "[!] Unsupported login step type: %s\n", step.Type) + os.Exit(1) + } + } + + fmt.Fprintf(os.Stderr, "\n[+] %s\n", step.Instructions) + fmt.Fprintf(os.Stderr, "[+] Login ID: %s\n", step.CompleteParams.UserLoginID) + + // Clean shutdown. + os.Exit(0) +} + +// findAdminUser returns the first user MXID with admin permissions. +func findAdminUser(br *mxmain.BridgeMain) id.UserID { + for userID, perm := range br.Config.Bridge.Permissions { + if perm.Admin { + return id.UserID(userID) + } + } + return "" +} diff --git a/cmd/mautrix-imessage/main.go b/cmd/mautrix-imessage/main.go new file mode 100644 index 00000000..74a9befb --- /dev/null +++ b/cmd/mautrix-imessage/main.go @@ -0,0 +1,275 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Tulir Asokan, Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/beeper/bridge-manager/api/beeperapi" + + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/matrix/mxmain" + "maunium.net/go/mautrix/id" + + "github.com/lrhodin/imessage/pkg/connector" +) + +var ( + Tag = "unknown" + Commit = "unknown" + BuildTime = "unknown" +) + +var m = mxmain.BridgeMain{ + Name: "mautrix-imessage", + URL: "https://github.com/lrhodin/imessage", + Description: "A Matrix-iMessage puppeting bridge (bridgev2).", + Version: "0.1.0", + + Connector: &connector.IMConnector{}, +} + +func init() { + m.PostInit = func() { + proc := m.Bridge.Commands.(*commands.Processor) + for _, h := range connector.BridgeCommands() { + proc.AddHandler(h) + } + } +} + +func main() { + m.InitVersion(Tag, Commit, BuildTime) + + // Handle subcommands / flags before normal bridge startup. + if len(os.Args) > 1 && os.Args[0] != "-" { + switch os.Args[1] { + case "login": + // Remove "login" from args so flag parsing in PreInit works. + os.Args = append(os.Args[:1], os.Args[2:]...) + runInteractiveLogin(&m) + return + case "check-restore": + // Validate that backup session state can be restored without + // re-authentication. Exits 0 if valid, 1 if not. + if connector.CheckSessionRestore() { + fmt.Fprintln(os.Stderr, "[+] Backup session state is valid — login can be auto-restored") + os.Exit(0) + } else { + fmt.Fprintln(os.Stderr, "[-] No valid backup session state — login required") + os.Exit(1) + } + case "list-handles": + // Print available iMessage handles (phone/email) from session state. + handles := connector.ListHandles() + if len(handles) == 0 { + os.Exit(1) + } + for _, h := range handles { + fmt.Println(h) + } + return + case "carddav-setup": + // Discover CardDAV URL + encrypt password for install scripts. + runCardDAVSetup() + return + case "init-db": + // Initialize the database schema and exit without starting the + // bridge. Used by install scripts to create the DB before asking + // setup questions, without connecting to Matrix or APNs. + os.Args = append(os.Args[:1], os.Args[2:]...) + m.PreInit() + repairPermissions(&m) + m.Init() + fmt.Fprintln(os.Stderr, "Database initialized successfully") + os.Exit(0) + } + } + + // --setup flag: check permissions (FDA + Contacts) via native dialogs. + if isSetupMode() { + // Remove --setup from args so it doesn't confuse the bridge. + var filtered []string + for _, a := range os.Args { + if a != "--setup" && a != "-setup" { + filtered = append(filtered, a) + } + } + os.Args = filtered + runSetupPermissions() + return + } + + // Instead of m.Run(), manually call PreInit/Init/Start so we can + // repair broken permissions before validateConfig() runs in Init(). + m.PreInit() + repairPermissions(&m) + m.Init() + m.Start() + exitCode := m.WaitForInterrupt() + m.Stop() + os.Exit(exitCode) +} + +// repairPermissions detects and fixes broken bridge.permissions before the +// bridge's validateConfig() rejects the config. This handles cases where +// bbctl generated a config with an empty or invalid username, leaving +// permissions with only example.com defaults. +func repairPermissions(br *mxmain.BridgeMain) { + if br.Config == nil { + return + } + configured := br.Config.Bridge.Permissions.IsConfigured() + fmt.Fprintf(os.Stderr, "[permissions] IsConfigured=%v entries=%d\n", configured, len(br.Config.Bridge.Permissions)) + for key := range br.Config.Bridge.Permissions { + fmt.Fprintf(os.Stderr, "[permissions] %q\n", key) + } + if configured { + return + } + + // Permissions are not configured — try to derive the correct MXID + // from bbctl's saved credentials. + username := loadBBCtlUsername() + if username == "" { + fmt.Fprintf(os.Stderr, "[permissions] loadBBCtlUsername returned empty — cannot repair\n") + return + } + + mxid := id.NewUserID(username, "beeper.com") + + // Remove bogus entries (example.com defaults, empty username variants, + // wildcard relay) from the in-memory map so findAdminUser() doesn't + // pick them over the real MXID. Patterns match fixPermissionsOnDisk() + // and the shell fix_permissions() function. + for key := range br.Config.Bridge.Permissions { + if strings.Contains(key, "example.com") || key == "*" || + key == "@:" || key == "@" || strings.HasPrefix(key, "@:") { + delete(br.Config.Bridge.Permissions, key) + } + } + + br.Config.Bridge.Permissions[string(mxid)] = &bridgeconfig.PermissionLevelAdmin + + // Also persist the fix to config.yaml so this is a one-time repair. + if br.ConfigPath != "" { + fixPermissionsOnDisk(br.ConfigPath, string(mxid)) + } + + fmt.Fprintf(os.Stderr, "Auto-repaired bridge.permissions: %s → admin\n", mxid) +} + +// loadBBCtlUsername reads the username from bbctl's config.json. +func loadBBCtlUsername() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + path := filepath.Join(configDir, "bbctl", "config.json") + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var cfg struct { + Environments map[string]struct { + Username string `json:"username"` + AccessToken string `json:"access_token"` + } `json:"environments"` + } + if json.Unmarshal(data, &cfg) != nil { + return "" + } + if prod, ok := cfg.Environments["prod"]; ok { + if prod.Username != "" { + return prod.Username + } + // Username empty but have credentials — try whoami as last resort + if strings.HasPrefix(prod.AccessToken, "syt_") { + resp, err := beeperapi.Whoami("beeper.com", prod.AccessToken) + if err == nil && resp.UserInfo.Username != "" { + return resp.UserInfo.Username + } + } + } + return "" +} + +// fixPermissionsOnDisk patches the config.yaml file to set the correct admin +// MXID and remove all bogus permission entries (example.com defaults, empty +// username variants). Matches the same patterns as the in-memory cleanup in +// repairPermissions() and the shell repair function in the install scripts. +func fixPermissionsOnDisk(configPath string, mxid string) { + data, err := os.ReadFile(configPath) + if err != nil { + return + } + + // isBogusPermLine returns true for any permissions entry that should be + // removed: example.com defaults, empty-username variants, wildcard relay. + isBogusPermLine := func(trimmed string) bool { + // Empty-username patterns: "@:beeper.com", "@": ... + if strings.Contains(trimmed, `"@":`) || strings.Contains(trimmed, `"@:`) { + return true + } + // Example.com defaults: "@admin:example.com", "example.com" + if strings.Contains(trimmed, "example.com") { + return true + } + // Wildcard relay entry from example config + if strings.HasPrefix(trimmed, `"*":`) { + return true + } + return false + } + + lines := strings.Split(string(data), "\n") + inPerms := false + replaced := false + var out []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Track whether we're inside the permissions block. + if strings.HasPrefix(trimmed, "permissions:") { + inPerms = true + out = append(out, line) + continue + } + // A non-indented, non-empty line exits the permissions block. + if inPerms && trimmed != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + inPerms = false + } + + if inPerms && isBogusPermLine(trimmed) { + if !replaced && strings.Contains(trimmed, ": admin") { + // Replace the first admin line with the correct MXID. + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + out = append(out, indent+`"`+mxid+`": admin`) + replaced = true + } + // Drop all other bogus lines (example.com user, wildcard relay, etc.) + continue + } + out = append(out, line) + } + _ = os.WriteFile(configPath, []byte(strings.Join(out, "\n")), 0600) +} diff --git a/cmd/mautrix-imessage/setup_darwin.go b/cmd/mautrix-imessage/setup_darwin.go new file mode 100644 index 00000000..0961bb0e --- /dev/null +++ b/cmd/mautrix-imessage/setup_darwin.go @@ -0,0 +1,114 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +//go:build darwin && !ios + +package main + +import ( + "database/sql" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" + + "github.com/lrhodin/imessage/imessage/mac" +) + +// dialog shows a macOS dialog and returns true if the user clicked the +// default button. The optional second button is always "Quit". +func dialog(title, msg string) bool { + script := fmt.Sprintf( + `display dialog %q with title %q buttons {"Quit","OK"} default button "OK"`, + msg, title, + ) + err := exec.Command("osascript", "-e", script).Run() + return err == nil // user clicked OK +} + +func dialogInfo(title, msg string) { + script := fmt.Sprintf( + `display dialog %q with title %q buttons {"OK"} default button "OK"`, + msg, title, + ) + exec.Command("osascript", "-e", script).Run() +} + +func canReadChatDB() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + dbPath := filepath.Join(home, "Library", "Messages", "chat.db") + db, err := sql.Open("sqlite3", dbPath+"?mode=ro") + if err != nil { + return false + } + defer db.Close() + _, err = db.Query("SELECT 1 FROM message LIMIT 1") + return err == nil +} + +func requestContacts() (bool, error) { + cs := mac.NewContactStore() + err := cs.RequestContactAccess() + if err != nil { + return false, err + } + return cs.HasContactAccess, nil +} + +// runSetupPermissions checks and prompts for FDA and Contacts permissions +// using native macOS dialogs. It does NOT install the LaunchAgent — the +// install script handles that. +func runSetupPermissions() { + title := "iMessage Bridge Setup" + + // ── Step 1: Full Disk Access ───────────────────────────────── + if !canReadChatDB() { + if !dialog(title, "Full Disk Access is required to read iMessages.\n\nClick OK, then add this app in the window that opens.") { + os.Exit(0) + } + exec.Command("open", "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles").Run() + for !canReadChatDB() { + time.Sleep(2 * time.Second) + } + dialogInfo(title, "✓ Full Disk Access granted.") + } + + // ── Step 2: Contacts ───────────────────────────────────────── + granted, err := requestContacts() + if err != nil { + dialog(title, fmt.Sprintf("Contacts access error: %v\n\nPlease grant access in System Settings → Privacy & Security → Contacts.", err)) + } else if !granted { + dialog(title, "Contacts access was denied.\n\nPlease enable it in System Settings → Privacy & Security → Contacts, then restart.") + } + + status := "✓ Full Disk Access granted\n" + if granted { + status += "✓ Contacts access granted\n" + } else { + status += "⚠ Contacts access not granted (names won't resolve)\n" + } + dialogInfo(title, status) +} + +func isSetupMode() bool { + for _, arg := range os.Args[1:] { + if strings.TrimLeft(arg, "-") == "setup" { + return true + } + } + return false +} + + diff --git a/cmd/mautrix-imessage/setup_other.go b/cmd/mautrix-imessage/setup_other.go new file mode 100644 index 00000000..95434086 --- /dev/null +++ b/cmd/mautrix-imessage/setup_other.go @@ -0,0 +1,9 @@ +//go:build !darwin || ios + +package main + +func isSetupMode() bool { + return false +} + +func runSetupPermissions() {} diff --git a/commands.go b/commands.go deleted file mode 100644 index ee37236e..00000000 --- a/commands.go +++ /dev/null @@ -1,176 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "fmt" - "strings" - - "go.mau.fi/mautrix-imessage/imessage" - "maunium.net/go/mautrix/bridge/commands" -) - -var ( - HelpSectionManagingPortals = commands.HelpSection{Name: "Managing portals", Order: 15} -) - -type WrappedCommandEvent struct { - *commands.Event - Bridge *IMBridge - User *User - Portal *Portal -} - -func (br *IMBridge) RegisterCommands() { - proc := br.CommandProcessor.(*commands.Processor) - proc.AddHandlers( - cmdPM, - cmdSearchContacts, - cmdRefreshContacts, - ) -} - -func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) { - return func(ce *commands.Event) { - user := ce.User.(*User) - var portal *Portal - if ce.Portal != nil { - portal = ce.Portal.(*Portal) - } - br := ce.Bridge.Child.(*IMBridge) - handler(&WrappedCommandEvent{ce, br, user, portal}) - } -} - -var cmdPM = &commands.FullHandler{ - Func: wrapCommand(fnPM), - Name: "pm", - Help: commands.HelpMeta{ - Section: HelpSectionManagingPortals, - Description: "Creates a new PM with the specified number or address.", - }, - RequiresPortal: false, - RequiresLogin: false, -} - -func fnPM(ce *WrappedCommandEvent) { - ce.Bridge.ZLog.Trace().Interface("args", ce.Args).Str("cmd", ce.Command).Msg("fnPM") - - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `pm ` OR `pm `") - return - } - - startedDm, err := ce.Bridge.WebsocketHandler.StartChat(*&StartDMRequest{ - Identifier: ce.RawArgs, - Force: false, - ActuallyStart: true, - }) - - if err != nil { - ce.Reply("Failed to start PM: %s", err) - } else { - ce.Reply("Created portal room [%s](%s) and invited you to it.", startedDm.RoomID, startedDm.RoomID.URI(ce.Bridge.Config.Homeserver.Domain).MatrixToURL()) - } -} - -var cmdSearchContacts = &commands.FullHandler{ - Func: wrapCommand(fnSearchContacts), - Name: "search-contacts", - Help: commands.HelpMeta{ - Section: HelpSectionManagingPortals, - Description: "Searches contacts based on name, phone, and email (only for BlueBubbles mode).", - }, - RequiresPortal: false, - RequiresLogin: false, -} - -func fnSearchContacts(ce *WrappedCommandEvent) { - ce.Bridge.ZLog.Trace().Interface("args", ce.Args).Str("cmd", ce.Command).Msg("fnSearchContacts") - - if len(ce.Args) == 0 { - ce.Reply("**Usage:** `search-contacts `") - return - } - - // TODO trim whitespace from args first - contacts, err := ce.Bridge.IM.SearchContactList(ce.RawArgs) - if err != nil { - ce.Reply("Failed to search contacts: %s", err) - } else { - if contacts == nil || len(contacts) == 0 { - ce.Reply("No contacts found for search `%s`", ce.RawArgs) - } else { - replyMessage := fmt.Sprintf("Found %d contacts:\n", len(contacts)) - - for _, contact := range contacts { - markdownString := buildContactString(contact) - replyMessage += markdownString - replyMessage += strings.Repeat("-", 40) + "\n" - } - - ce.Reply(replyMessage) - } - } -} - -func buildContactString(contact *imessage.Contact) string { - name := contact.Nickname - if name == "" { - name = fmt.Sprintf("%s %s", contact.FirstName, contact.LastName) - } - - contactInfo := fmt.Sprintf("**%s**\n", name) - - if len(contact.Phones) > 0 { - contactInfo += "- **Phones:**\n" - for _, phone := range contact.Phones { - contactInfo += fmt.Sprintf(" - %s\n", phone) - } - } - - if len(contact.Emails) > 0 { - contactInfo += "- **Emails:**\n" - for _, email := range contact.Emails { - contactInfo += fmt.Sprintf(" - %s\n", email) - } - } - - return contactInfo -} - -var cmdRefreshContacts = &commands.FullHandler{ - Func: wrapCommand(fnRefreshContacts), - Name: "refresh-contacts", - Help: commands.HelpMeta{ - Section: HelpSectionManagingPortals, - Description: "Request that the bridge reload cached contacts (only for BlueBubbles mode).", - }, - RequiresPortal: false, - RequiresLogin: false, -} - -func fnRefreshContacts(ce *WrappedCommandEvent) { - ce.Bridge.ZLog.Trace().Interface("args", ce.Args).Str("cmd", ce.Command).Msg("fnSearchContacts") - - err := ce.Bridge.IM.RefreshContactList() - if err != nil { - ce.Reply("Failed to search contacts: %s", err) - } else { - ce.Reply("Contacts List updated!") - } -} diff --git a/config/bridge.go b/config/bridge.go deleted file mode 100644 index 18d5e922..00000000 --- a/config/bridge.go +++ /dev/null @@ -1,255 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - "bytes" - "strconv" - "strings" - "text/template" - - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type DeferredConfig struct { - StartDaysAgo int `yaml:"start_days_ago"` - MaxBatchEvents int `yaml:"max_batch_events"` - BatchDelay int `yaml:"batch_delay"` -} - -type BridgeConfig struct { - User id.UserID `yaml:"user"` - - UsernameTemplate string `yaml:"username_template"` - DisplaynameTemplate string `yaml:"displayname_template"` - - PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"` - - DeliveryReceipts bool `yaml:"delivery_receipts"` - MessageStatusEvents bool `yaml:"message_status_events"` - SendErrorNotices bool `yaml:"send_error_notices"` - - MaxHandleSeconds int `yaml:"max_handle_seconds"` - DeviceID string `yaml:"device_id"` - - SyncDirectChatList bool `yaml:"sync_direct_chat_list"` - LoginSharedSecret string `yaml:"login_shared_secret"` - DoublePuppetServerURL string `yaml:"double_puppet_server_url"` - Backfill struct { - Enable bool `yaml:"enable"` - OnlyBackfill bool `yaml:"only_backfill"` - InitialLimit int `yaml:"initial_limit"` - InitialSyncMaxAge float64 `yaml:"initial_sync_max_age"` - UnreadHoursThreshold int `yaml:"unread_hours_threshold"` - Immediate struct { - MaxEvents int `yaml:"max_events"` - } `yaml:"immediate"` - - Deferred []DeferredConfig `yaml:"deferred"` - } `yaml:"backfill"` - PeriodicSync bool `yaml:"periodic_sync"` - FindPortalsIfEmpty bool `yaml:"find_portals_if_db_empty"` - MediaViewer struct { - URL string `yaml:"url"` - Homeserver string `yaml:"homeserver"` - SMSMinSize int `yaml:"sms_min_size"` - IMMinSize int `yaml:"imessage_min_size"` - Template string `yaml:"template"` - } `yaml:"media_viewer"` - ConvertHEIF bool `yaml:"convert_heif"` - ConvertTIFF bool `yaml:"convert_tiff"` - ConvertVideo struct { - Enabled bool `yaml:"enabled"` - FFMPEGArgs []string `yaml:"ffmpeg_args"` - MimeType string `yaml:"mime_type"` - Extension string `yaml:"extension"` - } `yaml:"convert_video"` - CommandPrefix string `yaml:"command_prefix"` - ForceUniformDMSenders bool `yaml:"force_uniform_dm_senders"` - DisableSMSPortals bool `yaml:"disable_sms_portals"` - RerouteSMSGroupReplies bool `yaml:"reroute_mms_group_replies"` - FederateRooms bool `yaml:"federate_rooms"` - CaptionInMessage bool `yaml:"caption_in_message"` - PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"` - - Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"` - - Relay RelayConfig `yaml:"relay"` - - usernameTemplate *template.Template `yaml:"-"` - displaynameTemplate *template.Template `yaml:"-"` - communityTemplate *template.Template `yaml:"-"` -} - -func (bc BridgeConfig) GetResendBridgeInfo() bool { - return false -} - -func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts { - return bridgeconfig.ManagementRoomTexts{ - Welcome: "Hello, I'm an iMessage bridge bot.", - WelcomeConnected: "Use `help` for help.", - WelcomeUnconnected: "", - AdditionalHelp: "", - } -} - -func (bc BridgeConfig) Validate() error { - return nil -} - -func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig { - return bc.Encryption -} - -func (bc BridgeConfig) EnableMessageStatusEvents() bool { - return bc.MessageStatusEvents -} - -func (bc BridgeConfig) EnableMessageErrorNotices() bool { - return bc.SendErrorNotices -} - -func (bc BridgeConfig) GetCommandPrefix() string { - return bc.CommandPrefix -} - -type umBridgeConfig BridgeConfig - -func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - err := unmarshal((*umBridgeConfig)(bc)) - if err != nil { - return err - } - - bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate) - if err != nil { - return err - } - - bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate) - if err != nil { - return err - } - - return nil -} - -type UsernameTemplateArgs struct { - UserID id.UserID -} - -func (bc BridgeConfig) FormatDisplayname(name string) string { - var buf strings.Builder - bc.displaynameTemplate.Execute(&buf, name) - return buf.String() -} - -func (bc BridgeConfig) FormatUsername(username string) string { - if strings.HasPrefix(username, "+") { - if _, err := strconv.Atoi(username[1:]); err == nil { - username = username[1:] - } - } else { - username = id.EncodeUserLocalpart(username) - } - var buf bytes.Buffer - bc.usernameTemplate.Execute(&buf, username) - return buf.String() -} - -type RelayConfig struct { - Enabled bool `yaml:"enabled"` - Whitelist []string `yaml:"whitelist"` - MessageFormats map[event.MessageType]string `yaml:"message_formats"` - - messageTemplates *template.Template `yaml:"-"` - whitelistMap map[string]struct{} `yaml:"-"` - isAllWhitelisted bool `yaml:"-"` -} - -type umRelayConfig RelayConfig - -func (rc *RelayConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - err := unmarshal((*umRelayConfig)(rc)) - if err != nil { - return err - } - - rc.messageTemplates = template.New("messageTemplates") - for key, format := range rc.MessageFormats { - _, err = rc.messageTemplates.New(string(key)).Parse(format) - if err != nil { - return err - } - } - - rc.whitelistMap = make(map[string]struct{}, len(rc.Whitelist)) - for _, item := range rc.Whitelist { - rc.whitelistMap[item] = struct{}{} - if item == "*" { - rc.isAllWhitelisted = true - } - } - - return nil -} - -func (rc *RelayConfig) IsWhitelisted(userID id.UserID) bool { - if !rc.Enabled { - return false - } else if rc.isAllWhitelisted { - return true - } else if _, ok := rc.whitelistMap[string(userID)]; ok { - return true - } else { - _, homeserver, _ := userID.Parse() - _, ok = rc.whitelistMap[homeserver] - return len(homeserver) > 0 && ok - } -} - -type Sender struct { - UserID string - event.MemberEventContent -} - -type formatData struct { - Sender Sender - Message string - FileName string - Content *event.MessageEventContent -} - -func (rc *RelayConfig) FormatMessage(content *event.MessageEventContent, sender id.UserID, member event.MemberEventContent) (string, error) { - if len(member.Displayname) == 0 { - member.Displayname = sender.String() - } - var formatted strings.Builder - err := rc.messageTemplates.ExecuteTemplate(&formatted, string(content.MsgType), formatData{ - Sender: Sender{ - UserID: sender.String(), - MemberEventContent: member, - }, - Content: content, - Message: content.Body, - FileName: content.Body, - }) - return formatted.String(), err -} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 10c09276..00000000 --- a/config/config.go +++ /dev/null @@ -1,46 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - "maunium.net/go/mautrix/bridge/bridgeconfig" - - "go.mau.fi/mautrix-imessage/imessage" -) - -type SegmentConfig struct { - Key string `yaml:"key"` - UserID string `yaml:"user_id"` -} - -type Config struct { - *bridgeconfig.BaseConfig `yaml:",inline"` - - IMessage imessage.PlatformConfig `yaml:"imessage"` - Segment SegmentConfig `yaml:"segment"` - Bridge BridgeConfig `yaml:"bridge"` - - HackyStartupTest struct { - Identifier string `yaml:"identifier"` - Message string `yaml:"message"` - ResponseMessage string `yaml:"response_message"` - Key string `yaml:"key"` - EchoMode bool `yaml:"echo_mode"` - SendOnStartup bool `yaml:"send_on_startup"` - PeriodicResolve int `yaml:"periodic_resolve"` - } `yaml:"hacky_startup_test"` -} diff --git a/config/download.go b/config/download.go deleted file mode 100644 index 5fcea25d..00000000 --- a/config/download.go +++ /dev/null @@ -1,127 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "time" - - "maunium.net/go/mautrix" - - "go.mau.fi/mautrix-imessage/ipc" -) - -func sanitize(secret string) string { - parsedURL, _ := url.Parse(secret) - parsedURL.User = nil - parsedURL.RawQuery = "" - return parsedURL.String() -} - -type configRedirect struct { - URL string `json:"url"` - Redirected bool `json:"redirected"` -} - -func output(url string, redirected bool) error { - raw, err := json.Marshal(&ipc.OutgoingMessage{ - Command: "config_url", - Data: &configRedirect{ - URL: url, - Redirected: redirected, - }, - }) - if err != nil { - return err - } - fmt.Printf("%s\n", raw) - return nil -} - -func getRedirect(configURL string) (string, error) { - client := &http.Client{ - Timeout: 30 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - req, err := http.NewRequest(http.MethodHead, configURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", mautrix.DefaultUserAgent) - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to request HEAD %s: %w", sanitize(configURL), err) - } - if resp.StatusCode == http.StatusMovedPermanently || resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther || resp.StatusCode == http.StatusTemporaryRedirect || resp.StatusCode == http.StatusPermanentRedirect { - var targetURL *url.URL - targetURL, err = resp.Location() - if err != nil { - return "", fmt.Errorf("failed to get redirect target location: %w", err) - } - configURL = targetURL.String() - err = output(configURL, true) - } else if resp.StatusCode >= 400 { - return "", fmt.Errorf("HEAD %s returned HTTP %d", sanitize(configURL), resp.StatusCode) - } else { - err = output(configURL, false) - } - if err != nil { - return "", fmt.Errorf("failed to output new config URL: %w", err) - } - return configURL, nil -} - -func Download(configURL, saveTo string, outputRedirect bool) error { - client := &http.Client{ - Timeout: 30 * time.Second, - } - if outputRedirect { - var err error - configURL, err = getRedirect(configURL) - if err != nil { - return fmt.Errorf("failed to check config redirect: %w", err) - } - } - req, err := http.NewRequest(http.MethodGet, configURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", mautrix.DefaultUserAgent) - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to request GET %s: %w", sanitize(configURL), err) - } else if resp.StatusCode >= 400 { - return fmt.Errorf("GET %s returned HTTP %d", sanitize(configURL), resp.StatusCode) - } - defer resp.Body.Close() - file, err := os.OpenFile(saveTo, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - return fmt.Errorf("failed to open %s for writing config: %w", saveTo, err) - } - _, err = io.Copy(file, resp.Body) - if err != nil { - return fmt.Errorf("failed to write config to %s: %w", saveTo, err) - } - return nil -} diff --git a/config/upgrade.go b/config/upgrade.go deleted file mode 100644 index 21e8593d..00000000 --- a/config/upgrade.go +++ /dev/null @@ -1,172 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - up "go.mau.fi/util/configupgrade" - "maunium.net/go/mautrix/bridge/bridgeconfig" -) - -func DoUpgrade(helper *up.Helper) { - if legacyDB, ok := helper.Get(up.Str, "appservice", "database"); ok { - helper.Set(up.Str, legacyDB, "appservice", "database", "uri") - } - bridgeconfig.Upgrader.DoUpgrade(helper) - - helper.Copy(up.Int, "revision") - - helper.Copy(up.Str, "imessage", "platform") - helper.Copy(up.Str, "imessage", "imessage_rest_path") - helper.Copy(up.List, "imessage", "imessage_rest_args") - helper.Copy(up.Str, "imessage", "contacts_mode") - helper.Copy(up.Bool, "imessage", "log_ipc_payloads") - helper.Copy(up.Str|up.Null, "imessage", "hacky_set_locale") - helper.Copy(up.List, "imessage", "environment") - helper.Copy(up.Str, "imessage", "unix_socket") - helper.Copy(up.Int, "imessage", "ping_interval_seconds") - helper.Copy(up.Bool, "imessage", "delete_media_after_upload") - helper.Copy(up.Str|up.Null, "imessage", "bluebubbles_url") - helper.Copy(up.Str|up.Null, "imessage", "bluebubbles_password") - - helper.Copy(up.Str|up.Null, "segment", "key") - helper.Copy(up.Str|up.Null, "segment", "user_id") - helper.Copy(up.Int|up.Str|up.Null, "hacky_startup_test", "identifier") - helper.Copy(up.Str|up.Null, "hacky_startup_test", "message") - helper.Copy(up.Str|up.Null, "hacky_startup_test", "response_message") - helper.Copy(up.Str|up.Null, "hacky_startup_test", "key") - helper.Copy(up.Bool, "hacky_startup_test", "echo_mode") - helper.Copy(up.Bool, "hacky_startup_test", "send_on_startup") - helper.Copy(up.Int, "hacky_startup_test", "periodic_resolve") - - helper.Copy(up.Str, "bridge", "user") - helper.Copy(up.Str, "bridge", "username_template") - helper.Copy(up.Str, "bridge", "displayname_template") - helper.Copy(up.Bool, "bridge", "personal_filtering_spaces") - - helper.Copy(up.Bool, "bridge", "delivery_receipts") - if legacyStatusEvents, ok := helper.Get(up.Bool, "bridge", "send_message_send_status_events"); ok && legacyStatusEvents != "" { - helper.Set(up.Bool, legacyStatusEvents, "bridge", "message_status_events") - } else { - helper.Copy(up.Bool, "bridge", "message_status_events") - } - helper.Copy(up.Bool, "bridge", "send_error_notices") - helper.Copy(up.Int, "bridge", "max_handle_seconds") - helper.Copy(up.Str|up.Null, "bridge", "device_id") - helper.Copy(up.Bool, "bridge", "sync_direct_chat_list") - helper.Copy(up.Str|up.Null, "bridge", "login_shared_secret") - helper.Copy(up.Str|up.Null, "bridge", "double_puppet_server_url") - if legacyBackfillLimit, ok := helper.Get(up.Int, "bridge", "initial_backfill_limit"); ok { - helper.Set(up.Int, legacyBackfillLimit, "bridge", "backfill", "initial_limit") - } else { - helper.Copy(up.Int, "bridge", "backfill", "initial_limit") - } - if legacySyncMaxAge, ok := helper.Get(up.Float|up.Int, "bridge", "chat_sync_max_age"); ok { - helper.Set(up.Float, legacySyncMaxAge, "bridge", "backfill", "initial_sync_max_age") - } else { - helper.Copy(up.Float|up.Int, "bridge", "backfill", "initial_sync_max_age") - } - helper.Copy(up.Bool, "bridge", "backfill", "enable") - helper.Copy(up.Int, "bridge", "backfill", "unread_hours_threshold") - helper.Copy(up.Bool, "bridge", "backfill", "only_backfill") - helper.Copy(up.Int, "bridge", "backfill", "immediate", "max_events") - helper.Copy(up.List, "bridge", "backfill", "deferred") - helper.Copy(up.Bool, "bridge", "periodic_sync") - helper.Copy(up.Bool, "bridge", "find_portals_if_db_empty") - if legacyMediaViewerURL, ok := helper.Get(up.Str, "bridge", "media_viewer_url"); ok && legacyMediaViewerURL != "" { - helper.Set(up.Str, legacyMediaViewerURL, "bridge", "media_viewer", "url") - - if legacyMinSize, ok := helper.Get(up.Int, "bridge", "media_viewer_min_size"); ok { - helper.Set(up.Int, legacyMinSize, "bridge", "media_viewer", "sms_min_size") - } else if legacyMinSize, ok = helper.Get(up.Int, "bridge", "media_viewer_sms_min_size"); ok { - helper.Set(up.Int, legacyMinSize, "bridge", "media_viewer", "sms_min_size") - } - if imessageMinSize, ok := helper.Get(up.Int, "bridge", "media_viewer_imessage_min_size"); ok { - helper.Set(up.Int, imessageMinSize, "bridge", "media_viewer", "imessage_min_size") - } - if template, ok := helper.Get(up.Str, "bridge", "media_viewer_template"); ok { - helper.Set(up.Str, template, "bridge", "media_viewer", "template") - } - } else { - helper.Copy(up.Str|up.Null, "bridge", "media_viewer", "url") - helper.Copy(up.Str|up.Null, "bridge", "media_viewer", "homeserver") - helper.Copy(up.Int, "bridge", "media_viewer", "sms_min_size") - helper.Copy(up.Int, "bridge", "media_viewer", "imessage_min_size") - helper.Copy(up.Str, "bridge", "media_viewer", "template") - } - helper.Copy(up.Bool, "bridge", "convert_heif") - helper.Copy(up.Bool, "bridge", "convert_tiff") - helper.Copy(up.Bool, "bridge", "convert_video", "enabled") - helper.Copy(up.List, "bridge", "convert_video", "ffmpeg_args") - helper.Copy(up.Str, "bridge", "convert_video", "extension") - helper.Copy(up.Str, "bridge", "convert_video", "mime_type") - helper.Copy(up.Str, "bridge", "command_prefix") - helper.Copy(up.Bool, "bridge", "force_uniform_dm_senders") - helper.Copy(up.Bool, "bridge", "disable_sms_portals") - helper.Copy(up.Bool, "bridge", "federate_rooms") - helper.Copy(up.Bool, "bridge", "caption_in_message") - helper.Copy(up.Str, "bridge", "private_chat_portal_meta") - - helper.Copy(up.Bool, "bridge", "encryption", "allow") - helper.Copy(up.Bool, "bridge", "encryption", "default") - helper.Copy(up.Bool, "bridge", "encryption", "appservice") - helper.Copy(up.Bool, "bridge", "encryption", "require") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete") - helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired") - helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive") - helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send") - helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share") - - legacyKeyShareAllow, ok := helper.Get(up.Bool, "bridge", "encryption", "key_sharing", "allow") - if ok { - helper.Set(up.Bool, legacyKeyShareAllow, "bridge", "encryption", "allow_key_sharing") - legacyKeyShareRequireCS, legacyOK1 := helper.Get(up.Bool, "bridge", "encryption", "key_sharing", "require_cross_signing") - legacyKeyShareRequireVerification, legacyOK2 := helper.Get(up.Bool, "bridge", "encryption", "key_sharing", "require_verification") - if legacyOK1 && legacyOK2 && legacyKeyShareRequireVerification == "false" && legacyKeyShareRequireCS == "false" { - helper.Set(up.Str, "unverified", "bridge", "encryption", "verification_levels", "share") - } - } else { - helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing") - } - - helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom") - helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds") - helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages") - helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation") - helper.Copy(up.Bool, "bridge", "relay", "enabled") - helper.Copy(up.List, "bridge", "relay", "whitelist") - helper.Copy(up.Map, "bridge", "relay", "message_formats") -} - -var SpacedBlocks = [][]string{ - {"homeserver", "software"}, - {"appservice"}, - {"appservice", "id"}, - {"appservice", "as_token"}, - {"imessage"}, - {"bridge"}, - {"bridge", "username_template"}, - {"bridge", "delivery_receipts"}, - {"bridge", "encryption"}, - {"bridge", "relay"}, - {"logging"}, - {"revision"}, -} diff --git a/connecttest.go b/connecttest.go deleted file mode 100644 index 8039c4cf..00000000 --- a/connecttest.go +++ /dev/null @@ -1,330 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2023 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "crypto/hmac" - cryptoRand "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "math/rand" - "time" - - "maunium.net/go/mautrix/crypto/utils" - - "go.mau.fi/mautrix-imessage/database" - "go.mau.fi/mautrix-imessage/imessage" -) - -func encryptTestPayload(keyStr string, payload map[string]any) (map[string]any, error) { - key, err := base64.RawStdEncoding.DecodeString(keyStr) - if err != nil { - return nil, fmt.Errorf("failed to decode key: %w", err) - } - aesKey, hmacKey := utils.DeriveKeysSHA256(key, "meow") - data, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal payload: %w", err) - } - iv := make([]byte, utils.AESCTRIVLength) - _, err = cryptoRand.Read(iv) - if err != nil { - return nil, fmt.Errorf("failed to generate iv: %w", err) - } - data = utils.XorA256CTR(data, aesKey, *(*[utils.AESCTRIVLength]byte)(iv)) - - h := hmac.New(sha256.New, hmacKey[:]) - h.Write(data) - h.Write(iv) - return map[string]any{ - "ciphertext": base64.RawStdEncoding.EncodeToString(data), - "checksum": base64.RawStdEncoding.EncodeToString(h.Sum(nil)), - "iv": base64.RawStdEncoding.EncodeToString(iv), - }, nil -} - -func decryptTestPayload(keyStr string, encrypted map[string]any) (map[string]any, error) { - key, err := base64.RawStdEncoding.DecodeString(keyStr) - if err != nil { - return nil, fmt.Errorf("failed to decode key: %w", err) - } - var ciphertext, checksum, iv []byte - getKey := func(key string) ([]byte, error) { - val, ok := encrypted[key].(string) - if !ok { - return nil, fmt.Errorf("missing %s in encrypted payload", key) - } - decoded, err := base64.RawStdEncoding.DecodeString(val) - if err != nil { - return nil, fmt.Errorf("failed to decode %s: %w", key, err) - } - return decoded, nil - } - if ciphertext, err = getKey("ciphertext"); err != nil { - return nil, err - } else if checksum, err = getKey("checksum"); err != nil { - return nil, err - } else if iv, err = getKey("iv"); err != nil { - return nil, err - } else if len(iv) != utils.AESCTRIVLength { - return nil, fmt.Errorf("incorrect iv length %d", len(iv)) - } - aesKey, hmacKey := utils.DeriveKeysSHA256(key, "meow") - h := hmac.New(sha256.New, hmacKey[:]) - h.Write(ciphertext) - h.Write(iv) - if !hmac.Equal(h.Sum(nil), checksum) { - return nil, fmt.Errorf("mismatching hmac") - } - var payload map[string]any - err = json.Unmarshal(utils.XorA256CTR(ciphertext, aesKey, *(*[utils.AESCTRIVLength]byte)(iv)), &payload) - return payload, err -} - -const startupTestKey = "com.beeper.startup_test" -const startupTestIDKey = "com.beeper.startup_test_id" -const startupTestResponseKey = "com.beeper.startup_test_response" - -const hackyTestSegmentEvent = "iMC startup test" -const hackyTestLogAction = "hacky startup test" - -func trackStartupTestError(erroredAt, randomID string) { - Segment.Track(hackyTestSegmentEvent, map[string]any{ - "event": "fail", - "error": erroredAt, - "random_id": randomID, - }) -} - -func (br *IMBridge) hackyTestLoop() { - log := br.ZLog.With(). - Str("identifier", br.Config.HackyStartupTest.Identifier). - Str("action", "hacky periodic test"). - Logger() - log.Info(). - Int("interval", br.Config.HackyStartupTest.PeriodicResolve). - Msg("Starting hacky periodic test loop") - for { - time.Sleep(time.Duration(br.Config.HackyStartupTest.PeriodicResolve) * time.Second) - log.Debug().Msg("Sending hacky periodic test") - resp, err := br.WebsocketHandler.StartChat(StartDMRequest{ - Identifier: br.Config.HackyStartupTest.Identifier, - }) - if err != nil { - log.Error().Err(err).Msg("Failed to resolve identifier") - } else { - log.Info().Interface("response", resp).Msg("Successfully resolved identifier") - } - - } -} - -func (br *IMBridge) hackyStartupTests(sleep, forceSend bool) { - if sleep { - time.Sleep(time.Duration(rand.Intn(120)+60) * time.Second) - } - br.DB.KV.Set(database.KVBridgeWasConnected, "true") - randomID := br.Bot.TxnID() - log := br.ZLog.With(). - Str("identifier", br.Config.HackyStartupTest.Identifier). - Str("random_id", randomID). - Str("action", hackyTestLogAction). - Logger() - log.Info().Msg("Running hacky startup test") - actuallyStart := br.Config.HackyStartupTest.Message != "" && (br.Config.HackyStartupTest.SendOnStartup || forceSend) - resp, err := br.WebsocketHandler.StartChat(StartDMRequest{ - Identifier: br.Config.HackyStartupTest.Identifier, - ActuallyStart: actuallyStart, - }) - if err != nil { - log.Error().Err(err).Msg("Failed to resolve identifier") - trackStartupTestError("start chat", randomID) - return - } - log.Info().Interface("response", resp).Msg("Successfully resolved identifier") - if !actuallyStart { - return - } - portal := br.GetPortalByGUID(resp.GUID) - - payload, err := encryptTestPayload(br.Config.HackyStartupTest.Key, map[string]any{ - "segment_user_id": Segment.userID, - "user_id": br.user.MXID.String(), - "random_id": randomID, - }) - if err != nil { - trackStartupTestError("encrypt payload", randomID) - log.Error().Err(err).Msg("Failed to encrypt ping payload") - return - } - metadata := map[string]any{ - startupTestKey: payload, - startupTestIDKey: randomID, - } - sendResp, err := br.IM.SendMessage(portal.getTargetGUID("text message", "startup test", ""), br.Config.HackyStartupTest.Message, "", 0, nil, metadata) - if err != nil { - log.Error().Err(err).Msg("Failed to send message") - trackStartupTestError("send message", randomID) - return - } - br.pendingHackyTestGUID = sendResp.GUID - br.pendingHackyTestRandomID = randomID - log.Info().Str("msg_guid", sendResp.GUID).Msg("Successfully sent message") - Segment.Track(hackyTestSegmentEvent, map[string]any{ - "event": "message sent", - "random_id": randomID, - "msg_guid": sendResp.GUID, - }) - go func() { - time.Sleep(30 * time.Second) - if !br.hackyTestSuccess { - log.Info().Msg("Hacky test success flag not set, sending timeout event") - Segment.Track(hackyTestSegmentEvent, map[string]any{ - "event": "timeout", - "random_id": randomID, - "msg_guid": sendResp.GUID, - }) - } - }() -} - -func (br *IMBridge) trackStartupTestPingStatus(msgStatus *imessage.SendMessageStatus) { - br.ZLog.Info(). - Interface("status", msgStatus). - Str("random_id", br.pendingHackyTestRandomID). - Str("action", hackyTestLogAction). - Msg("Received message status for hacky test") - meta := map[string]any{ - "msg_guid": msgStatus.GUID, - "random_id": br.pendingHackyTestRandomID, - } - switch msgStatus.Status { - case "delivered": - meta["event"] = "ping delivered" - case "sent": - meta["event"] = "ping sent" - case "failed": - meta["event"] = "ping failed" - meta["error"] = msgStatus.Message - default: - return - } - Segment.Track(hackyTestSegmentEvent, meta) -} - -func (br *IMBridge) receiveStartupTestPing(msg *imessage.Message) { - unencryptedID, ok := msg.Metadata[startupTestIDKey].(string) - if !ok { - return - } - encryptedPayload, ok := msg.Metadata[startupTestKey].(map[string]any) - if !ok { - return - } - log := br.ZLog.With(). - Str("action", hackyTestLogAction). - Str("random_id", unencryptedID). - Str("msg_guid", msg.GUID). - Str("chat_guid", msg.ChatGUID). - Str("sender_guid", msg.Sender.String()). - Logger() - log.Info().Msg("Received startup test ping") - payload, err := decryptTestPayload(br.Config.HackyStartupTest.Key, encryptedPayload) - if err != nil { - Segment.Track(hackyTestSegmentEvent, map[string]any{ - "event": "fail", - "error": "ping decrypt", - "random_id": unencryptedID, - "msg_guid": msg.GUID, - }) - return - } - userID, _ := payload["user_id"].(string) - segmentUserID, _ := payload["segment_user_id"].(string) - log = log.With(). - Str("matrix_user_id", userID). - Str("segment_user_id", segmentUserID). - Logger() - log.Info().Msg("Decrypted startup test ping") - randomID, ok := payload["random_id"].(string) - if randomID != unencryptedID { - log.Warn().Str("encrypted_random_id", randomID).Msg("Mismatching random ID in encrypted payload") - Segment.TrackUser(hackyTestSegmentEvent, segmentUserID, map[string]any{ - "event": "fail", - "error": "mismatching random ID", - "random_id": randomID, - "msg_guid": msg.GUID, - }) - return - } - Segment.TrackUser(hackyTestSegmentEvent, segmentUserID, map[string]any{ - "event": "ping received", - "random_id": unencryptedID, - "msg_guid": msg.GUID, - }) - time.Sleep(2 * time.Second) - resp, err := br.IM.SendMessage(msg.ChatGUID, br.Config.HackyStartupTest.ResponseMessage, msg.GUID, 0, nil, map[string]any{ - startupTestResponseKey: map[string]any{ - "random_id": randomID, - }, - }) - if err != nil { - log.Error().Err(err).Msg("Failed to send pong") - Segment.TrackUser(hackyTestSegmentEvent, segmentUserID, map[string]any{ - "event": "fail", - "error": "pong send", - "random_id": unencryptedID, - "msg_guid": msg.GUID, - }) - return - } - log.Info().Str("pong_msg_guid", resp.GUID).Msg("Sent pong") - Segment.TrackUser(hackyTestSegmentEvent, segmentUserID, map[string]any{ - "event": "pong sent", - "random_id": unencryptedID, - "msg_guid": msg.GUID, - "pong_guid": resp.GUID, - }) -} - -func (br *IMBridge) receiveStartupTestPong(resp map[string]any, msg *imessage.Message) { - randomID, _ := resp["random_id"].(string) - br.ZLog.Info(). - Str("action", hackyTestLogAction). - Str("random_id", randomID). - Str("msg_guid", msg.GUID). - Str("reply_to_guid", msg.ReplyToGUID). - Msg("Startup test successful") - Segment.Track(hackyTestSegmentEvent, map[string]any{ - "event": "success", - "random_id": randomID, - "pong_guid": msg.GUID, - "msg_guid": msg.ReplyToGUID, - }) - if br.pendingHackyTestRandomID == randomID { - br.hackyTestSuccess = true - br.pendingHackyTestGUID = "" - br.pendingHackyTestRandomID = "" - } else { - br.ZLog.Info(). - Str("pending_random_id", br.pendingHackyTestRandomID). - Str("pending_msg_guid", br.pendingHackyTestGUID). - Msg("Pending test ID isn't same as received ID") - } -} diff --git a/custompuppet.go b/custompuppet.go deleted file mode 100644 index 8ea2593d..00000000 --- a/custompuppet.go +++ /dev/null @@ -1,156 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "crypto/hmac" - "crypto/sha512" - "encoding/hex" - "errors" - "fmt" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/id" -) - -var ( - ErrMismatchingMXID = errors.New("whoami result does not match custom mxid") -) - -var _ bridge.DoublePuppet = (*User)(nil) - -func (user *User) SwitchCustomMXID(accessToken string, mxid id.UserID) error { - if mxid != user.MXID { - return errors.New("mismatching mxid") - } - user.AccessToken = accessToken - return user.startCustomMXID() -} - -func (user *User) CustomIntent() *appservice.IntentAPI { - return user.DoublePuppetIntent -} - -func (user *User) initDoublePuppet() { - var err error - if len(user.AccessToken) > 0 { - err = user.startCustomMXID() - if errors.Is(err, mautrix.MUnknownToken) && len(user.bridge.Config.Bridge.LoginSharedSecret) > 0 { - user.log.Debugln("Unknown token while starting custom puppet, trying to relogin with shared secret") - err = user.loginWithSharedSecret() - if err == nil { - err = user.startCustomMXID() - } - } - } else { - err = user.loginWithSharedSecret() - if err == nil { - err = user.startCustomMXID() - } - } - if err != nil { - user.log.Warnln("Failed to switch to auto-logined custom puppet:", err) - } else { - user.log.Infoln("Successfully automatically enabled custom puppet") - } -} - -func (user *User) loginWithSharedSecret() error { - user.log.Debugfln("Logging in with shared secret") - loginSecret := user.bridge.Config.Bridge.LoginSharedSecret - client, err := user.bridge.AS.NewExternalMautrixClient(user.MXID, "", user.bridge.Config.Bridge.DoublePuppetServerURL) - if err != nil { - return err - } - req := mautrix.ReqLogin{ - Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(user.MXID)}, - DeviceID: id.DeviceID(user.bridge.Config.IMessage.BridgeName()), - InitialDeviceDisplayName: user.bridge.Config.IMessage.BridgeName(), - } - if loginSecret == "appservice" { - client.AccessToken = user.bridge.AS.Registration.AppToken - req.Type = mautrix.AuthTypeAppservice - } else { - mac := hmac.New(sha512.New, []byte(loginSecret)) - mac.Write([]byte(user.MXID)) - req.Password = hex.EncodeToString(mac.Sum(nil)) - req.Type = mautrix.AuthTypePassword - } - resp, err := client.Login(&req) - if err != nil { - return fmt.Errorf("failed to log in with shared secret: %w", err) - } - user.AccessToken = resp.AccessToken - user.Update() - return nil -} - -func (user *User) newDoublePuppetIntent() (*appservice.IntentAPI, error) { - client, err := user.bridge.AS.NewExternalMautrixClient(user.MXID, user.AccessToken, user.bridge.Config.Bridge.DoublePuppetServerURL) - if err != nil { - return nil, err - } - - ia := user.bridge.AS.NewIntentAPI("custom") - ia.Client = client - ia.Localpart, _, _ = user.MXID.Parse() - ia.UserID = user.MXID - ia.IsCustomPuppet = true - return ia, nil -} - -func (user *User) clearCustomMXID() { - user.AccessToken = "" - user.NextBatch = "" - user.DoublePuppetIntent = nil -} - -func (user *User) startCustomMXID() error { - if len(user.AccessToken) == 0 { - user.clearCustomMXID() - return nil - } - intent, err := user.newDoublePuppetIntent() - if err != nil { - user.clearCustomMXID() - return fmt.Errorf("failed to create double puppet intent: %w", err) - } - resp, err := intent.Whoami() - if err != nil { - user.clearCustomMXID() - return fmt.Errorf("failed to ensure double puppet token is valid: %w", err) - } - if resp.UserID != user.MXID { - user.clearCustomMXID() - return ErrMismatchingMXID - } - user.DoublePuppetIntent = intent - return nil -} - -func (user *User) tryRelogin(cause error, action string) bool { - user.log.Debugfln("Trying to relogin after '%v' while %s", cause, action) - err := user.loginWithSharedSecret() - if err != nil { - user.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err) - return false - } - user.log.Infofln("Successfully relogined after '%v' while %s", cause, action) - return true -} diff --git a/database/backfillqueue.go b/database/backfillqueue.go deleted file mode 100644 index 719f2189..00000000 --- a/database/backfillqueue.go +++ /dev/null @@ -1,315 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2023 Tulir Asokan, Sumner Evans -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "context" - "database/sql" - "errors" - "fmt" - "sync" - "time" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type BackfillQuery struct { - db *Database - log log.Logger - - backfillQueryLock sync.Mutex -} - -func (bq *BackfillQuery) New() *Backfill { - return &Backfill{ - db: bq.db, - log: bq.log, - } -} - -func (bq *BackfillQuery) NewWithValues(userID id.UserID, priority int, portalGUID string, timeStart, timeEnd *time.Time, maxBatchEvents, maxTotalEvents, batchDelay int) *Backfill { - return &Backfill{ - db: bq.db, - log: bq.log, - UserID: userID, - Priority: priority, - PortalGUID: portalGUID, - TimeStart: timeStart, - TimeEnd: timeEnd, - MaxBatchEvents: maxBatchEvents, - MaxTotalEvents: maxTotalEvents, - BatchDelay: batchDelay, - } -} - -const ( - getNextBackfillQuery = ` - SELECT queue_id, user_mxid, priority, portal_guid, time_start, time_end, max_batch_events, max_total_events, batch_delay - FROM backfill_queue - WHERE user_mxid=$1 - AND ( - dispatch_time IS NULL - OR ( - dispatch_time < $2 - AND completed_at IS NULL - ) - ) - ORDER BY priority - LIMIT 1 - ` - getIncompleteCountQuery = ` - SELECT COUNT(*) - FROM backfill_queue - WHERE user_mxid=$1 AND completed_at IS NULL - ` - getCountQuery = ` - SELECT COUNT(*) - FROM backfill_queue - WHERE user_mxid=$1 - ` - getUnstartedOrInFlightQuery = ` - SELECT 1 - FROM backfill_queue - WHERE user_mxid=$1 - AND (dispatch_time IS NULL OR completed_at IS NULL) - LIMIT 1 - ` -) - -// GetNext returns the next backfill to perform -func (bq *BackfillQuery) GetNext(userID id.UserID) (backfill *Backfill) { - bq.backfillQueryLock.Lock() - defer bq.backfillQueryLock.Unlock() - - rows, err := bq.db.Query(getNextBackfillQuery, userID, time.Now().Add(-15*time.Minute)) - if err != nil || rows == nil { - bq.log.Errorfln("Failed to query next backfill queue job: %v", err) - return - } - defer rows.Close() - if rows.Next() { - backfill = bq.New().Scan(rows) - } - return -} - -func (bq *BackfillQuery) IncompleteCount(ctx context.Context, userID id.UserID) (incompleteCount int, err error) { - bq.backfillQueryLock.Lock() - defer bq.backfillQueryLock.Unlock() - - row := bq.db.QueryRowContext(ctx, getIncompleteCountQuery, userID) - err = row.Scan(&incompleteCount) - return -} - -func (bq *BackfillQuery) Count(ctx context.Context, userID id.UserID) (count int, err error) { - bq.backfillQueryLock.Lock() - defer bq.backfillQueryLock.Unlock() - - row := bq.db.QueryRowContext(ctx, getCountQuery, userID) - err = row.Scan(&count) - return -} - -func (bq *BackfillQuery) DeleteAll(userID id.UserID) { - bq.backfillQueryLock.Lock() - defer bq.backfillQueryLock.Unlock() - _, err := bq.db.Exec("DELETE FROM backfill_queue WHERE user_mxid=$1", userID) - if err != nil { - bq.log.Warnfln("Failed to delete backfill queue items for %s: %v", userID, err) - } -} - -func (bq *BackfillQuery) DeleteAllForPortal(userID id.UserID, portalGUID string) { - bq.backfillQueryLock.Lock() - defer bq.backfillQueryLock.Unlock() - _, err := bq.db.Exec(` - DELETE FROM backfill_queue - WHERE user_mxid=$1 - AND portal_guid=$2 - `, userID, portalGUID) - if err != nil { - bq.log.Warnfln("Failed to delete backfill queue items for %s/%s: %v", userID, portalGUID, err) - } -} - -type Backfill struct { - db *Database - log log.Logger - - // Fields - QueueID int - UserID id.UserID - Priority int - PortalGUID string - TimeStart *time.Time - TimeEnd *time.Time - MaxBatchEvents int - MaxTotalEvents int - BatchDelay int - DispatchTime *time.Time - CompletedAt *time.Time -} - -func (b *Backfill) String() string { - return fmt.Sprintf("Backfill{QueueID: %d, UserID: %s, Priority: %d, Portal: %s, TimeStart: %s, TimeEnd: %s, MaxBatchEvents: %d, MaxTotalEvents: %d, BatchDelay: %d, DispatchTime: %s, CompletedAt: %s}", - b.QueueID, b.UserID, b.Priority, b.PortalGUID, b.TimeStart, b.TimeEnd, b.MaxBatchEvents, b.MaxTotalEvents, b.BatchDelay, b.CompletedAt, b.DispatchTime, - ) -} - -func (b *Backfill) Scan(row dbutil.Scannable) *Backfill { - var maxTotalEvents, batchDelay sql.NullInt32 - err := row.Scan(&b.QueueID, &b.UserID, &b.Priority, &b.PortalGUID, &b.TimeStart, &b.TimeEnd, &b.MaxBatchEvents, &maxTotalEvents, &batchDelay) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - b.log.Errorln("Database scan failed:", err) - } - return nil - } - b.MaxTotalEvents = int(maxTotalEvents.Int32) - b.BatchDelay = int(batchDelay.Int32) - return b -} - -func (b *Backfill) Insert(txn dbutil.Execable) { - b.db.Backfill.backfillQueryLock.Lock() - defer b.db.Backfill.backfillQueryLock.Unlock() - - if txn == nil { - txn = b.db - } - rows, err := txn.Query(` - INSERT INTO backfill_queue - (user_mxid, priority, portal_guid, time_start, time_end, max_batch_events, max_total_events, batch_delay, dispatch_time, completed_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING queue_id - `, b.UserID, b.Priority, b.PortalGUID, b.TimeStart, b.TimeEnd, b.MaxBatchEvents, b.MaxTotalEvents, b.BatchDelay, b.DispatchTime, b.CompletedAt) - defer rows.Close() - if err != nil || !rows.Next() { - b.log.Warnfln("Failed to insert backfill for %s with priority %d: %v", b.PortalGUID, b.Priority, err) - return - } - err = rows.Scan(&b.QueueID) - if err != nil { - b.log.Warnfln("Failed to insert backfill for %s with priority %d: %v", b.PortalGUID, b.Priority, err) - } -} - -func (b *Backfill) MarkDispatched() { - b.db.Backfill.backfillQueryLock.Lock() - defer b.db.Backfill.backfillQueryLock.Unlock() - - if b.QueueID == 0 { - b.log.Errorfln("Cannot mark backfill as dispatched without queue_id. Maybe it wasn't actually inserted in the database?") - return - } - _, err := b.db.Exec("UPDATE backfill_queue SET dispatch_time=$1 WHERE queue_id=$2", time.Now(), b.QueueID) - if err != nil { - b.log.Warnfln("Failed to mark backfill with priority %d as dispatched: %v", b.Priority, err) - } -} - -func (b *Backfill) MarkDone() { - b.db.Backfill.backfillQueryLock.Lock() - defer b.db.Backfill.backfillQueryLock.Unlock() - - if b.QueueID == 0 { - b.log.Errorfln("Cannot mark backfill done without queue_id. Maybe it wasn't actually inserted in the database?") - return - } - _, err := b.db.Exec("UPDATE backfill_queue SET completed_at=$1 WHERE queue_id=$2", time.Now(), b.QueueID) - if err != nil { - b.log.Warnfln("Failed to mark backfill with priority %d as complete: %v", b.Priority, err) - } -} - -func (bq *BackfillQuery) NewBackfillState(userID id.UserID, portalGUID string) *BackfillState { - return &BackfillState{ - db: bq.db, - log: bq.log, - UserID: userID, - PortalGUID: portalGUID, - } -} - -const ( - getBackfillState = ` - SELECT user_mxid, portal_guid, processing_batch, backfill_complete, first_expected_ts - FROM backfill_state - WHERE user_mxid=$1 - AND portal_guid=$2 - ` -) - -type BackfillState struct { - db *Database - log log.Logger - - // Fields - UserID id.UserID - PortalGUID string - ProcessingBatch bool - BackfillComplete bool - FirstExpectedTimestamp uint64 -} - -func (b *BackfillState) Scan(row dbutil.Scannable) *BackfillState { - err := row.Scan(&b.UserID, &b.PortalGUID, &b.ProcessingBatch, &b.BackfillComplete, &b.FirstExpectedTimestamp) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - b.log.Errorln("Database scan failed:", err) - } - return nil - } - return b -} - -func (b *BackfillState) Upsert() { - _, err := b.db.Exec(` - INSERT INTO backfill_state - (user_mxid, portal_guid, processing_batch, backfill_complete, first_expected_ts) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_mxid, portal_guid) - DO UPDATE SET - processing_batch=EXCLUDED.processing_batch, - backfill_complete=EXCLUDED.backfill_complete, - first_expected_ts=EXCLUDED.first_expected_ts`, - b.UserID, b.PortalGUID, b.ProcessingBatch, b.BackfillComplete, b.FirstExpectedTimestamp) - if err != nil { - b.log.Warnfln("Failed to insert backfill state for %s: %v", b.PortalGUID, err) - } -} - -func (b *BackfillState) SetProcessingBatch(processing bool) { - b.ProcessingBatch = processing - b.Upsert() -} - -func (bq *BackfillQuery) GetBackfillState(userID id.UserID, portalGUID string) (backfillState *BackfillState) { - rows, err := bq.db.Query(getBackfillState, userID, portalGUID) - if err != nil || rows == nil { - bq.log.Error(err) - return - } - defer rows.Close() - if rows.Next() { - backfillState = bq.NewBackfillState(userID, portalGUID).Scan(rows) - } - return -} diff --git a/database/database.go b/database/database.go deleted file mode 100644 index 4ca1db05..00000000 --- a/database/database.go +++ /dev/null @@ -1,80 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - _ "github.com/mattn/go-sqlite3" - "maunium.net/go/maulogger/v2" - - "go.mau.fi/util/dbutil" - - "go.mau.fi/mautrix-imessage/database/upgrades" -) - -type Database struct { - *dbutil.Database - - User *UserQuery - Portal *PortalQuery - Puppet *PuppetQuery - Message *MessageQuery - Tapback *TapbackQuery - KV *KeyValueQuery - MergedChat *MergedChatQuery - Backfill *BackfillQuery -} - -func New(parent *dbutil.Database, log maulogger.Logger) *Database { - db := &Database{ - Database: parent, - } - db.UpgradeTable = upgrades.Table - - db.User = &UserQuery{ - db: db, - log: log.Sub("User"), - } - db.Portal = &PortalQuery{ - db: db, - log: log.Sub("Portal"), - } - db.Puppet = &PuppetQuery{ - db: db, - log: log.Sub("Puppet"), - } - db.Message = &MessageQuery{ - db: db, - log: log.Sub("Message"), - } - db.Tapback = &TapbackQuery{ - db: db, - log: log.Sub("Tapback"), - } - db.KV = &KeyValueQuery{ - db: db, - log: log.Sub("KeyValue"), - } - db.MergedChat = &MergedChatQuery{ - db: db, - log: log.Sub("MergedChat"), - } - db.Backfill = &BackfillQuery{ - db: db, - log: log.Sub("Backfill"), - } - return db -} diff --git a/database/kvstore.go b/database/kvstore.go deleted file mode 100644 index 246723ae..00000000 --- a/database/kvstore.go +++ /dev/null @@ -1,65 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "database/sql" - "errors" - "fmt" - - log "maunium.net/go/maulogger/v2" -) - -type KeyValueQuery struct { - db *Database - log log.Logger -} - -const ( - KVSendStatusStart = "com.beeper.send_status_start" - KVBridgeWasConnected = "bridge_was_connected" - KVBridgeFirstConnect = "bridge_first_connect" - KVBridgeInfoVersion = "bridge_info_version" - KVLookedForPortals = "looked_for_portals" - - ExpectedBridgeInfoVersion = "1" -) - -func (kvq *KeyValueQuery) Get(key string) (value string) { - err := kvq.db.QueryRow("SELECT value FROM kv_store WHERE key=$1", key).Scan(&value) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - panic(fmt.Errorf("failed to scan value for %s: %w", key, err)) - } - return -} - -func (kvq *KeyValueQuery) Set(key, value string) { - _, err := kvq.db.Exec(` - INSERT INTO kv_store (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value=excluded.value - `, key, value) - if err != nil { - panic(fmt.Errorf("failed to insert %s: %w", key, err)) - } -} - -func (kvq *KeyValueQuery) Delete(key string) { - _, err := kvq.db.Exec("DELETE FROM kv_store WHERE key=$1", key) - if err != nil { - panic(fmt.Errorf("failed to delete %s: %w", key, err)) - } -} diff --git a/database/mergedchat.go b/database/mergedchat.go deleted file mode 100644 index 8d3ecfc6..00000000 --- a/database/mergedchat.go +++ /dev/null @@ -1,82 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "database/sql" - "errors" - "fmt" - "strings" - - "go.mau.fi/util/dbutil" - log "maunium.net/go/maulogger/v2" -) - -type MergedChatQuery struct { - db *Database - log log.Logger -} - -func (mcq *MergedChatQuery) Set(txn dbutil.Execable, target string, sources ...string) { - if txn == nil { - txn = mcq.db - } - placeholders := make([]string, len(sources)) - args := make([]any, len(sources)+1) - args[0] = target - for i, source := range sources { - args[i+1] = source - placeholders[i] = fmt.Sprintf("(?1, ?%d)", i+2) - } - _, err := txn.Exec(fmt.Sprintf("INSERT OR REPLACE INTO merged_chat (target_guid, source_guid) VALUES %s", strings.Join(placeholders, ", ")), args...) - if err != nil { - mcq.log.Warnfln("Failed to insert %s->%s: %v", sources, target, err) - } -} - -func (mcq *MergedChatQuery) Remove(guid string) { - _, err := mcq.db.Exec("DELETE FROM merged_chat WHERE source_guid=$1", guid) - if err != nil { - mcq.log.Warnfln("Failed to remove %s: %v", guid, err) - } -} - -func (mcq *MergedChatQuery) GetAllForTarget(guid string) (sources []string) { - rows, err := mcq.db.Query("SELECT source_guid FROM merged_chat WHERE target_guid=$1", guid) - if err != nil { - mcq.log.Errorfln("Failed to get merge sources for %s: %v", guid, err) - return - } - for rows.Next() { - var source string - err = rows.Scan(&source) - if err != nil { - mcq.log.Errorfln("Failed to scan merge source: %v", err) - } else { - sources = append(sources, source) - } - } - return -} - -func (mcq *MergedChatQuery) Get(guid string) (target string) { - err := mcq.db.QueryRow("SELECT target_guid FROM merged_chat WHERE source_guid=$1", guid).Scan(&target) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - mcq.log.Errorfln("Failed to get merge target for %s: %v", guid, err) - } - return -} diff --git a/database/message.go b/database/message.go deleted file mode 100644 index 81d307b2..00000000 --- a/database/message.go +++ /dev/null @@ -1,209 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "database/sql" - "errors" - "fmt" - "strings" - "time" - - log "maunium.net/go/maulogger/v2" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -type MessageQuery struct { - db *Database - log log.Logger -} - -func (mq *MessageQuery) New() *Message { - return &Message{ - db: mq.db, - log: mq.log, - } -} - -func (mq *MessageQuery) GetIDsSince(chat string, since time.Time) (messages []string) { - rows, err := mq.db.Query("SELECT guid FROM message WHERE portal_guid=$1 AND timestamp>=$2 AND part=0 ORDER BY timestamp ASC", chat, since.Unix()*1000) - if err != nil || rows == nil { - return nil - } - defer rows.Close() - for rows.Next() { - var msgID string - err = rows.Scan(&msgID) - if err != nil { - mq.log.Errorln("Database scan failed:", err) - } else { - messages = append(messages, msgID) - } - } - return -} - -func (mq *MessageQuery) GetLastByGUID(chat string, guid string) *Message { - return mq.get("SELECT portal_guid, guid, part, mxid, sender_guid, handle_guid, timestamp "+ - "FROM message WHERE portal_guid=$1 AND guid=$2 ORDER BY part DESC LIMIT 1", chat, guid) -} - -func (mq *MessageQuery) FindChatByGUID(guid string) (chatGUID string) { - err := mq.db.QueryRow("SELECT portal_guid FROM message WHERE guid=$1", guid).Scan(&chatGUID) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - mq.log.Errorfln("Failed to find chat by GUID: %v", err) - } - return -} - -func (mq *MessageQuery) GetByGUID(chat string, guid string, part int) *Message { - return mq.get("SELECT portal_guid, guid, part, mxid, sender_guid, handle_guid, timestamp "+ - "FROM message WHERE portal_guid=$1 AND guid=$2 AND part=$3", chat, guid, part) -} - -func (mq *MessageQuery) GetByMXID(mxid id.EventID) *Message { - return mq.get("SELECT portal_guid, guid, part, mxid, sender_guid, handle_guid, timestamp "+ - "FROM message WHERE mxid=$1", mxid) -} - -func (mq *MessageQuery) GetLastInChat(chat string) *Message { - msg := mq.get("SELECT portal_guid, guid, part, mxid, sender_guid, handle_guid, timestamp "+ - "FROM message WHERE portal_guid=$1 ORDER BY timestamp DESC LIMIT 1", chat) - if msg == nil || msg.Timestamp == 0 { - // Old db, we don't know what the last message is. - return nil - } - return msg -} - -func (mq *MessageQuery) GetFirstInChat(chat string) *Message { - msg := mq.get("SELECT portal_guid, guid, part, mxid, sender_guid, handle_guid, timestamp "+ - "FROM message WHERE portal_guid=$1 ORDER BY timestamp ASC LIMIT 1", chat) - if msg == nil || msg.Timestamp == 0 { - // Old db, we don't know what the first message is. - return nil - } - return msg -} - -func (mq *MessageQuery) GetEarliestTimestampInChat(chat string) (int64, error) { - row := mq.db.QueryRow("SELECT MIN(timestamp) FROM message WHERE portal_guid=$1", chat) - var timestamp sql.NullInt64 - if err := row.Scan(×tamp); err != nil { - return -1, err - } else if !timestamp.Valid { - return -1, nil - } else { - return timestamp.Int64, nil - } -} - -func (mq *MessageQuery) MergePortalGUID(txn dbutil.Execable, to string, from ...string) int64 { - if txn == nil { - txn = mq.db - } - args := make([]any, len(from)+1) - args[0] = to - for i, fr := range from { - args[i+1] = fr - } - placeholders := strings.TrimSuffix(strings.Repeat("?,", len(from)), ",") - res, err := txn.Exec(fmt.Sprintf("UPDATE message SET portal_guid=? WHERE portal_guid IN (%s)", placeholders), args...) - if err != nil { - mq.log.Errorfln("Failed to update portal GUID for messages (%v -> %s): %v", err, from, to) - return -1 - } else { - affected, err := res.RowsAffected() - if err != nil { - mq.log.Warnfln("Failed to get number of rows affected by merge: %v", err) - } - return affected - } -} - -func (mq *MessageQuery) SplitPortalGUID(txn dbutil.Execable, fromHandle, fromPortal, to string) int64 { - if txn == nil { - txn = mq.db - } - res, err := txn.Exec("UPDATE message SET portal_guid=?1 WHERE portal_guid=?2 AND handle_guid=?3", to, fromPortal, fromHandle) - if err != nil { - mq.log.Errorfln("Failed to split portal GUID for messages (%s in %s -> %s): %v", fromHandle, fromPortal, to, err) - return -1 - } else { - affected, err := res.RowsAffected() - if err != nil { - mq.log.Warnfln("Failed to get number of rows affected by split: %v", err) - } - return affected - } -} - -func (mq *MessageQuery) get(query string, args ...interface{}) *Message { - row := mq.db.QueryRow(query, args...) - if row == nil { - return nil - } - return mq.New().Scan(row) -} - -type Message struct { - db *Database - log log.Logger - - PortalGUID string - GUID string - Part int - MXID id.EventID - SenderGUID string - HandleGUID string - Timestamp int64 -} - -func (msg *Message) Time() time.Time { - return time.UnixMilli(msg.Timestamp) -} - -func (msg *Message) Scan(row dbutil.Scannable) *Message { - err := row.Scan(&msg.PortalGUID, &msg.GUID, &msg.Part, &msg.MXID, &msg.SenderGUID, &msg.HandleGUID, &msg.Timestamp) - if err != nil { - if err != sql.ErrNoRows { - msg.log.Errorln("Database scan failed:", err) - } - return nil - } - return msg -} - -func (msg *Message) Insert(txn dbutil.Execable) { - if txn == nil { - txn = msg.db - } - _, err := txn.Exec("INSERT INTO message (portal_guid, guid, part, mxid, sender_guid, handle_guid, timestamp) VALUES ($1, $2, $3, $4, $5, $6, $7)", - msg.PortalGUID, msg.GUID, msg.Part, msg.MXID, msg.SenderGUID, msg.HandleGUID, msg.Timestamp) - if err != nil { - msg.log.Warnfln("Failed to insert %s.%d@%s: %v", msg.GUID, msg.Part, msg.PortalGUID, err) - } -} - -func (msg *Message) Delete() { - _, err := msg.db.Exec("DELETE FROM message WHERE portal_guid=$1 AND guid=$2", msg.PortalGUID, msg.GUID) - if err != nil { - msg.log.Warnfln("Failed to delete %s.%d@%s: %v", msg.GUID, msg.Part, msg.PortalGUID, err) - } -} diff --git a/database/portal.go b/database/portal.go deleted file mode 100644 index 7a540beb..00000000 --- a/database/portal.go +++ /dev/null @@ -1,197 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "database/sql" - "fmt" - - log "maunium.net/go/maulogger/v2" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/imessage" -) - -type PortalQuery struct { - db *Database - log log.Logger -} - -func (pq *PortalQuery) New() *Portal { - return &Portal{ - db: pq.db, - log: pq.log, - } -} - -func (pq *PortalQuery) Count() (count int) { - err := pq.db.QueryRow("SELECT COUNT(*) FROM portal").Scan(&count) - if err != nil { - pq.log.Warnln("Failed to scan number of portals:", err) - count = -1 - } - return -} - -const portalColumns = "guid, mxid, name, avatar_hash, avatar_url, encrypted, backfill_start_ts, in_space, thread_id, last_seen_handle, first_event_id, next_batch_id" -const selectPortal = "SELECT " + portalColumns + " FROM portal" -const selectMergedPortalByGUID = "SELECT " + portalColumns + " FROM merged_chat LEFT JOIN portal ON merged_chat.target_guid=portal.guid WHERE source_guid=$1" - -func (pq *PortalQuery) GetAllWithMXID() []*Portal { - return pq.getAll(selectPortal + " WHERE mxid<>''") -} - -func (pq *PortalQuery) GetByGUID(guid string) *Portal { - parsed := imessage.ParseIdentifier(guid) - if parsed.IsGroup { - return pq.get(selectPortal+" WHERE guid=$1", guid) - } else { - return pq.get(selectMergedPortalByGUID, guid) - } -} - -func (pq *PortalQuery) FindByThreadID(threadID string) []*Portal { - return pq.getAll(selectPortal+" WHERE thread_id=$1", threadID) -} - -func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal { - return pq.get(selectPortal+" WHERE mxid=$1", mxid) -} - -func (pq *PortalQuery) FindPrivateChats() []*Portal { - return pq.getAll(selectPortal + " WHERE guid LIKE '%%;-;%%'") -} - -func (pq *PortalQuery) getAll(query string, args ...interface{}) (portals []*Portal) { - rows, err := pq.db.Query(query, args...) - if err != nil || rows == nil { - return nil - } - defer rows.Close() - for rows.Next() { - portals = append(portals, pq.New().Scan(rows)) - } - return -} - -func (pq *PortalQuery) get(query string, args ...interface{}) *Portal { - row := pq.db.QueryRow(query, args...) - if row == nil { - return nil - } - return pq.New().Scan(row) -} - -type Portal struct { - db *Database - log log.Logger - - GUID string - MXID id.RoomID - - Name string - AvatarHash *[32]byte - AvatarURL id.ContentURI - Encrypted bool - BackfillStartTS int64 - InSpace bool - ThreadID string - LastSeenHandle string - - FirstEventID id.EventID - NextBatchID id.BatchID -} - -func (portal *Portal) avatarHashSlice() []byte { - if portal.AvatarHash == nil { - return nil - } - return (*portal.AvatarHash)[:] -} - -func (portal *Portal) Scan(row dbutil.Scannable) *Portal { - var mxid, avatarURL sql.NullString - var avatarHashSlice []byte - err := row.Scan(&portal.GUID, &mxid, &portal.Name, &avatarHashSlice, &avatarURL, &portal.Encrypted, &portal.BackfillStartTS, &portal.InSpace, &portal.ThreadID, &portal.LastSeenHandle, &portal.FirstEventID, &portal.NextBatchID) - if err != nil { - if err != sql.ErrNoRows { - portal.log.Errorln("Database scan failed:", err) - } - return nil - } - portal.MXID = id.RoomID(mxid.String) - portal.AvatarURL, _ = id.ParseContentURI(avatarURL.String) - if avatarHashSlice != nil || len(avatarHashSlice) == 32 { - var avatarHash [32]byte - copy(avatarHash[:], avatarHashSlice) - portal.AvatarHash = &avatarHash - } - return portal -} - -func (portal *Portal) mxidPtr() *id.RoomID { - if len(portal.MXID) > 0 { - return &portal.MXID - } - return nil -} - -func (portal *Portal) Insert(txn dbutil.Execable) { - if txn == nil { - txn = portal.db - } - _, err := txn.Exec(fmt.Sprintf("INSERT INTO portal (%s) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", portalColumns), - portal.GUID, portal.mxidPtr(), portal.Name, portal.avatarHashSlice(), portal.AvatarURL.String(), portal.Encrypted, portal.BackfillStartTS, portal.InSpace, portal.ThreadID, portal.LastSeenHandle, portal.FirstEventID, portal.NextBatchID) - if err != nil { - portal.log.Warnfln("Failed to insert %s: %v", portal.GUID, err) - } else { - portal.log.Debugfln("Inserted new portal %s", portal.GUID) - } -} - -func (portal *Portal) Update(txn dbutil.Execable) { - if txn == nil { - txn = portal.db - } - var mxid *id.RoomID - if len(portal.MXID) > 0 { - mxid = &portal.MXID - } - _, err := txn.Exec("UPDATE portal SET mxid=$1, name=$2, avatar_hash=$3, avatar_url=$4, encrypted=$5, backfill_start_ts=$6, in_space=$7, thread_id=$8, last_seen_handle=$9, first_event_id=$10, next_batch_id=$11 WHERE guid=$12", - mxid, portal.Name, portal.avatarHashSlice(), portal.AvatarURL.String(), portal.Encrypted, portal.BackfillStartTS, portal.InSpace, portal.ThreadID, portal.LastSeenHandle, portal.FirstEventID, portal.NextBatchID, portal.GUID) - if err != nil { - portal.log.Warnfln("Failed to update %s: %v", portal.GUID, err) - } -} - -func (portal *Portal) ReID(newGUID string) { - _, err := portal.db.Exec("UPDATE portal SET guid=$1 WHERE guid=$2", newGUID, portal.GUID) - if err != nil { - portal.log.Warnfln("Failed to re-id %s: %v", portal.GUID, err) - } else { - portal.GUID = newGUID - } -} - -func (portal *Portal) Delete() { - _, err := portal.db.Exec("DELETE FROM portal WHERE guid=$1", portal.GUID) - if err != nil { - portal.log.Warnfln("Failed to delete %s: %v", portal.GUID, err) - } -} diff --git a/database/puppet.go b/database/puppet.go deleted file mode 100644 index 3dfebac8..00000000 --- a/database/puppet.go +++ /dev/null @@ -1,115 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "database/sql" - "fmt" - - log "maunium.net/go/maulogger/v2" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -type PuppetQuery struct { - db *Database - log log.Logger -} - -func (pq *PuppetQuery) New() *Puppet { - return &Puppet{ - db: pq.db, - log: pq.log, - } -} - -const puppetColumns = "id, displayname, name_overridden, avatar_hash, avatar_url, contact_info_set" - -func (pq *PuppetQuery) GetAll() (puppets []*Puppet) { - rows, err := pq.db.Query(fmt.Sprintf("SELECT %s FROM puppet", puppetColumns)) - if err != nil || rows == nil { - return nil - } - defer rows.Close() - for rows.Next() { - puppets = append(puppets, pq.New().Scan(rows)) - } - return -} - -func (pq *PuppetQuery) Get(id string) *Puppet { - row := pq.db.QueryRow(fmt.Sprintf("SELECT %s FROM puppet WHERE id=$1", puppetColumns), id) - if row == nil { - return nil - } - return pq.New().Scan(row) -} - -type Puppet struct { - db *Database - log log.Logger - - ID string - Displayname string - NameOverridden bool - AvatarHash *[32]byte - AvatarURL id.ContentURI - ContactInfoSet bool -} - -func (puppet *Puppet) avatarHashSlice() []byte { - if puppet.AvatarHash == nil { - return nil - } - return (*puppet.AvatarHash)[:] -} - -func (puppet *Puppet) Scan(row dbutil.Scannable) *Puppet { - var avatarURL sql.NullString - var avatarHashSlice []byte - err := row.Scan(&puppet.ID, &puppet.Displayname, &puppet.NameOverridden, &avatarHashSlice, &avatarURL, &puppet.ContactInfoSet) - if err != nil { - if err != sql.ErrNoRows { - puppet.log.Errorln("Database scan failed:", err) - } - return nil - } - puppet.AvatarURL, _ = id.ParseContentURI(avatarURL.String) - if avatarHashSlice != nil || len(avatarHashSlice) == 32 { - var avatarHash [32]byte - copy(avatarHash[:], avatarHashSlice) - puppet.AvatarHash = &avatarHash - } - return puppet -} - -func (puppet *Puppet) Insert() { - _, err := puppet.db.Exec("INSERT INTO puppet (id, displayname, name_overridden, avatar_hash, avatar_url, contact_info_set) VALUES ($1, $2, $3, $4, $5, $6)", - puppet.ID, puppet.Displayname, puppet.NameOverridden, puppet.avatarHashSlice(), puppet.AvatarURL.String(), puppet.ContactInfoSet) - if err != nil { - puppet.log.Warnfln("Failed to insert %s: %v", puppet.ID, err) - } -} - -func (puppet *Puppet) Update() { - _, err := puppet.db.Exec("UPDATE puppet SET displayname=$1, name_overridden=$2, avatar_hash=$3, avatar_url=$4, contact_info_set=$5 WHERE id=$6", - puppet.Displayname, puppet.NameOverridden, puppet.avatarHashSlice(), puppet.AvatarURL.String(), puppet.ContactInfoSet, puppet.ID) - if err != nil { - puppet.log.Warnfln("Failed to update %s: %v", puppet.ID, err) - } -} diff --git a/database/tapback.go b/database/tapback.go deleted file mode 100644 index fe75f11f..00000000 --- a/database/tapback.go +++ /dev/null @@ -1,118 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "database/sql" - - log "maunium.net/go/maulogger/v2" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/imessage" -) - -type TapbackQuery struct { - db *Database - log log.Logger -} - -func (mq *TapbackQuery) New() *Tapback { - return &Tapback{ - db: mq.db, - log: mq.log, - } -} - -func (mq *TapbackQuery) GetByGUID(chat, message string, part int, sender string) *Tapback { - return mq.get("SELECT portal_guid, guid, message_guid, message_part, sender_guid, handle_guid, type, mxid "+ - "FROM tapback WHERE portal_guid=$1 AND message_guid=$2 AND message_part=$3 AND sender_guid=$4", - chat, message, part, sender) -} - -func (mq *TapbackQuery) GetByTapbackGUID(chat, tapback string) *Tapback { - return mq.get("SELECT portal_guid, guid, message_guid, message_part, sender_guid, handle_guid, type, mxid "+ - "FROM tapback WHERE portal_guid=$1 AND guid=$2", - chat, tapback) -} - -func (mq *TapbackQuery) GetByMXID(mxid id.EventID) *Tapback { - return mq.get("SELECT portal_guid, guid, message_guid, message_part, sender_guid, handle_guid, type, mxid "+ - "FROM tapback WHERE mxid=$1", mxid) -} - -func (mq *TapbackQuery) get(query string, args ...interface{}) *Tapback { - row := mq.db.QueryRow(query, args...) - if row == nil { - return nil - } - return mq.New().Scan(row) -} - -type Tapback struct { - db *Database - log log.Logger - - PortalGUID string - GUID string - MessageGUID string - MessagePart int - SenderGUID string - HandleGUID string - Type imessage.TapbackType - MXID id.EventID -} - -func (tapback *Tapback) Scan(row dbutil.Scannable) *Tapback { - var nullishGUID sql.NullString - err := row.Scan(&tapback.PortalGUID, &nullishGUID, &tapback.MessageGUID, &tapback.MessagePart, &tapback.SenderGUID, &tapback.HandleGUID, &tapback.Type, &tapback.MXID) - if err != nil { - if err != sql.ErrNoRows { - tapback.log.Errorln("Database scan failed:", err) - } - return nil - } - tapback.GUID = nullishGUID.String - return tapback -} - -func (tapback *Tapback) Insert(txn dbutil.Execable) { - if txn == nil { - txn = tapback.db - } - _, err := txn.Exec("INSERT INTO tapback (portal_guid, guid, message_guid, message_part, sender_guid, handle_guid, type, mxid) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", - tapback.PortalGUID, tapback.GUID, tapback.MessageGUID, tapback.MessagePart, tapback.SenderGUID, tapback.HandleGUID, tapback.Type, tapback.MXID) - if err != nil { - tapback.log.Warnfln("Failed to insert tapback %s/%s.%d/%s: %v", tapback.PortalGUID, tapback.MessageGUID, tapback.MessagePart, tapback.SenderGUID, err) - } -} - -func (tapback *Tapback) Update() { - _, err := tapback.db.Exec("UPDATE tapback SET guid=?5, type=?6, mxid=?7 WHERE portal_guid=?1 AND message_guid=?2 AND message_part=?3 AND sender_guid=?4", - tapback.PortalGUID, tapback.MessageGUID, tapback.MessagePart, tapback.SenderGUID, tapback.GUID, tapback.Type, tapback.MXID) - if err != nil { - tapback.log.Warnfln("Failed to update tapback %s/%s.%d/%s: %v", tapback.PortalGUID, tapback.MessageGUID, tapback.MessagePart, tapback.SenderGUID, err) - } -} - -func (tapback *Tapback) Delete() { - _, err := tapback.db.Exec("DELETE FROM tapback WHERE portal_guid=$1 AND message_guid=$2 AND message_part=$3 AND sender_guid=$4", tapback.PortalGUID, tapback.MessageGUID, tapback.MessagePart, tapback.SenderGUID) - if err != nil { - tapback.log.Warnfln("Failed to delete tapback %s/%s.%d/%s: %v", tapback.PortalGUID, tapback.MessageGUID, tapback.MessagePart, tapback.SenderGUID, err) - } -} diff --git a/database/upgrades/00-latest-schema.sql b/database/upgrades/00-latest-schema.sql deleted file mode 100644 index 7b622542..00000000 --- a/database/upgrades/00-latest-schema.sql +++ /dev/null @@ -1,109 +0,0 @@ --- v0 -> v21 (compatible with v18+): Latest schema - -CREATE TABLE portal ( - guid TEXT PRIMARY KEY, - mxid TEXT UNIQUE, - name TEXT NOT NULL, - avatar_hash TEXT, - avatar_url TEXT, - encrypted BOOLEAN NOT NULL DEFAULT false, - backfill_start_ts BIGINT NOT NULL DEFAULT 0, - in_space BOOLEAN NOT NULL DEFAULT false, - thread_id TEXT NOT NULL DEFAULT '', - last_seen_handle TEXT NOT NULL DEFAULT '', - first_event_id TEXT NOT NULL DEFAULT '', - next_batch_id TEXT NOT NULL DEFAULT '' -); -CREATE INDEX portal_thread_id_idx ON portal (thread_id); - -CREATE TABLE puppet ( - id TEXT PRIMARY KEY, - displayname TEXT NOT NULL, - name_overridden BOOLEAN, - avatar_hash TEXT, - avatar_url TEXT, - contact_info_set BOOLEAN NOT NULL DEFAULT false -); - -CREATE TABLE "user" ( - mxid TEXT PRIMARY KEY, - access_token TEXT NOT NULL, - next_batch TEXT NOT NULL, - space_room TEXT NOT NULL, - management_room TEXT NOT NULL -); - -CREATE TABLE message ( - portal_guid TEXT REFERENCES portal(guid) ON DELETE CASCADE ON UPDATE CASCADE, - guid TEXT, - part INTEGER, - mxid TEXT NOT NULL UNIQUE, - sender_guid TEXT NOT NULL, - handle_guid TEXT NOT NULL DEFAULT '', - timestamp BIGINT NOT NULL, - PRIMARY KEY (portal_guid, guid, part) -); - -CREATE TABLE tapback ( - portal_guid TEXT, - message_guid TEXT, - message_part INTEGER, - sender_guid TEXT, - handle_guid TEXT NOT NULL DEFAULT '', - type INTEGER NOT NULL, - mxid TEXT NOT NULL UNIQUE, guid TEXT DEFAULT NULL, - PRIMARY KEY (portal_guid, message_guid, message_part, sender_guid), - FOREIGN KEY (portal_guid, message_guid, message_part) REFERENCES message(portal_guid, guid, part) ON DELETE CASCADE ON UPDATE CASCADE -); - -CREATE TABLE kv_store ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE merged_chat ( - source_guid TEXT PRIMARY KEY, - target_guid TEXT NOT NULL, - - CONSTRAINT merged_chat_portal_fkey FOREIGN KEY (target_guid) REFERENCES portal(guid) ON DELETE CASCADE ON UPDATE CASCADE -); - -CREATE TRIGGER on_portal_insert_add_merged_chat AFTER INSERT ON portal WHEN NEW.guid LIKE '%%;-;%%' BEGIN - INSERT INTO merged_chat (source_guid, target_guid) VALUES (NEW.guid, NEW.guid) - ON CONFLICT (source_guid) DO UPDATE SET target_guid=NEW.guid; -END; - -CREATE TRIGGER on_merge_delete_portal AFTER INSERT ON merged_chat WHEN NEW.source_guid<>NEW.target_guid BEGIN - DELETE FROM portal WHERE guid=NEW.source_guid; -END; - -CREATE TABLE backfill_queue ( - queue_id INTEGER PRIMARY KEY - -- only: postgres - GENERATED ALWAYS AS IDENTITY - , - user_mxid TEXT, - priority INTEGER NOT NULL, - portal_guid TEXT, - time_start TIMESTAMP, - time_end TIMESTAMP, - dispatch_time TIMESTAMP, - completed_at TIMESTAMP, - batch_delay INTEGER, - max_batch_events INTEGER NOT NULL, - max_total_events INTEGER, - - FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE, - FOREIGN KEY (portal_guid) REFERENCES portal (guid) ON DELETE CASCADE -); - -CREATE TABLE backfill_state ( - user_mxid TEXT, - portal_guid TEXT, - processing_batch BOOLEAN, - backfill_complete BOOLEAN, - first_expected_ts BIGINT, - PRIMARY KEY (user_mxid, portal_guid), - FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE, - FOREIGN KEY (portal_guid) REFERENCES portal (guid) ON DELETE CASCADE -); diff --git a/database/upgrades/02-avatar-optional.go b/database/upgrades/02-avatar-optional.go deleted file mode 100644 index 5ca95288..00000000 --- a/database/upgrades/02-avatar-optional.go +++ /dev/null @@ -1,114 +0,0 @@ -package upgrades - -import ( - "go.mau.fi/util/dbutil" -) - -const createPortalTable2 = `CREATE TABLE portal ( - guid TEXT PRIMARY KEY, - mxid TEXT UNIQUE, - name TEXT NOT NULL, - avatar_hash TEXT, - avatar_url TEXT, - encrypted BOOLEAN NOT NULL DEFAULT 0 -)` - -const createPuppetTable2 = `CREATE TABLE puppet ( - id TEXT PRIMARY KEY, - displayname TEXT NOT NULL, - avatar_hash TEXT, - avatar_url TEXT -)` - -const createMessageTable = `CREATE TABLE message ( - chat_guid TEXT REFERENCES portal(guid) ON DELETE CASCADE, - guid TEXT, - mxid TEXT NOT NULL UNIQUE, - sender_guid TEXT NOT NULL, - timestamp BIGINT NOT NULL, - PRIMARY KEY (chat_guid, guid) -)` - -const createTapbackTable = `CREATE TABLE tapback ( - chat_guid TEXT, - message_guid TEXT, - sender_guid TEXT, - type INTEGER NOT NULL, - mxid TEXT NOT NULL UNIQUE, - PRIMARY KEY (chat_guid, message_guid, sender_guid), - FOREIGN KEY (chat_guid, message_guid) REFERENCES message(chat_guid, guid) ON DELETE CASCADE -)` - -func init() { - Table.Register(-1, 2, 0, "Make avatar fields optional", true, func(tx dbutil.Execable, db *dbutil.Database) error { - _, err := tx.Exec("PRAGMA defer_foreign_keys = ON") - if err != nil { - return err - } - _, err = tx.Exec("ALTER TABLE puppet RENAME TO old_puppet") - if err != nil { - return err - } - _, err = tx.Exec("ALTER TABLE portal RENAME TO old_portal") - if err != nil { - return err - } - _, err = tx.Exec("ALTER TABLE message RENAME TO old_message") - if err != nil { - return err - } - _, err = tx.Exec("ALTER TABLE tapback RENAME TO old_tapback") - if err != nil { - return err - } - _, err = tx.Exec(createPortalTable2) - if err != nil { - return err - } - _, err = tx.Exec(createPuppetTable2) - if err != nil { - return err - } - _, err = tx.Exec(createMessageTable) - if err != nil { - return err - } - _, err = tx.Exec(createTapbackTable) - if err != nil { - return err - } - _, err = tx.Exec("INSERT INTO puppet SELECT * FROM old_puppet") - if err != nil { - return err - } - _, err = tx.Exec("INSERT INTO portal SELECT * FROM old_portal") - if err != nil { - return err - } - _, err = tx.Exec("INSERT INTO message SELECT * FROM old_message") - if err != nil { - return err - } - _, err = tx.Exec("INSERT INTO tapback SELECT * FROM old_tapback") - if err != nil { - return err - } - _, err = tx.Exec("DROP TABLE old_tapback") - if err != nil { - return err - } - _, err = tx.Exec("DROP TABLE old_message") - if err != nil { - return err - } - _, err = tx.Exec("DROP TABLE old_portal") - if err != nil { - return err - } - _, err = tx.Exec("DROP TABLE old_puppet") - if err != nil { - return err - } - return nil - }) -} diff --git a/database/upgrades/03-message-part-index.go b/database/upgrades/03-message-part-index.go deleted file mode 100644 index a3e74c1a..00000000 --- a/database/upgrades/03-message-part-index.go +++ /dev/null @@ -1,70 +0,0 @@ -package upgrades - -import ( - "fmt" - - "go.mau.fi/util/dbutil" -) - -const createMessageTable2 = `CREATE TABLE message ( - chat_guid TEXT REFERENCES portal(guid) ON DELETE CASCADE, - guid TEXT, - part INTEGER, - mxid TEXT NOT NULL UNIQUE, - sender_guid TEXT NOT NULL, - timestamp BIGINT NOT NULL, - PRIMARY KEY (chat_guid, guid, part) -)` - -const createTapbackTable2 = `CREATE TABLE tapback ( - chat_guid TEXT, - message_guid TEXT, - message_part INTEGER, - sender_guid TEXT, - type INTEGER NOT NULL, - mxid TEXT NOT NULL UNIQUE, - PRIMARY KEY (chat_guid, message_guid, message_part, sender_guid), - FOREIGN KEY (chat_guid, message_guid, message_part) REFERENCES message(chat_guid, guid, part) ON DELETE CASCADE -)` - -func init() { - Table.Register(-1, 3, 0, "Add part index to message and tapback tables", true, func(tx dbutil.Execable, db *dbutil.Database) error { - _, err := tx.Exec("PRAGMA defer_foreign_keys = ON") - if err != nil { - return fmt.Errorf("failed to enable defer_foreign_keys pragma: %w", err) - } - _, err = tx.Exec("ALTER TABLE message RENAME TO old_message") - if err != nil { - return fmt.Errorf("failed to rename old message table: %w", err) - } - _, err = tx.Exec("ALTER TABLE tapback RENAME TO old_tapback") - if err != nil { - return fmt.Errorf("failed to rename old tapback table: %w", err) - } - _, err = tx.Exec(createMessageTable2) - if err != nil { - return fmt.Errorf("failed to create new message table: %w", err) - } - _, err = tx.Exec(createTapbackTable2) - if err != nil { - return fmt.Errorf("failed to create new tapback table: %w", err) - } - _, err = tx.Exec("INSERT INTO message SELECT chat_guid, guid, 0, mxid, sender_guid, timestamp FROM old_message") - if err != nil { - return fmt.Errorf("failed to copy messages into new table: %w", err) - } - _, err = tx.Exec("INSERT INTO tapback SELECT chat_guid, message_guid, 0, sender_guid, type, mxid FROM old_tapback") - if err != nil { - return fmt.Errorf("failed to copy tapbacks into new table: %w", err) - } - _, err = tx.Exec("DROP TABLE old_tapback") - if err != nil { - return fmt.Errorf("failed to drop old tapback table: %w", err) - } - _, err = tx.Exec("DROP TABLE old_message") - if err != nil { - return fmt.Errorf("failed to drop old message table: %w", err) - } - return nil - }) -} diff --git a/database/upgrades/04-portal-backfill-start-ts.sql b/database/upgrades/04-portal-backfill-start-ts.sql deleted file mode 100644 index 805823e7..00000000 --- a/database/upgrades/04-portal-backfill-start-ts.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v4: Add backfill_start_ts to portal table - -ALTER TABLE portal ADD COLUMN backfill_start_ts BIGINT NOT NULL DEFAULT 0; diff --git a/database/upgrades/05-message-on-update-cascade.go b/database/upgrades/05-message-on-update-cascade.go deleted file mode 100644 index 596087e9..00000000 --- a/database/upgrades/05-message-on-update-cascade.go +++ /dev/null @@ -1,70 +0,0 @@ -package upgrades - -import ( - "fmt" - - "go.mau.fi/util/dbutil" -) - -const createMessageTable3 = `CREATE TABLE message ( - chat_guid TEXT REFERENCES portal(guid) ON DELETE CASCADE ON UPDATE CASCADE, - guid TEXT, - part INTEGER, - mxid TEXT NOT NULL UNIQUE, - sender_guid TEXT NOT NULL, - timestamp BIGINT NOT NULL, - PRIMARY KEY (chat_guid, guid, part) -)` - -const createTapbackTable3 = `CREATE TABLE tapback ( - chat_guid TEXT, - message_guid TEXT, - message_part INTEGER, - sender_guid TEXT, - type INTEGER NOT NULL, - mxid TEXT NOT NULL UNIQUE, - PRIMARY KEY (chat_guid, message_guid, message_part, sender_guid), - FOREIGN KEY (chat_guid, message_guid, message_part) REFERENCES message(chat_guid, guid, part) ON DELETE CASCADE ON UPDATE CASCADE -)` - -func init() { - Table.Register(-1, 5, 0, "Add ON UPDATE CASCADE to message foreign keys", true, func(tx dbutil.Execable, db *dbutil.Database) error { - _, err := tx.Exec("PRAGMA defer_foreign_keys = ON") - if err != nil { - return fmt.Errorf("failed to enable defer_foreign_keys pragma: %w", err) - } - _, err = tx.Exec("ALTER TABLE message RENAME TO old_message") - if err != nil { - return fmt.Errorf("failed to rename old message table: %w", err) - } - _, err = tx.Exec("ALTER TABLE tapback RENAME TO old_tapback") - if err != nil { - return fmt.Errorf("failed to rename old tapback table: %w", err) - } - _, err = tx.Exec(createMessageTable3) - if err != nil { - return fmt.Errorf("failed to create new message table: %w", err) - } - _, err = tx.Exec(createTapbackTable3) - if err != nil { - return fmt.Errorf("failed to create new tapback table: %w", err) - } - _, err = tx.Exec("INSERT INTO message SELECT chat_guid, guid, part, mxid, sender_guid, timestamp FROM old_message") - if err != nil { - return fmt.Errorf("failed to copy messages into new table: %w", err) - } - _, err = tx.Exec("INSERT INTO tapback SELECT chat_guid, message_guid, message_part, sender_guid, type, mxid FROM old_tapback") - if err != nil { - return fmt.Errorf("failed to copy tapbacks into new table: %w", err) - } - _, err = tx.Exec("DROP TABLE old_tapback") - if err != nil { - return fmt.Errorf("failed to drop old tapback table: %w", err) - } - _, err = tx.Exec("DROP TABLE old_message") - if err != nil { - return fmt.Errorf("failed to drop old message table: %w", err) - } - return nil - }) -} diff --git a/database/upgrades/06-crypto-store-last-used.sql b/database/upgrades/06-crypto-store-last-used.sql deleted file mode 100644 index 1570d3b5..00000000 --- a/database/upgrades/06-crypto-store-last-used.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v6: Split last_used into last_encrypted and last_decrypted in crypto store -ALTER TABLE crypto_olm_session RENAME COLUMN last_used TO last_decrypted; -ALTER TABLE crypto_olm_session ADD COLUMN last_encrypted timestamp; -UPDATE crypto_olm_session SET last_encrypted=last_decrypted; diff --git a/database/upgrades/07-tapback-guids.sql b/database/upgrades/07-tapback-guids.sql deleted file mode 100644 index a3b55789..00000000 --- a/database/upgrades/07-tapback-guids.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v7: Add a guid column to tapback table - -ALTER TABLE tapback ADD COLUMN guid TEXT DEFAULT NULL; diff --git a/database/upgrades/08-remove-management-room.sql b/database/upgrades/08-remove-management-room.sql deleted file mode 100644 index b08c3007..00000000 --- a/database/upgrades/08-remove-management-room.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v8: Remove management room column from users - -ALTER TABLE user DROP COLUMN management_room; diff --git a/database/upgrades/09-add-kv-store.sql b/database/upgrades/09-add-kv-store.sql deleted file mode 100644 index 31e9e649..00000000 --- a/database/upgrades/09-add-kv-store.sql +++ /dev/null @@ -1,6 +0,0 @@ --- v9: Add an arbitrary key-value store for random stuff - -CREATE TABLE kv_store ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); diff --git a/database/upgrades/10-personal-filtering-spaces.sql b/database/upgrades/10-personal-filtering-spaces.sql deleted file mode 100644 index 4521f979..00000000 --- a/database/upgrades/10-personal-filtering-spaces.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v10: Add personal filtering space info - -ALTER TABLE "user" ADD COLUMN space_room TEXT NOT NULL DEFAULT ''; -ALTER TABLE portal ADD COLUMN in_space BOOLEAN NOT NULL DEFAULT false; diff --git a/database/upgrades/11-splitcrypto-store-handling-split.sql b/database/upgrades/11-splitcrypto-store-handling-split.sql deleted file mode 100644 index 1bf5bf21..00000000 --- a/database/upgrades/11-splitcrypto-store-handling-split.sql +++ /dev/null @@ -1,5 +0,0 @@ --- v11: Move crypto/state store upgrade handling to separate systems -CREATE TABLE crypto_version (version INTEGER PRIMARY KEY); -INSERT INTO crypto_version VALUES (6); -CREATE TABLE mx_version (version INTEGER PRIMARY KEY); -INSERT INTO mx_version VALUES (3); diff --git a/database/upgrades/12-management-room.sql b/database/upgrades/12-management-room.sql deleted file mode 100644 index 972df866..00000000 --- a/database/upgrades/12-management-room.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v12: Store user management room ID -ALTER TABLE "user" ADD COLUMN management_room TEXT NOT NULL DEFAULT ''; diff --git a/database/upgrades/13-displayname-override.sql b/database/upgrades/13-displayname-override.sql deleted file mode 100644 index 10fac652..00000000 --- a/database/upgrades/13-displayname-override.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v13: Remember whether a ghost displayname has been overridden -ALTER TABLE puppet ADD COLUMN name_overridden BOOLEAN NOT NULL DEFAULT false; diff --git a/database/upgrades/14-correlation-id.sql b/database/upgrades/14-correlation-id.sql deleted file mode 100644 index 13d64317..00000000 --- a/database/upgrades/14-correlation-id.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v14: Add correlation_id columns - -ALTER TABLE portal ADD COLUMN correlation_id TEXT; -ALTER TABLE puppet ADD COLUMN correlation_id TEXT; diff --git a/database/upgrades/15-thread-id.sql b/database/upgrades/15-thread-id.sql deleted file mode 100644 index f630b3d0..00000000 --- a/database/upgrades/15-thread-id.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v15: Store internal thread ID for portals - -ALTER TABLE portal ADD COLUMN thread_id TEXT NOT NULL DEFAULT ''; diff --git a/database/upgrades/16-remove-correlation-id.sql b/database/upgrades/16-remove-correlation-id.sql deleted file mode 100644 index f27c3049..00000000 --- a/database/upgrades/16-remove-correlation-id.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v16: Remove correlation_id columns - -ALTER TABLE portal DROP COLUMN correlation_id; -ALTER TABLE puppet DROP COLUMN correlation_id; diff --git a/database/upgrades/17-batch-send-ids.sql b/database/upgrades/17-batch-send-ids.sql deleted file mode 100644 index 0e7e00bb..00000000 --- a/database/upgrades/17-batch-send-ids.sql +++ /dev/null @@ -1,4 +0,0 @@ --- v17: Add batch sending identifiers to portals - -ALTER TABLE portal ADD COLUMN first_event_id TEXT NOT NULL DEFAULT ''; -ALTER TABLE portal ADD COLUMN next_batch_id TEXT NOT NULL DEFAULT ''; diff --git a/database/upgrades/18-chat-merges.sql b/database/upgrades/18-chat-merges.sql deleted file mode 100644 index 8bb0a776..00000000 --- a/database/upgrades/18-chat-merges.sql +++ /dev/null @@ -1,28 +0,0 @@ --- v18: Add table for merging chats - -CREATE TABLE merged_chat ( - source_guid TEXT PRIMARY KEY, - target_guid TEXT NOT NULL, - - CONSTRAINT merged_chat_portal_fkey FOREIGN KEY (target_guid) REFERENCES portal(guid) ON DELETE CASCADE ON UPDATE CASCADE -); - -INSERT INTO merged_chat (source_guid, target_guid) SELECT guid, guid FROM portal WHERE guid LIKE '%%;-;%%'; - -CREATE TRIGGER on_portal_insert_add_merged_chat AFTER INSERT ON portal WHEN NEW.guid LIKE '%%;-;%%' BEGIN - INSERT INTO merged_chat (source_guid, target_guid) VALUES (NEW.guid, NEW.guid) - ON CONFLICT (source_guid) DO UPDATE SET target_guid=NEW.guid; -END; - -CREATE TRIGGER on_merge_delete_portal AFTER INSERT ON merged_chat WHEN NEW.source_guid<>NEW.target_guid BEGIN - DELETE FROM portal WHERE guid=NEW.source_guid; -END; - -ALTER TABLE message RENAME COLUMN chat_guid TO portal_guid; -ALTER TABLE message ADD COLUMN handle_guid TEXT NOT NULL DEFAULT ''; -ALTER TABLE tapback RENAME COLUMN chat_guid TO portal_guid; -ALTER TABLE tapback ADD COLUMN handle_guid TEXT NOT NULL DEFAULT ''; -ALTER TABLE portal ADD COLUMN last_seen_handle TEXT NOT NULL DEFAULT ''; -UPDATE portal SET last_seen_handle=guid WHERE guid LIKE '%%;-;%%'; -UPDATE message SET handle_guid=portal_guid WHERE portal_guid LIKE '%%;-;%%'; -UPDATE tapback SET handle_guid=portal_guid WHERE portal_guid LIKE '%%;-;%%'; diff --git a/database/upgrades/19-add-contact-info.sql b/database/upgrades/19-add-contact-info.sql deleted file mode 100644 index 3e52b28b..00000000 --- a/database/upgrades/19-add-contact-info.sql +++ /dev/null @@ -1,3 +0,0 @@ --- v19: Store whether custom contact info has been set for a puppet - -ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false; diff --git a/database/upgrades/20-thread-id-index.sql b/database/upgrades/20-thread-id-index.sql deleted file mode 100644 index 7630eb83..00000000 --- a/database/upgrades/20-thread-id-index.sql +++ /dev/null @@ -1,2 +0,0 @@ --- v20 (compatible with v18+): Add index on portal thread IDs -CREATE INDEX portal_thread_id_idx ON portal (thread_id); diff --git a/database/upgrades/21-prioritized-backfill.sql b/database/upgrades/21-prioritized-backfill.sql deleted file mode 100644 index dfd19550..00000000 --- a/database/upgrades/21-prioritized-backfill.sql +++ /dev/null @@ -1,32 +0,0 @@ --- v21 (compatible with v18+): Add backfill queue - -CREATE TABLE backfill_queue ( - queue_id INTEGER PRIMARY KEY - -- only: postgres - GENERATED ALWAYS AS IDENTITY - , - user_mxid TEXT, - priority INTEGER NOT NULL, - portal_guid TEXT, - time_start TIMESTAMP, - time_end TIMESTAMP, - dispatch_time TIMESTAMP, - completed_at TIMESTAMP, - batch_delay INTEGER, - max_batch_events INTEGER NOT NULL, - max_total_events INTEGER, - - FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE, - FOREIGN KEY (portal_guid) REFERENCES portal (guid) ON DELETE CASCADE -); - -CREATE TABLE backfill_state ( - user_mxid TEXT, - portal_guid TEXT, - processing_batch BOOLEAN, - backfill_complete BOOLEAN, - first_expected_ts BIGINT, - PRIMARY KEY (user_mxid, portal_guid), - FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE ON UPDATE CASCADE, - FOREIGN KEY (portal_guid) REFERENCES portal (guid) ON DELETE CASCADE -); diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go deleted file mode 100644 index 88545bf8..00000000 --- a/database/upgrades/upgrades.go +++ /dev/null @@ -1,32 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package upgrades - -import ( - "embed" - - "go.mau.fi/util/dbutil" -) - -var Table dbutil.UpgradeTable - -//go:embed *.sql -var rawUpgrades embed.FS - -func init() { - Table.RegisterFS(rawUpgrades) -} diff --git a/database/user.go b/database/user.go deleted file mode 100644 index 4b486ec8..00000000 --- a/database/user.go +++ /dev/null @@ -1,84 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "database/sql" - - log "maunium.net/go/maulogger/v2" - - "go.mau.fi/util/dbutil" - "maunium.net/go/mautrix/id" -) - -type UserQuery struct { - db *Database - log log.Logger -} - -func (uq *UserQuery) New() *User { - return &User{ - db: uq.db, - log: uq.log, - } -} - -func (uq *UserQuery) GetByMXID(userID id.UserID) *User { - row := uq.db.QueryRow(`SELECT mxid, access_token, next_batch, space_room, management_room FROM "user" WHERE mxid=$1`, userID) - if row == nil { - return nil - } - return uq.New().Scan(row) -} - -type User struct { - db *Database - log log.Logger - - MXID id.UserID - AccessToken string - NextBatch string - SpaceRoom id.RoomID - ManagementRoom id.RoomID -} - -func (user *User) Scan(row dbutil.Scannable) *User { - err := row.Scan(&user.MXID, &user.AccessToken, &user.NextBatch, &user.SpaceRoom, &user.ManagementRoom) - if err != nil { - if err != sql.ErrNoRows { - user.log.Errorln("Database scan failed:", err) - } - return nil - } - return user -} - -func (user *User) Insert() { - _, err := user.db.Exec(`INSERT INTO "user" (mxid, access_token, next_batch, space_room, management_room) VALUES ($1, $2, $3, $4, $5)`, - user.MXID, user.AccessToken, user.NextBatch, user.SpaceRoom, user.ManagementRoom) - if err != nil { - user.log.Warnfln("Failed to insert %s: %v", user.MXID, err) - } -} - -func (user *User) Update() { - _, err := user.db.Exec(`UPDATE "user" SET access_token=$1, next_batch=$2, space_room=$3, management_room=$4 WHERE mxid=$5`, - user.AccessToken, user.NextBatch, user.SpaceRoom, user.ManagementRoom, user.MXID) - if err != nil { - user.log.Warnfln("Failed to update %s: %v", user.MXID, err) - } -} diff --git a/docker-run.sh b/docker-run.sh deleted file mode 100755 index da0b7b7a..00000000 --- a/docker-run.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/sh - -if [[ -z "$GID" ]]; then - GID="$UID" -fi - -# Define functions. -function fixperms { - chown -R $UID:$GID /data - - # /opt/mautrix-imessage is read-only, so disable file logging if it's pointing there. - if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/mautrix-imessage.log" ]]; then - yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml - fi -} - -if [[ ! -f /data/config.yaml ]]; then - cp /opt/mautrix-imessage/example-config.yaml /data/config.yaml - echo "Didn't find a config file." - echo "Copied default config file to /data/config.yaml" - echo "Modify that config file to your liking." - echo "Start the container again after that to generate the registration file." - exit -fi - -if [[ ! -f /data/registration.yaml ]]; then - /usr/bin/mautrix-imessage -g -c /data/config.yaml -r /data/registration.yaml || exit $? - echo "Didn't find a registration file." - echo "Generated one for you." - echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." - exit -fi - -cd /data -fixperms -exec su-exec $UID:$GID /usr/bin/mautrix-imessage diff --git a/docs/apple-auth-research.md b/docs/apple-auth-research.md new file mode 100644 index 00000000..0e203d08 --- /dev/null +++ b/docs/apple-auth-research.md @@ -0,0 +1,748 @@ +# Apple iCloud Authentication: Token Lifecycle & Persistence + +Research document for the iMessage bridge's iCloud authentication system. +Covers every token type, its lifetime, refresh mechanism, and the correct +strategy for maintaining persistent access across process restarts. + +--- + +## Table of Contents + +1. [Token Inventory](#1-token-inventory) +2. [Token Dependency Graph](#2-token-dependency-graph) +3. [How a Real Apple Device Maintains Persistent Auth](#3-how-a-real-apple-device-maintains-persistent-auth) +4. [Our Current (Broken) Flow Analyzed](#4-our-current-broken-flow-analyzed) +5. [Root Cause of the 401s](#5-root-cause-of-the-401s) +6. [Error -22406 Explained](#6-error--22406-explained) +7. [Recommended Fix](#7-recommended-fix) +8. [Alternative Approaches Considered](#8-alternative-approaches-considered) +9. [Sources](#9-sources) + +--- + +## 1. Token Inventory + +### 1.1 Hashed Password (SHA-256 of raw password) + +| Property | Value | +|---|---| +| **Full Name** | SHA-256 pre-hash of the Apple ID password | +| **Issued By** | Computed locally: `SHA256(raw_password)` | +| **Lifetime** | Indefinite (valid as long as user doesn't change their Apple ID password) | +| **Refreshable without 2FA?** | N/A — it IS the credential | +| **Used For** | SRP-6a authentication with GSA (`login_email_pass()`) | +| **Depends On** | Nothing | + +**Details:** During the initial login, the raw password is SHA-256 hashed +client-side before being used in the SRP-6a protocol. The bridge stores this +hash (hex-encoded) as `AccountHashedPasswordHex`. The hash is then further +processed through PBKDF2 with a server-provided salt and iteration count during +each SRP handshake. The protocol variant (`s2k` vs `s2k_fo`) determines whether +the hash bytes or their hex encoding are fed to PBKDF2. + +**Critical insight:** This stored hash is functionally equivalent to the +plaintext password for SRP purposes. It can be used to perform `login_email_pass()` +at any time. However, on an HSA2 account, the server may still require 2FA +verification — the hash alone does not bypass 2FA. + +### 1.2 SPD (Server Provided Data) + +| Property | Value | +|---|---| +| **Full Name** | Server Provided Data | +| **Issued By** | GSA endpoint (`gsa.apple.com/grandslam/GsService2`) during SRP `complete` step | +| **Lifetime** | The SPD itself doesn't expire; it contains session identifiers | +| **Refreshable without 2FA?** | Only re-obtained by performing a full SRP login | +| **Used For** | Contains ADSID, DSID, GsIdmsToken, account name, and token dictionary | +| **Depends On** | Successful SRP authentication | + +**Details:** The SPD is an AES-CBC encrypted plist returned in the `spd` field +of the GSA `complete` response. It is decrypted using keys derived from the +SRP session key. It contains: + +- `adsid` — Alternate Directory Services ID (see 1.3) +- `DsPrsId` — Directory Services Person ID / DSID (see 1.4) +- `GsIdmsToken` — Used to build the `X-Apple-Identity-Token` header for 2FA verification +- `acname` — Account username +- `fn`, `ln` — First/last name +- `t` — Dictionary of tokens (PET, HB token, GS tokens) with their expiry/duration + +The SPD is stable across sessions as long as the account hasn't changed +(password change, security upgrade, etc.). The identifiers within it (ADSID, +DSID) are permanent for the account. + +### 1.3 ADSID (Alternate Directory Services ID) + +| Property | Value | +|---|---| +| **Full Name** | Alternate Directory Services ID (also called AltDSID) | +| **Issued By** | Apple identity services, embedded in SPD | +| **Lifetime** | Permanent (tied to the Apple ID account) | +| **Refreshable without 2FA?** | N/A — it's an identifier, not a token | +| **Used For** | Combined with tokens (PET, HB) to form auth headers; used in `X-Apple-ADSID` header; used in delegate requests | +| **Depends On** | SPD (extracted from `spd["adsid"]`) | + +**Details:** The ADSID is a UUID-like string that uniquely identifies the Apple +ID account in Apple's directory services. It is used in conjunction with various +tokens to form authorization headers (e.g., `base64(ADSID:PET)` for delegate +login, `base64(ADSID:HB_TOKEN)` for postdata/circle operations). + +### 1.4 DSID (Directory Services Person ID) + +| Property | Value | +|---|---| +| **Full Name** | Directory Services Person ID | +| **Issued By** | Apple identity services, embedded in SPD | +| **Lifetime** | Permanent (tied to the Apple ID account) | +| **Refreshable without 2FA?** | N/A — it's an identifier, not a token | +| **Used For** | CardDAV `Basic` auth header (`DSID:mmeAuthToken`); CloudKit operations; circle authentication | +| **Depends On** | SPD (extracted from `spd["DsPrsId"]`) | + +**Details:** Numeric account identifier. Used as the "username" part of Basic +auth for MobileMe/iCloud service APIs (CardDAV, CloudKit, Quota, etc.). + +### 1.5 GsIdmsToken + +| Property | Value | +|---|---| +| **Full Name** | Grand Slam Identity Management Services Token | +| **Issued By** | GSA, embedded in SPD | +| **Lifetime** | Long-lived (appears to last for the duration of the SRP session, likely hours to days) | +| **Refreshable without 2FA?** | Only by performing a fresh SRP login | +| **Used For** | Building the `X-Apple-Identity-Token` header for 2FA verification and trusted device communication | +| **Depends On** | SPD (extracted from `spd["GsIdmsToken"]`) | + +**Details:** This token is combined with the ADSID as `base64(ADSID:GsIdmsToken)` +to form the `X-Apple-Identity-Token` header used when sending/verifying 2FA +codes and for circle (iCloud Keychain) operations in the `is_twofa=true` path. + +### 1.6 PET (Password Equivalent Token) + +| Property | Value | +|---|---| +| **Full Name** | Password Equivalent Token | +| **Issued By** | GSA, via SPD token dictionary (`com.apple.gs.idms.pet`) or `X-Apple-PE-Token` header after 2FA | +| **Lifetime** | **~2 hours** (see analysis below) | +| **Refreshable without 2FA?** | **Yes, on a provisioned/trusted machine** (see Section 3) | +| **Used For** | Authenticating to Apple delegate endpoints (MobileMe, IDS) as a password substitute | +| **Depends On** | SRP login + 2FA verification (initial); SRP login only (on trusted machine) | + +**Details:** The PET is the most important renewable token in the chain. Its +name literally means "Password Equivalent Token" — it acts as a short-lived +password substitute for downstream service authentication. + +**Lifetime evidence from our codebase:** +- In `verify_2fa()` (device 2FA path): the PET header contains `ADSID:TOKEN` or + `ADSID:TOKEN:DURATION`. When no duration is present, the code defaults to + **300 seconds** (5 minutes). This is a conservative fallback. +- In `verify_sms_2fa()` (SMS path): tokens may have format + `ADSID:TOKEN:DURATION:EXPIRY_MS`. The code parses either field. +- In `login_email_pass()` (SPD token dictionary): tokens have explicit `expiry` + (ms since epoch) or `duration` (seconds) fields. +- From observation and community reports: PET tokens obtained after 2FA + verification typically have a **duration of a few hours** (commonly 2-4 hours). + The exact duration is server-controlled and varies. +- Our code in `restore_token_provider()` injects a fake **30-day expiry** which + is completely wrong — the client-side expiry is meaningless because the server + enforces its own expiry independently. + +**Key insight:** The PET's *client-side* expiry tracking is only a hint. Even if +we set a 30-day client-side expiry, the server will reject the token after its +actual server-side lifetime (~2-4 hours). This is the primary cause of our +401 errors. + +### 1.7 HB Token (Happy Birthday Token) + +| Property | Value | +|---|---| +| **Full Name** | Happy Birthday Token (`com.apple.gs.idms.hb`) | +| **Issued By** | GSA, via SPD token dictionary or X-Apple-HB-Token header after 2FA | +| **Lifetime** | Similar to PET (hours) | +| **Refreshable without 2FA?** | Yes, same mechanism as PET (via SRP login on trusted machine) | +| **Used For** | `X-Apple-HB-Token` header for postdata/liveness/circle/teardown operations | +| **Depends On** | SRP login + 2FA verification | + +**Details:** Used for GSA service operations (postdata, teardown, circle auth +in the non-2FA path). The code calls `get_token("com.apple.gs.idms.hb")` which +triggers automatic refresh via `login_email_pass()` if expired. + +### 1.8 MobileMe Delegate / mmeAuthToken + +| Property | Value | +|---|---| +| **Full Name** | MobileMe Delegate Authentication Token | +| **Issued By** | `setup.icloud.com/setup/authenticate` (login_apple_delegates endpoint) | +| **Lifetime** | **~24 hours** (server-enforced, varies) | +| **Refreshable without 2FA?** | Yes — if you have a valid PET | +| **Used For** | `X-MobileMe-AuthToken` header for iCloud services: CardDAV contacts, CloudKit, Quota, etc. | +| **Depends On** | PET + ADSID (inputs to `login_apple_delegates()`) | + +**Details:** The MobileMe delegate is obtained by POSTing to the +`setup.icloud.com/setup/authenticate` endpoint with `Basic ADSID:PET` +authentication and requesting specific delegate services (`com.apple.mobileme`, +`com.apple.private.ids`). + +The response is a plist containing: +- `tokens` — dict including `mmeAuthToken` (the actual bearer token for iCloud APIs) +- `com.apple.mobileme` — config dict with service URLs (CardDAV URL, Quota URL, etc.) + +The `mmeAuthToken` lifetime appears to be approximately 24 hours based on +community observations, though Apple can vary this server-side. + +**Our code refreshes this every 2 hours** (see `get_mme_token()` in `auth.rs`) +to stay well within the validity window. This refresh requires a valid PET. + +### 1.9 IDS Delegate / auth_token + +| Property | Value | +|---|---| +| **Full Name** | IDS (Identity Services) Delegate Auth Token | +| **Issued By** | Same `login_apple_delegates()` call, but for `com.apple.private.ids` | +| **Lifetime** | Used immediately for IDS certificate authentication; not cached long-term | +| **Refreshable without 2FA?** | Yes — if you have a valid PET | +| **Used For** | Authenticating with Apple's IDS certificate service to get device identity certs | +| **Depends On** | PET + ADSID | + +### 1.10 Anisette Data (Machine Identity Headers) + +| Property | Value | +|---|---| +| **Full Name** | Anisette Headers (X-Apple-I-MD, X-Apple-I-MD-M, X-Apple-I-MD-LU, etc.) | +| **Issued By** | Generated locally by `omnisette` library (emulates Apple's `adi` framework) | +| **Lifetime** | **~30 seconds** per OTP (X-Apple-I-MD); the machine identity (X-Apple-I-MD-M) is long-lived | +| **Refreshable without 2FA?** | Yes — generated locally, no network call needed | +| **Used For** | Required in all GSA requests. Identifies the machine to Apple's servers. | +| **Depends On** | Provisioned machine state (stored in `state/anisette/`) | + +**Details:** Anisette data consists of two key components: +1. **Machine identity** (`X-Apple-I-MD-M`): A long-lived machine identifier obtained + during initial provisioning. Persisted on disk. Valid for months/years unless + the machine identity is revoked by Apple. +2. **One-Time Password** (`X-Apple-I-MD`): A time-based OTP derived from the + machine identity. Valid for approximately 30 seconds. + +The machine provisioning process (handled by omnisette via `midStartProvisioning` +and `midFinishProvisioning` endpoints) is what establishes the machine as a +"known device" to Apple's servers. This is analogous to installing iCloud on +a new computer — it registers the device. + +**Critical for 2FA bypass:** On a real Apple device, the anisette provisioning +combined with a successful 2FA verification "trusts" the device. Subsequent +SRP logins from the same provisioned machine may not require 2FA. This is the +mechanism Blackwood-4NT documents: "Once enrolled, further GSA logins will no +longer require 2FA from the given machine." + +### 1.11 CloudKit Session Tokens + +| Property | Value | +|---|---| +| **Full Name** | CloudKit authentication tokens | +| **Issued By** | CloudKit service, using mmeAuthToken for initial auth | +| **Lifetime** | Session-scoped | +| **Refreshable without 2FA?** | Yes — derived from mmeAuthToken | +| **Used For** | CloudKit database operations (cloud messages, keychain sync) | +| **Depends On** | mmeAuthToken from MobileMe delegate | + +**Details:** CloudKit auth piggybacks on the MobileMe delegate. The +`mmeAuthToken` is used as the authentication credential for CloudKit API calls. +There is no separate long-lived CloudKit token; if the mmeAuthToken is fresh, +CloudKit works. + +--- + +## 2. Token Dependency Graph + +``` +┌──────────────────────────────────────────────────────────┐ +│ INITIAL LOGIN (with 2FA) │ +│ Apple ID + Password (SHA-256 hash) + 2FA Code │ +│ │ │ +│ SRP-6a Handshake │ +│ (GSA endpoint) │ +│ │ │ +│ ▼ │ +│ ┌─────────┐ │ +│ │ SPD │ Contains: ADSID, DSID, │ +│ │ │ GsIdmsToken, Tokens{} │ +│ └────┬────┘ │ +│ │ │ +│ ┌──────────┼──────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌───────┐ ┌───────┐ ┌──────┐ │ +│ │ PET │ │ HB │ │ GS │ (2-4h lifetime) │ +│ │ Token │ │ Token │ │Tokens│ │ +│ └───┬───┘ └───────┘ └──────┘ │ +│ │ │ +│ ┌─────────┼─────────┐ │ +│ ▼ ▼ │ +│ ┌────────────┐ ┌────────────┐ │ +│ │ MobileMe │ │ IDS │ │ +│ │ Delegate │ │ Delegate │ │ +│ │ (~24h) │ │ (one-time) │ │ +│ └─────┬──────┘ └────────────┘ │ +│ │ │ +│ ┌────┴─────┐ │ +│ ▼ ▼ │ +│ CardDAV CloudKit │ +│ Contacts Operations │ +└──────────────────────────────────────────────────────────┘ + +REFRESH CHAIN (without 2FA, on trusted/provisioned machine): + + Stored Hash + Anisette + │ + ▼ + SRP login_email_pass() ──→ Fresh SPD + Fresh PET (+ HB, GS) + │ (no 2FA required if machine is trusted) + ▼ + login_apple_delegates() ──→ Fresh MobileMe Delegate + │ + ▼ + CardDAV / CloudKit work again +``` + +**The refresh chain in plain words:** + +1. **Hashed password** + fresh anisette → `login_email_pass()` → new PET (if machine trusted, no 2FA) +2. **PET** + ADSID → `login_apple_delegates()` → new MobileMe delegate (mmeAuthToken) +3. **mmeAuthToken** + DSID → iCloud API calls (CardDAV, CloudKit) + +Each layer has its own lifetime: +- PET: ~2-4 hours +- mmeAuthToken: ~24 hours +- Hashed password: indefinite (until user changes password) + +--- + +## 3. How a Real Apple Device Maintains Persistent Auth + +A real Apple device (iPhone, Mac) never re-prompts for 2FA after the initial +setup. Here is how it achieves this: + +### 3.1 Machine Provisioning (Anisette/ADI) + +When you first sign into iCloud on a Mac, the `akd` (AuthKit Daemon) process: +1. Generates a machine identity via Apple's ADI (Apple Device Identity) framework +2. Provisions this identity with Apple's servers (`midStartProvisioning` / `midFinishProvisioning`) +3. Stores the provisioned state locally (in the Keychain on macOS, in `state/anisette/` in our case) + +This establishes the machine as a "known device" with a unique `X-Mme-Device-Id`. + +### 3.2 Trusted Device Status + +After the user completes 2FA verification, Apple marks the combination of: +- The machine identity (anisette provisioned state) +- The Apple ID account + +...as "trusted." This trust is stored server-side. The key factors that +maintain trust are: +- **Consistent machine identity**: Same `X-Mme-Device-Id`, same `X-Apple-I-MD-M` +- **Same anisette provisioning**: The machine's ADI provisioned state hasn't been reset + +### 3.3 Token Refresh Without 2FA + +On a trusted device, the token refresh cycle works like this: + +1. **Periodic SRP re-authentication**: `akd` periodically calls the GSA endpoint + with the stored password (from Keychain). Because the machine is trusted, + the server returns `LoginState::LoggedIn` directly — no `au` field requesting + `trustedDeviceSecondaryAuth` or `secondaryAuth`. + +2. **Fresh tokens from SPD**: Each SRP login produces a fresh SPD containing + new PET, HB, and GS tokens with fresh expiry times. + +3. **Delegate refresh**: The fresh PET is used to call `login_apple_delegates()` + for fresh MobileMe delegate tokens. + +4. **Continuous operation**: This cycle repeats every few hours, well within + token lifetimes, keeping all services authenticated indefinitely. + +### 3.4 When Trust Is Lost + +Trust can be lost when: +- The anisette provisioning state is deleted/corrupted +- Apple detects anomalous behavior from the machine identity +- The user changes their Apple ID password +- Apple revokes the machine identity (rare, but happens with abusive patterns) +- Too many failed authentication attempts + +When trust is lost, the next SRP login will return +`LoginState::NeedsDevice2FA` or `LoginState::NeedsSMS2FA`, requiring user +interaction. + +### 3.5 pyicloud's Approach (Web Session, Different Protocol) + +For reference, pyicloud uses a **different authentication protocol** — the iCloud +web session API (`idmsa.apple.com`), not GSA. It uses: +- HTTP cookies for session persistence +- A `trust_token` / `X-Apple-TwoSV-Trust-Token` header to establish trust +- Session cookies that last approximately **2 months** before requiring re-auth + +This is the web equivalent of device trust. The `trust_session()` call after +2FA is what pyicloud documentation refers to when it says "Authentication will +expire after an interval set by Apple, at which point you will have to +re-authenticate. This interval is currently two months." + +Our bridge uses the **native GSA/SRP protocol** (like a real Apple device), +not the web API. The trust mechanism is different — it's based on anisette +machine provisioning rather than HTTP cookies. + +--- + +## 4. Our Current (Broken) Flow Analyzed + +### 4.1 Login (Works Correctly) + +``` +1. User enters Apple ID + password + 2FA code +2. login_email_pass() → SRP handshake → SPD with tokens (PET, HB, etc.) +3. verify_2fa() or verify_sms_2fa() → fresh PET with real server-provided duration +4. login_apple_delegates() with PET → MobileMe delegate (mmeAuthToken) +5. Store everything: username, hashed_password, PET, SPD, ADSID, DSID, MobileMe delegate JSON +``` + +**This is correct.** At this point, all tokens are fresh and valid. + +### 4.2 Restore (Broken) + +``` +1. restore_token_provider() is called with stored credentials +2. Creates fresh anisette provider ✓ +3. Creates AppleAccount, sets username + hashed_password + SPD ✓ +4. Injects stored PET with FAKE 30-day expiry ← PROBLEM #1 +5. Seeds cached MobileMe delegate JSON ← partial workaround +6. TokenProvider.get_mme_token() is called for CardDAV +7. If within 2h of seeding, uses cached delegate ← MAY WORK temporarily +8. After 2h, or on first call if cache expired, calls refresh_mme() +9. refresh_mme() tries login_apple_delegates() with stored PET ← FAILS (PET expired server-side) +10. Falls back to login_email_pass() with hashed password ← FAILS with -22406 +11. All iCloud services dead +``` + +### 4.3 Why the Seeded MobileMe Delegate Sometimes Works + +The bridge seeds the last-known MobileMe delegate JSON on restore. If the +bridge restarts quickly (within ~24 hours of the delegate being fetched) and +the 2-hour refresh timer hasn't fired, the cached mmeAuthToken may still be +valid server-side. CardDAV calls work. + +But as soon as `get_mme_token()` decides to refresh (after 2 hours), it calls +`refresh_mme()`, which needs a valid PET — and the stored PET is long expired. + +--- + +## 5. Root Cause of the 401s + +There are **two distinct failures**: + +### Failure 1: Expired PET used for delegate refresh + +The PET stored at login time has a real server-side lifetime of ~2-4 hours. +Our code injects it with a fake 30-day client-side expiry, which means +`get_token("com.apple.gs.idms.pet")` doesn't attempt to refresh it (it thinks +it's still valid). But when this stale PET is sent to +`setup.icloud.com/setup/authenticate`, Apple's server rejects it. + +**Location:** `restore_token_provider()` in `pkg/rustpushgo/src/lib.rs:636-639` +```rust +account.tokens.insert("com.apple.gs.idms.pet".to_string(), icloud_auth::FetchedToken { + token: pet, + expiration: std::time::SystemTime::now() + std::time::Duration::from_secs(60 * 60 * 24 * 30), // 30 days ← WRONG +}); +``` + +### Failure 2: SRP re-auth triggers 2FA requirement (error -22406) + +When the PET-based refresh fails, `refresh_mme()` falls back to +`login_email_pass()` with the stored hashed password. This performs a fresh +SRP handshake. However, the server returns an error because **the machine is +not trusted** (or trust has been lost), so 2FA is required. + +Error -22406 maps to an Apple authentication error indicating that additional +verification is needed. The GSA server returns this when: +- The machine identity (anisette) is not recognized as trusted +- The account requires 2FA and the current session hasn't completed it +- The anisette provisioning state has changed since the initial login + +--- + +## 6. Error -22406 Explained + +Error code `-22406` in Apple's GSA protocol corresponds to +**"Authentication required" or "Unauthorized"** — it means the SRP +authentication itself succeeded (password was correct), but the server won't +issue tokens without additional verification (2FA). + +This happens when `login_email_pass()` completes the SRP handshake successfully +(M1/M2 verified), but the SPD's `Status.au` field is set to +`trustedDeviceSecondaryAuth` or `secondaryAuth`. In our code, this would +normally return `LoginState::NeedsDevice2FA` or `LoginState::NeedsSMS2FA`. + +However, looking at the code in `refresh_mme()`, the fallback calls +`login_email_pass()` and checks for `LoginState::LoggedIn`. If it gets anything +else (like `NeedsDevice2FA`), it falls through to the error path. + +**The -22406 error suggests that the SRP handshake itself is failing at the +`check_error()` stage** — before even getting to the `Status.au` check. This +can happen when: +1. The anisette state is stale or the machine identity is blacklisted +2. The account has been locked or flagged +3. The hashed password format is wrong for the current `s2k`/`s2k_fo` protocol + variant selected by the server + +**Most likely cause:** The anisette machine identity is either not provisioned +correctly on restore, or Apple doesn't recognize it as trusted. The `default_provider()` +call in `restore_token_provider()` creates a fresh anisette provider from disk +state, but if that state is corrupted or was never properly provisioned, all +subsequent GSA calls will fail. + +--- + +## 7. Recommended Fix + +### 7.1 Core Strategy: Use `get_token()` Auto-Refresh + +The `AppleAccount::get_token()` method in `icloud-auth/src/client.rs` already +implements the correct refresh logic: + +```rust +pub async fn get_token(&mut self, token: &str) -> Option { + let has_valid_token = if !self.tokens.is_empty() { + let data = self.tokens.get(token)?; + data.expiration.elapsed().is_err() // still valid? + } else { + false + }; + if !has_valid_token { + // Token expired → re-authenticate with stored password + let username = self.username.clone()?; + let hashed_password = self.hashed_password.clone()?; + match self.login_email_pass(&username, &hashed_password).await { + Ok(LoginState::LoggedIn) => {}, + _err => { + error!("Failed to refresh tokens, state {_err:?}"); + return None + } + } + } + Some(self.tokens.get(token)?.token.to_string()) +} +``` + +This method: +1. Checks if the token is expired (client-side) +2. If expired, calls `login_email_pass()` with stored credentials +3. If the SRP login returns `LoggedIn` (machine is trusted), fresh tokens are issued +4. Returns the fresh token + +**The problem is that we bypass this by injecting a fake 30-day expiry.** The +`get_token()` method never fires because it thinks the PET is still valid. + +### 7.2 Specific Changes + +#### Change 1: Set realistic PET expiry on restore + +In `restore_token_provider()`, set the PET expiry to something short (e.g., 0 +or 5 minutes) so that the first call to `get_token()` or `refresh_mme()` +triggers a re-authentication: + +```rust +// Instead of 30-day fake expiry, set to already-expired or near-expired +account.tokens.insert("com.apple.gs.idms.pet".to_string(), icloud_auth::FetchedToken { + token: pet, + expiration: std::time::SystemTime::now(), // expired immediately — forces refresh on first use +}); +``` + +This way, the first call to `refresh_mme()` → `get_gsa_token("com.apple.gs.idms.pet")` +→ `get_token()` will detect the expiry and call `login_email_pass()` to get a +fresh PET. + +#### Change 2: Handle 2FA-required state in refresh_mme() + +The `refresh_mme()` fallback currently calls `login_email_pass()` and only +handles `LoginState::LoggedIn`. It needs to handle the case where the server +requires 2FA: + +```rust +match account.login_email_pass(&username, &password).await { + Ok(LoginState::LoggedIn) => { + // Success — machine is trusted, got fresh tokens + info!("refresh_mme: password re-auth succeeded without 2FA"); + } + Ok(LoginState::NeedsDevice2FA) | Ok(LoginState::NeedsSMS2FA) => { + // Machine is NOT trusted — need user interaction + warn!("refresh_mme: SRP succeeded but server requires 2FA — cannot auto-refresh"); + // Signal to the bridge that re-login is needed + return Err(PushError::AuthRequires2FA); + } + Ok(state) => { + warn!("refresh_mme: unexpected login state: {:?}", state); + return Err(PushError::TokenMissing); + } + Err(auth_err) => { + warn!("refresh_mme: SRP auth failed: {}", auth_err); + return Err(PushError::TokenMissing); + } +} +``` + +#### Change 3: Ensure anisette state persistence and consistency + +The anisette provisioning state in `state/anisette/` must be preserved across +restarts. Verify that: +1. The anisette directory is not being cleared on restart +2. The same `X-Mme-Device-Id` is used across sessions +3. The anisette provider created in `restore_token_provider()` loads the same + provisioned state that was used during the original login + +If the anisette state is lost, the machine loses its "trusted" status and all +SRP logins will require 2FA. This cannot be fixed without user interaction. + +#### Change 4: Propagate fresh PET back to persisted state + +When `login_email_pass()` succeeds in `refresh_mme()` and produces a fresh PET, +the bridge should persist the new PET to `UserLoginMetadata.AccountPET` so that +subsequent restarts have a more recent (though still likely expired) PET. This +is a "best effort" — the real fix is the auto-refresh via `get_token()`. + +#### Change 5: Proactive PET refresh timer + +Instead of waiting for `get_mme_token()` to trigger a refresh after 2 hours, +add a proactive background timer that refreshes the PET every ~1 hour: + +```rust +// In TokenProvider, run a background task: +// Every 60 minutes, call get_token("com.apple.gs.idms.pet") +// This keeps the PET fresh and prevents cascading expiry +``` + +This ensures the PET is always fresh when `refresh_mme()` needs it. + +### 7.3 Expected Behavior After Fix + +**On machine where trust is maintained:** +``` +1. Bridge starts, restore_token_provider() sets up account +2. PET injected with immediate expiry +3. First get_mme_token() triggers refresh_mme() +4. refresh_mme() calls get_gsa_token() → get_token() detects PET expired +5. get_token() calls login_email_pass() → SRP succeeds → LoginState::LoggedIn +6. Fresh PET returned → refresh_mme() fetches fresh MobileMe delegate +7. CardDAV works. CloudKit works. Everything works. +8. Every 2 hours, cycle repeats automatically. +``` + +**On machine where trust is lost:** +``` +1-4. Same as above +5. get_token() calls login_email_pass() → LoginState::NeedsDevice2FA +6. get_token() returns None → refresh_mme() fails +7. Bridge signals "re-login required" to user +8. User re-enters 2FA code → trust re-established → normal operation resumes +``` + +--- + +## 8. Alternative Approaches Considered + +### 8.1 Store and re-use the raw PET for longer + +**Won't work.** The PET has a server-enforced lifetime. No amount of client-side +expiry manipulation will make the server accept an expired PET. + +### 8.2 Use iCloud web session API (like pyicloud) instead of GSA + +Theoretically possible. The web API uses `trust_token` cookies that last ~2 +months. However: +- Our entire auth stack (IDS registration, circle auth, postdata) is built on GSA +- The web API is designed for browser-like clients, not device-like clients +- Switching would require a massive rewrite +- The web API may not provide all the delegate types we need (IDS specifically) + +**Not recommended.** + +### 8.3 Store the user's raw password + +Would allow us to always re-authenticate, but: +- Massive security risk +- We already store the hashed password, which is functionally equivalent for SRP +- The hash + anisette trust should be sufficient + +**Not needed — hashed password already serves this purpose.** + +### 8.4 Never let the PET expire by refreshing proactively + +This is part of the recommended fix (Change 5). By refreshing the PET every +~1 hour (well within its ~2-4 hour lifetime), we prevent the situation where +the PET expires and the stored one is the only fallback. + +--- + +## 9. Sources + +### Primary Sources (Code) + +1. **GSA client** — `rustpush/apple-private-apis/icloud-auth/src/client.rs` + - SRP login flow: `login_email_pass()` (~line 600) + - Token parsing from SPD: `if let Some(Value::Dictionary(dict)) = decoded_spd.get("t")` (~line 720) + - PET parsing from 2FA headers: `parse_pet_header()` (~line 990) + - Auto-refresh: `get_token()` (~line 310) + - 2FA state detection: `Status.au` field (~line 735) + +2. **TokenProvider / MobileMe refresh** — `rustpush/src/auth.rs` + - `refresh_mme()`: lines ~150-210 + - `get_mme_token()`: 2-hour refresh timer + - `login_apple_delegates()`: PET-based delegate fetching + +3. **Token restoration** — `pkg/rustpushgo/src/lib.rs` + - `restore_token_provider()`: line ~600 + - Fake 30-day PET expiry: line ~636 + +4. **Go-side restore** — `pkg/connector/client.go` + - `Connect()`: TokenProvider restoration and MobileMe delegate seeding + +### External References + +5. **Blackwood-4NT** (Alex Ionescu) — https://github.com/ionescu007/Blackwood-4NT + - Definitive documentation of GSA protocol, SPD, PET, ADSID + - Confirms: "SPD contains the Password Equivalent Token (PET)" + - Confirms: "Once enrolled [anisette provisioned], further GSA logins will + no longer require 2FA from the given machine" + - Confirms: PET used with ADSID for further API authentication + +6. **pypush** (JJTech0130) — https://github.com/JJTech0130/pypush + - Original iMessage reverse-engineering project + - GSA implementation reference (purchased by Beeper) + +7. **pyicloud** (picklepete) — https://github.com/picklepete/pyicloud + - Documents iCloud web session trust: "Authentication will expire after + an interval set by Apple... This interval is currently two months" + - Uses `trust_session()` after 2FA to establish long-lived web trust + - **Note:** Uses different auth protocol (web/idmsa) than our bridge (GSA) + +8. **Apple GSA Protocol gist** (JJTech0130) — https://gist.github.com/JJTech0130/049716196f5f1751b8944d93e73d3452 + - Python implementation of GSA SRP authentication + +9. **MathewYaldo/Apple-GSA-Protocol** — https://github.com/MathewYaldo/Apple-GSA-Protocol + - Documents GSA endpoint, SRP parameter construction, anisette requirements + - Notes: "X-Apple-I-MD parameter is time-sensitive and lasts only about 30 seconds" + +10. **DeepWiki analysis of pyicloud auth** — https://deepwiki.com/picklepete/pyicloud/2.1-authentication-and-security + - Documents session persistence headers: `X-Apple-ID-Session-Id`, + `X-Apple-Session-Token`, `X-Apple-TwoSV-Trust-Token`, `scnt` + +--- + +## Summary + +| Question | Answer | +|---|---| +| What is the longest-lived storable credential? | **Hashed password** (indefinite until password change) + **anisette machine identity** (months). Together, these allow PET refresh without 2FA on a trusted machine. | +| Can `login_email_pass()` get a fresh PET without 2FA? | **Yes, if the machine is trusted** (anisette properly provisioned, trust not revoked). If trust is lost, 2FA is required. Error -22406 indicates trust is not established or has been lost. | +| What is the PET lifetime? | **~2-4 hours** (server-controlled). Our 30-day fake expiry is meaningless. | +| What is the MobileMe delegate lifetime? | **~24 hours** (server-controlled). Our 2-hour refresh cycle is appropriate. | +| How does a real Apple device avoid re-prompting for 2FA? | **Machine provisioning** (anisette/ADI) establishes device trust. Subsequent SRP logins from the same provisioned machine skip 2FA. The device periodically re-authenticates with the stored password to get fresh PET tokens. | +| What is the correct refresh chain? | `hashed_password → SRP login → fresh PET → login_apple_delegates() → fresh mmeAuthToken → CardDAV/CloudKit` | +| Is persistent auth without 2FA possible? | **Yes**, as long as the anisette machine identity is preserved and Apple hasn't revoked trust. If trust is lost, the user must complete 2FA once to re-establish it. | +| What's the primary fix needed? | Stop injecting fake 30-day PET expiry. Let `get_token()` auto-refresh work by setting realistic/expired PET expiry on restore. Ensure anisette state persists correctly across restarts. | diff --git a/docs/cloudkit-guide.md b/docs/cloudkit-guide.md new file mode 100644 index 00000000..77f7f160 --- /dev/null +++ b/docs/cloudkit-guide.md @@ -0,0 +1,212 @@ +# CloudKit Integration Guide + +## Overview + +This bridge uses Apple's CloudKit to backfill historical iMessage conversations. Real-time messages are delivered via APNs push (`com.apple.madrid`), so CloudKit is only needed for fetching message history — not for ongoing sync. + +This document explains how CloudKit works, how we use it, and the design decisions behind our approach. + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Go bridge │ +│ │ +│ ┌──────────────┐ ┌────────────────────────┐ │ +│ │ Real-time │ │ CloudKit backfill │ │ +│ │ APNs push │ │ (one-shot on connect) │ │ +│ │ │ │ │ │ +│ │ com.apple. │ │ chatManateeZone │ │ +│ │ madrid │ │ messageManateeZone │ │ +│ └──────┬───────┘ └───────────┬────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Matrix bridge rooms │ │ +│ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +**Two paths, one purpose:** + +| Path | Source | When | What | +|------|--------|------|------| +| **Real-time** | APNs push on `com.apple.madrid` | Always running | Incoming/outgoing messages as they happen | +| **Backfill** | CloudKit Messages in iCloud | On connect only | Historical messages from before the bridge existed | + +## How CloudKit Works (Apple's Model) + +Apple's CloudKit stores iMessage data in the user's **private database** under the `com.apple.messages.cloud` container. Messages in iCloud uses three record zones: + +| Zone | Record Type | Contents | +|------|-------------|----------| +| `chatManateeZone` | `chatEncryptedv2` | Chat metadata: participants, group name, style, service | +| `messageManateeZone` | `MessageEncryptedV3` | Message content: text, sender, timestamp, flags | +| `attachmentManateeZone` | `attachment` | File attachments (MMCS blobs) | + +All records are end-to-end encrypted using **PCS (Protected CloudStorage)** keys derived from the user's iCloud Keychain. The bridge decrypts these via the `rustpush` Rust library. + +### Zone Change Tokens + +CloudKit's sync model is built around **server change tokens** (Apple calls them `CKServerChangeToken`). The key API is `CKFetchRecordZoneChangesOperation`: + +1. Pass `nil` token → get **all** records in the zone's history +2. Pass a stored token → get only records that **changed since** that token +3. Each response includes a new token to store for next time +4. A `status == 3` (or `moreComing == false`) signals the end of the current changeset + +From Apple's docs: + +> "Use this operation to fetch record changes in one or more record zones. You provide a configuration object for each record zone to query for changes. The configuration contains a server change token, which is an opaque pointer to a specific change in the zone's history. CloudKit returns only the changes that occur after that point." + +Tokens are opaque, zone-specific, and safe to persist to disk. + +### What Apple Says About Polling + +Apple explicitly recommends **against** periodic polling: + +> "It's not necessary to perform a fetch each time your app launches, or to schedule fetches at regular intervals." + +The intended pattern is: +1. Fetch all changes on first launch (token = nil) +2. Subscribe to zone changes via `CKRecordZoneSubscription` +3. On receipt of a silent push notification, fetch changes using the stored token + +We don't implement subscriptions because our APNs real-time path already delivers messages. CloudKit sync is redundant for ongoing operation. + +## Our Implementation + +### Files + +| File | Purpose | +|------|---------| +| `pkg/connector/sync_controller.go` | Orchestrates CloudKit backfill on connect | +| `pkg/connector/cloud_backfill_store.go` | SQLite storage for synced chats/messages and zone tokens | +| `pkg/rustpushgo/src/lib.rs` | Go↔Rust FFI: `cloud_sync_chats`, `cloud_sync_messages` | +| `rustpush/src/imessage/cloud_messages.rs` | Rust CloudKit client: zone sync, PCS decryption | +| `rustpush/src/icloud/cloudkit.rs` | Low-level CloudKit protobuf API (`FetchRecordChangesOperation`) | + +### Backfill Flow + +When the bridge connects (or reconnects), `startCloudSyncController` launches a goroutine that: + +``` +1. Wait for contacts to be ready (needed for name resolution) +2. Check if DB has any messages + ├── No messages → clear stored tokens (force full sync) + └── Has messages → keep tokens (incremental catch-up) +3. Sync chats (chatManateeZone) + - Load stored continuation token (or nil) + - Page through CloudKit responses until status == 3 + - Upsert each chat record into cloud_chat table + - Save new token +4. Sync messages (messageManateeZone) + - Same token-based pagination + - Resolve each message to a portal ID + - Upsert into cloud_message table + - Save new token +5. Create Matrix portals for all discovered conversations +6. Exit — real-time APNs handles everything from here +``` + +### Token Storage + +Tokens are stored in the `cloud_sync_state` table, keyed by `(login_id, zone)`: + +```sql +CREATE TABLE cloud_sync_state ( + login_id TEXT NOT NULL, + zone TEXT NOT NULL, + continuation_token TEXT, + last_success_ts BIGINT, + last_error TEXT, + updated_ts BIGINT NOT NULL, + PRIMARY KEY (login_id, zone) +); +``` + +The token is a base64-encoded opaque blob from CloudKit. We store separate tokens for `chatManateeZone` and `messageManateeZone`. + +### Portal ID Resolution + +CloudKit messages need to be mapped to bridge portal IDs. The resolution logic in `resolveConversationID`: + +1. **UUID chat_id** → group conversation → `gid:` +2. **Known chat record** → look up portal ID from `cloud_chat` table +3. **DM from sender** → `tel:+...` or `mailto:...` from the sender field +4. **DM from me** → parse destination from chat_id (`iMessage;-;+16692858317`) + +CloudKit chat style values: +- `43` = group conversation +- `45` = direct message (DM) + +### PCS Encryption + +All CloudKit records in the Manatee zones are encrypted with PCS (Protected CloudStorage). The decryption chain: + +1. **iCloud Keychain** contains the PCS service key for `Messages3` (service type 55) +2. **Zone protection info** is decrypted using the service key → yields zone keys +3. **Record protection info** is decrypted using zone keys → yields per-record keys +4. **Record fields** are decrypted using the per-record key + +The Rust code handles PCS key recovery with retries — if a key is missing, it refreshes the keychain/zone config and retries up to 4 times. Records that still can't be decrypted (e.g., very old records with rotated keys) are skipped with a warning. + +## Why No Periodic Polling + +Previous versions of the bridge polled CloudKit every 30 seconds and ran "repair tasks" that re-downloaded entire zone histories. This was removed because: + +1. **Real-time APNs already delivers all messages.** The `com.apple.madrid` push topic provides incoming messages, delivery receipts, read receipts, typing indicators, and reactions in real-time. Messages sent from the user's other Apple devices are also delivered via APNs. + +2. **Reconnect handles missed messages.** When the bridge reconnects after downtime, the backfill runs again using the stored continuation token. CloudKit returns only what changed while the bridge was offline. This is the same mechanism Apple uses — token-based catch-up. + +3. **Full zone re-scans were wasteful.** The old "repair" system called `CloudFetchRecentMessages` which started from a nil token every time, re-downloading the entire message zone (potentially thousands of records) just to filter for recent ones client-side. This was the opposite of how Apple's API is designed to work. + +4. **Apple says not to.** Their documentation explicitly states periodic polling is unnecessary when you have another mechanism for detecting changes. + +## Debugging + +### Inspecting Stored State + +```sql +-- Check sync tokens +SELECT * FROM cloud_sync_state; + +-- Count synced records +SELECT COUNT(*) FROM cloud_chat; +SELECT COUNT(*) FROM cloud_message WHERE deleted = FALSE; + +-- Messages per portal +SELECT portal_id, COUNT(*) as msg_count +FROM cloud_message +WHERE deleted = FALSE +GROUP BY portal_id +ORDER BY msg_count DESC; + +-- Check for unresolved messages +SELECT COUNT(*) FROM cloud_message +WHERE portal_id IS NULL OR portal_id = ''; +``` + +### Forcing a Full Re-sync + +Delete the sync tokens and restart the bridge: + +```sql +DELETE FROM cloud_sync_state; +``` + +Or delete all cloud data to start completely fresh: + +```sql +DELETE FROM cloud_sync_state; +DELETE FROM cloud_chat; +DELETE FROM cloud_message; +``` + +## References + +- [Apple CloudKit Documentation](https://developer.apple.com/documentation/cloudkit/) +- [CKFetchRecordZoneChangesOperation](https://developer.apple.com/documentation/cloudkit/ckfetchrecordzonechangesoperation) +- [Remote Records Guide](https://developer.apple.com/documentation/cloudkit/remote-records) +- [CKRecordZoneSubscription](https://developer.apple.com/documentation/cloudkit/ckrecordzonesubscription) +- `docs/group-id-research.md` — how chat identifiers map between CloudKit and APNs diff --git a/docs/group-id-research.md b/docs/group-id-research.md new file mode 100644 index 00000000..26a0dc99 --- /dev/null +++ b/docs/group-id-research.md @@ -0,0 +1,278 @@ +# iMessage Group Chat Identity Model — Research Report + +## Executive Summary + +The duplicate portal problem occurs because **the `sender_guid` (gid) in real-time APNs messages can differ from the `group_id` (gid) in CloudKit chat records**, even for the same conversation. This happens specifically when the bridge itself sends an outgoing message without providing the correct `sender_guid` — the Rust layer generates a brand new UUID via `Uuid::new_v4()`, which then becomes that session's `sender_guid`. When other participants reply, they parrot back this new UUID, creating a portal that doesn't match any CloudKit record. + +The root cause is **not** that Apple changes the group UUID on member changes. The CloudKit `group_id` is stable across member changes. The problem is that our bridge sometimes sends messages with a freshly generated UUID instead of the established one. + +--- + +## 1. Identifier Map + +| Field | Source | Wire Name | Format | Stability | Changes on Member Change? | +|-------|--------|-----------|--------|-----------|---------------------------| +| `chat_identifier` (cid) | CloudKit `chatEncryptedv2` | `cid` | `chat` (e.g., `chat368136512547052395`) | **Unstable** — new value per member snapshot | **Yes** — each participant change produces a new `cid` | +| `group_id` (gid) | CloudKit `chatEncryptedv2` | `gid` | UUID with dashes (e.g., `42572808-43F8-4013-AA0A-F13AD89AC210`) or hex-encoded string for SMS groups | **Stable** across member changes in CloudKit | **No** — same `gid` across all snapshots | +| `original_group_id` (ogid) | CloudKit `chatEncryptedv2` | `ogid` | UUID (same format as `gid`) | N/A — reference field | N/A — not yet exposed through FFI | +| `guid` | CloudKit `chatEncryptedv2` | `guid` | Unknown (present in struct, not yet analyzed) | Unknown | Unknown | +| `record_name` | CloudKit record metadata | (CK record name) | Hex hash (e.g., `5fe123c189fd4418a8acff4c36dacb41`) | Unique per CloudKit record | Yes — new record per snapshot | +| `sender_guid` | APNs real-time messages | `gid` | UUID without dashes, lowercase | **Should be stable** but can drift (see §3) | **No** — Apple keeps it stable; our bridge may change it | +| `style` (stl) | CloudKit `chatEncryptedv2` | `stl` | Integer: `43` = group, `45` = DM | Stable | No | +| `display_name` | CloudKit `chatEncryptedv2` | `name` | Free text or null | User-modifiable | No (unless user renames) | +| `service_name` (svc) | CloudKit `chatEncryptedv2` | `svc` | `"iMessage"` or `"SMS"` | Stable per chat | No | +| `participants` (ptcpts) | CloudKit `chatEncryptedv2` | `ptcpts` | Array of `{FZPersonID: "tel:+1..."/"mailto:..."}` | **Changes** per snapshot | **Yes** — that's what triggers new `cid` | +| `legacy_group_identifiers` | CloudKit `chatEncryptedv2` → `prop` | `prop.legacyGroupIdentifiers` | Array of strings | Unknown — not yet exposed | Unknown | + +### Key Observations + +1. **`group_id` (gid) IS stable across member changes in CloudKit.** Evidence: group `6265...` has 4 cloud_chat records with different `chat_identifier` values and varying participant counts (12–16), all sharing the same `group_id`. + +2. **`chat_identifier` (cid) is NOT stable.** Each member-change snapshot gets a new `chat` value. There are 70 cloud_chat records mapping to only 47 distinct group_ids. + +3. **`sender_guid` in APNs is the same field as `group_id` in CloudKit** — both are serialized as `gid` in their respective plist payloads. They *should* always match for the same conversation. + +4. **Two `cloud_chat_id` formats exist:** + - `chat` — derived from the CloudKit `chat_identifier` field when non-empty + - 32-char hex — used when `chat_identifier` is empty; falls back to `record_name` + +--- + +## 2. Assumption Verification + +### ❌ "Apple creates a new group UUID when members are added/removed" + +**DISPROVED.** CloudKit data proves the opposite. The `group_id` stays constant across member changes. What changes is the `chat_identifier` (`cid`) — Apple creates a new `chatEncryptedv2` record with a new `cid` but the same `gid` for each membership snapshot. + +Evidence from the database: group `6265373930353030643536643434613362643232396630386332356665336662` has 4 records with different `cloud_chat_id` values (`chat407641904384093446`, `chat413369107146090660`, `chat894658066477092892`, `chat666379037895724839`) and varying participant counts, all sharing the same `group_id`. + +### ⚠️ "`original_group_id` links to the previous UUID forming a chain" + +**UNCERTAIN — cannot verify.** The `ogid` field exists in the Rust `CloudChat` struct but is **not passed through the FFI layer** (`WrappedCloudSyncChat` does not include it) and is **not stored in the database**. We cannot examine its values without first adding it to the FFI struct and DB schema. + +However, since `group_id` itself appears stable, `ogid` may serve a different purpose — perhaps linking to a predecessor conversation when a group is forked or recreated, rather than tracking member-change snapshots. + +### ✅ "30+ different group UUIDs all represent the same conversation" → CORRECTLY identified as WRONG + +**CONFIRMED as wrong per the prompt.** The ~30 groups with overlapping participants (Ludvig, David, James) are legitimately different conversations created during testing. Each has its own unique `group_id`. This is correct behavior. + +### ✅ "`chat_identifier` (cid) is stable across member changes" + +**DISPROVED.** `cid` changes with each member-change snapshot. Multiple `cloud_chat_id` values (derived from `cid`) map to the same `group_id`. The `chat` format appears to be a hash/identifier that Apple recomputes when membership changes. + +### ⚠️ "`sender_guid` in real-time messages is the same as CloudKit's `gid`" + +**PARTIALLY CONFIRMED, WITH A CRITICAL CAVEAT.** Both fields use the same wire name (`gid` in plist) and the same UUID format. For conversations where Apple devices are sending, the `sender_guid` in APNs messages matches the `group_id` in CloudKit. + +**However**, the bridge can introduce mismatches: +- In `messages.rs:1959-1960`: `prepare_send()` generates a new `Uuid::new_v4()` if `sender_guid` is `None` +- The Go connector (`portalToConversation`) correctly passes the sender_guid for `gid:` portals +- But for **legacy comma-separated portals** or when `sender_guid` is not cached/persisted, the Rust layer may mint a new UUID + +The 3 orphaned portals (`gid:2f787cd8-...`, `gid:4b62c57d-...`, `gid:3dc13b1f-...`) with no matching cloud_chat records are evidence of this — these UUIDs were likely generated by `Uuid::new_v4()` during an outbound message, then parroted back by recipients. + +### ✅ "Style 43 = group, 45 = DM with no other values" + +**UNCERTAIN — cannot verify from DB** (style is not stored in `cloud_chat`). The CloudKit struct defines `style` as `i64` with comments "45 for normal chats, 43 for group". No evidence of other values, but we'd need to add style to the DB or log it during sync to verify exhaustively. + +--- + +## 3. The Real Bug: UUID Drift on Outbound Messages + +### How the Duplicate Occurs + +1. **CloudKit bootstrap**: Group "Ludvig, David, & James" syncs from CloudKit with `group_id = 6BC815C2-...`. Portal `gid:6bc815c2-...` is created. + +2. **Outbound message**: User sends from Beeper. The bridge calls `portalToConversation()`, which correctly resolves the `sender_guid` from the `gid:` portal ID. The Rust `prepare_send()` receives this UUID and uses it. ✅ This path works. + +3. **The failure case**: At some point, a message was sent through a code path where `sender_guid` was `None` (possibly before the `gid:` portal ID scheme existed, or during a restart before caches were populated). Rust's `prepare_send()` generated `2f787cd8-...` as a new UUID. This UUID was sent to all participants. + +4. **Inbound reply**: Other participants reply. Their devices now use `sender_guid = 2f787cd8-...` (the UUID from step 3). The bridge receives this, calls `makePortalKey()`, and creates a NEW portal `gid:2f787cd8-...`. The original portal `gid:6bc815c2-...` is abandoned. + +5. **CloudKit never learns**: CloudKit continues to use the original `group_id = 6BC815C2-...`. The real-time UUID `2f787cd8-...` is never recorded in CloudKit. + +### Evidence + +- Portal `gid:2f787cd8-5e31-4ed6-802c-4e1b7ee56eff` — exists in `portal` table, has NO matching `cloud_chat` record +- Portal `gid:4b62c57d-7b73-4960-8fbd-1f5836f8feb4` — same situation +- Portal `gid:6bc815c2-f84f-4c33-9a5f-60dde285004c` — HAS a matching `cloud_chat` record (`chat838581659461115708`) + +All three are named "Ludvig, David, & James" and represent the same logical conversation. + +### Why Recipients Use the Bridge's UUID + +When the bridge sends an outbound message with `sender_guid = `, the iMessage protocol treats this as the canonical group identifier for that message. If the recipients' devices see a `gid` they don't recognize, they may: +- Create a new local chat entry with that `gid` +- Or (more likely for existing conversations) continue using the conversation but echo back whatever `gid` was in the latest message + +Either way, replies come back with the bridge-generated UUID instead of the CloudKit-canonical one. + +--- + +## 4. CloudKit Field Details + +### `chat_identifier` (cid) +- Format: `chat` (e.g., `chat368136512547052395`) — appears to be a hash of participant list +- For DMs: the phone number or email (e.g., `+14158138533`, `user@example.com`) +- Can be empty — in which case `record_name` is used as `cloud_chat_id` +- **NOT stable** across member changes + +### `group_id` (gid) +- Format: Standard UUID with dashes for iMessage groups; hex-encoded string for SMS/MMS groups +- Present on ALL CloudKit chat records, including DMs (the code comments confirm: "The group_id (gid) field is set for ALL CloudKit chats, even DMs") +- **STABLE** across member changes for the same conversation +- Case varies between records (uppercase from CloudKit, sometimes lowercase from local operations) + +### `original_group_id` (ogid) +- Declared in `CloudChat` struct but NOT passed through FFI (`WrappedCloudSyncChat` omits it) +- NOT stored in the bridge database +- Purpose unknown without data inspection. Hypotheses: + 1. Points to a predecessor group when a conversation is "forked" + 2. Always equals `gid` (redundant) + 3. Points to the very first `gid` if the group was somehow recreated +- **Action needed**: Expose through FFI and store in DB to analyze + +### `legacy_group_identifiers` (in `CloudProp`) +- Array of strings inside the `prop` (properties) field +- Not exposed through FFI +- Could contain historical `gid` values or pre-migration identifiers +- **Action needed**: Expose and analyze + +### `participants` (ptcpts) +- Array of `CloudParticipant` structs, each with `FZPersonID` field +- URIs like `tel:+15551234567` or `mailto:user@example.com` +- Includes the local user's handle +- Different participant snapshots across cloud_chat records for the same group + +### `guid` +- Present in `CloudChat` struct but distinct from `group_id` +- Not analyzed in detail; may be a record-level GUID + +--- + +## 5. Architecture Recommendation + +### Portal ID Strategy + +**Continue using `gid:` as the canonical portal ID for groups.** The CloudKit `group_id` is the correct stable identifier. The current approach is fundamentally sound. + +### Fix: Prevent UUID Drift on Outbound Messages + +The primary fix is ensuring that `sender_guid` is NEVER `None` when sending to an existing group: + +1. **`portalToConversation()`** already handles `gid:` portals correctly by extracting the UUID from the portal ID. This path works. + +2. **Verify all send paths**: Audit every call to `makeConversation()` and `portalToConversation()` to ensure none can produce a `nil` sender_guid for group conversations. + +3. **Rust-side safety**: In `prepare_send()`, instead of silently generating a new UUID when `sender_guid` is `None`, log a warning. For group conversations (participants > 2), this should be treated as an error condition, not silently patched. + +### Fix: Reconcile Orphaned Portals + +For the 3 existing orphaned portals, a migration or repair step should: +1. Detect portals with `gid:` IDs that have no matching `cloud_chat.group_id` +2. Find the canonical portal for the same conversation (by matching participants + group name) +3. Merge/redirect the orphaned portal to the canonical one + +### Fix: Real-Time → CloudKit Reconciliation + +When a real-time message arrives with a `sender_guid` that doesn't match any existing portal: + +1. **Before creating a new portal**, check if there's an existing `cloud_chat` record with a `group_id` that matches — but also check if the participants overlap with an existing portal. +2. **Expose `original_group_id`** through the FFI layer and store it. If `ogid` provides a chain linking different `gid` values, use it to map incoming real-time UUIDs to canonical CloudKit UUIDs. +3. **Expose `legacy_group_identifiers`** and check if the incoming `sender_guid` appears in any chat's legacy identifiers. + +### Handling the Transition Period + +When a real-time APNs message arrives with a UUID before CloudKit has synced: +- The current behavior of creating a `gid:` portal is acceptable as a temporary measure +- When CloudKit sync later reveals the chat record with matching participants, the bridge should detect the duplicate and merge + +--- + +## 6. Recommended Data Model Changes + +### FFI Layer (`WrappedCloudSyncChat`) + +Add these fields: + +```rust +pub struct WrappedCloudSyncChat { + // ... existing fields ... + pub original_group_id: String, // ogid — expose for chain analysis + pub legacy_group_identifiers: Vec, // from prop field + pub guid: String, // CloudKit chat guid +} +``` + +### Database (`cloud_chat` table) + +```sql +ALTER TABLE cloud_chat ADD COLUMN original_group_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE cloud_chat ADD COLUMN style INTEGER NOT NULL DEFAULT 0; + +-- Index for ogid lookups (chain resolution) +CREATE INDEX cloud_chat_ogid_idx + ON cloud_chat (login_id, original_group_id) + WHERE original_group_id <> ''; +``` + +### Portal Metadata + +Already includes `sender_guid` and `group_name` — no changes needed. + +### New: Group UUID Alias Table + +For robust reconciliation, consider a mapping table: + +```sql +CREATE TABLE group_uuid_alias ( + login_id TEXT NOT NULL, + alias_uuid TEXT NOT NULL, -- any UUID seen for this group + canonical_uuid TEXT NOT NULL, -- the CloudKit group_id + source TEXT NOT NULL, -- 'cloudkit', 'apns', 'ogid', 'legacy' + created_ts BIGINT NOT NULL, + PRIMARY KEY (login_id, alias_uuid) +); + +CREATE INDEX group_uuid_alias_canonical_idx + ON group_uuid_alias (login_id, canonical_uuid); +``` + +When a real-time message arrives with `sender_guid = X`: +1. Check `group_uuid_alias` for `alias_uuid = X` +2. If found, use `canonical_uuid` as the portal ID +3. If not found, create portal with `gid:X` and note it as unresolved +4. When CloudKit syncs, populate aliases from `ogid` chains and `legacy_group_identifiers` + +--- + +## 7. Immediate Action Items + +1. **Expose `original_group_id` through FFI** — Add to `WrappedCloudSyncChat`, store in DB, analyze real data to understand the chain structure. + +2. **Expose `legacy_group_identifiers`** — Same treatment. These may contain the mapping we need. + +3. **Add `style` to cloud_chat table** — Currently not stored; needed to distinguish groups from DMs authoritatively. + +4. **Audit outbound sender_guid flow** — Verify that every code path providing a `WrappedConversation` for group sends includes the correct `sender_guid`. Add logging/assertions in `prepare_send()` for group conversations with no sender_guid. + +5. **Build reconciliation logic** — After exposing `ogid` and `legacy_group_identifiers`, implement the alias table and reconciliation during CloudKit sync. + +6. **Fix the 3 orphaned portals** — Manual or automated migration to redirect `gid:2f787cd8-...` and `gid:4b62c57d-...` to the canonical `gid:6bc815c2-...` portal. + +--- + +## Appendix: Source File Reference + +| File | Purpose | +|------|---------| +| `rustpush/src/imessage/cloud_messages.rs:231` | `CloudChat` struct — all CloudKit chat fields | +| `rustpush/src/imessage/messages.rs:31` | `ConversationData` struct — real-time message identity | +| `rustpush/src/imessage/messages.rs:1959` | `prepare_send()` — UUID generation when sender_guid is None | +| `rustpush/src/imessage/rawmessages.rs` | Raw APNs message structs — `gid` field mapping | +| `pkg/rustpushgo/src/lib.rs:762` | `WrappedCloudSyncChat` — FFI struct (missing `ogid`) | +| `pkg/rustpushgo/src/lib.rs:847` | `message_inst_to_wrapped()` — sender_guid propagation | +| `pkg/connector/client.go:2174` | `makePortalKey()` — portal ID resolution for real-time | +| `pkg/connector/client.go:2345` | `portalToConversation()` — outbound message routing | +| `pkg/connector/sync_controller.go:345` | `resolvePortalIDForCloudChat()` — CloudKit portal ID resolution | +| `pkg/connector/cloud_backfill_store.go:100` | DB schema — cloud_chat table | diff --git a/example-config.yaml b/example-config.yaml deleted file mode 100644 index 0097a3ad..00000000 --- a/example-config.yaml +++ /dev/null @@ -1,369 +0,0 @@ -# Homeserver details. -homeserver: - # The address that this appservice can use to connect to the homeserver. - address: https://matrix.example.com - # The address to mautrix-wsproxy (which should usually be next to the homeserver behind a reverse proxy). - # Only the /_matrix/client/unstable/fi.mau.as_sync websocket endpoint is used on this address. - # - # Set to null to disable using the websocket. When not using the websocket, make sure hostname and port are set in the appservice section. - websocket_proxy: wss://matrix.example.com - # How often should the websocket be pinged? Pinging will be disabled if this is zero. - ping_interval_seconds: 0 - # The domain of the homeserver (also known as server_name, used for MXIDs, etc). - domain: example.com - - # What software is the homeserver running? - # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here. - software: standard - # Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246? - async_media: false - -# Application service host/registration related details. -# Changing these values requires regeneration of the registration. -appservice: - # The hostname and port where this appservice should listen. - # The default method of deploying mautrix-imessage is using a websocket proxy, so it doesn't need a http server - # To use a http server instead of a websocket, set websocket_proxy to null in the homeserver section, - # and set the port below to a real port. - hostname: 0.0.0.0 - port: null - # Optional TLS certificates to listen for https instead of http connections. - tls_key: null - tls_cert: null - - # Database config. - database: - # The database type. Only "sqlite3-fk-wal" is supported. - type: sqlite3-fk-wal - # SQLite database path. A raw file path is supported, but `file:?_txlock=immediate` is recommended. - uri: file:mautrix-imessage.db?_txlock=immediate - - # The unique ID of this appservice. - id: imessage - # Appservice bot details. - bot: - # Username of the appservice bot. - username: imessagebot - # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty - # to leave display name/avatar as-is. - displayname: iMessage bridge bot - avatar: mxc://maunium.net/tManJEpANASZvDVzvRvhILdX - - # Whether or not to receive ephemeral events via appservice transactions. - # Requires MSC2409 support (i.e. Synapse 1.22+). - ephemeral_events: true - - # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. - as_token: "This value is generated when generating the registration" - hs_token: "This value is generated when generating the registration" - -# iMessage connection config -imessage: - # Available platforms: - # * mac: Standard Mac connector, requires full disk access and will ask for AppleScript and contacts permission. - # * ios: Jailbreak iOS connector when using with Brooklyn. - # * android: Equivalent to ios, but for use with the Android SMS wrapper app. - # * mac-nosip: Mac without SIP connector, runs Barcelona as a subprocess. - platform: mac - # Path to the Barcelona executable for the mac-nosip connector - imessage_rest_path: darwin-barcelona-mautrix - # Additional arguments to pass to the mac-nosip connector - imessage_rest_args: [] - # The mode for fetching contacts in the no-SIP connector. - # The default mode is `ipc` which will ask Barcelona. However, recent versions of Barcelona have removed contact support. - # You can specify `mac` to use Contacts.framework directly instead of through Barcelona. - # You can also specify `disable` to not try to use contacts at all. - contacts_mode: ipc - # Whether to log the contents of IPC payloads - log_ipc_payloads: false - # For the no-SIP connector, hackily set the user account locale before starting Barcelona. - hacky_set_locale: null - # A list of environment variables to add for the Barcelona process (as NAME=value strings) - environment: [] - # Path to unix socket for Barcelona communication. - unix_socket: mautrix-imessage.sock - # Interval to ping Barcelona at. The process will exit if Barcelona doesn't respond in time. - ping_interval_seconds: 15 - # Should media on disk be deleted after bridging to Matrix? - delete_media_after_upload: false - - bluebubbles_url: - bluebubbles_password: - -# Segment settings for collecting some debug data. -segment: - key: null - user_id: null - -hacky_startup_test: - identifier: null - message: null - response_message: null - key: null - echo_mode: false - send_on_startup: false - periodic_resolve: -1 - -# Bridge config -bridge: - # The user of the bridge. - user: "@you:example.com" - - # Localpart template of MXIDs for iMessage users. - # {{.}} is replaced with the phone number or email of the iMessage user. - username_template: imessage_{{.}} - # Displayname template for iMessage users. - # {{.}} is replaced with the contact list name (if available) or username (phone number or email) of the iMessage user. - displayname_template: "{{.}} (iMessage)" - # Should the bridge create a space and add bridged rooms to it? - personal_filtering_spaces: false - - # Whether or not the bridge should send a read receipt from the bridge bot when a message has been - # sent to iMessage. - delivery_receipts: false - # Whether or not the bridge should send the message status as a custom - # com.beeper.message_send_status event. - message_status_events: true - # Whether or not the bridge should send error notices via m.notice events - # when a message fails to bridge. - send_error_notices: true - # The maximum number of seconds between the message arriving at the - # homeserver and the bridge attempting to send the message. This can help - # prevent messages from being bridged a long time after arriving at the - # homeserver which could cause confusion in the chat history on the remote - # network. Set to 0 to disable. - max_handle_seconds: 0 - # Device ID to include in m.bridge data, read by client-integrated Android SMS. - # Not relevant for standalone bridges nor iMessage. - device_id: null - # Whether or not to update the m.direct account data event when double puppeting is enabled. - # Note that updating the m.direct event is not atomic (except with mautrix-asmux) - # and is therefore prone to race conditions. - sync_direct_chat_list: false - # Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth - # - # If set, double puppeting will be enabled automatically instead of the user - # having to find an access token and run `login-matrix` manually. - login_shared_secret: null - # Homeserver URL for the double puppet. If null, will use the URL set in homeserver -> address - double_puppet_server_url: null - # Backfill settings - backfill: - # Should backfilling be enabled at all? - enable: true - # Maximum number of messages to backfill for new portal rooms. - initial_limit: 100 - # Maximum age of chats to sync in days. - initial_sync_max_age: 0.5 - # If a backfilled chat is older than this number of hours, mark it as read even if it's unread on iMessage. - # Set to -1 to let any chat be unread. - unread_hours_threshold: 720 - - ######################################################################### - # The settings below are only applicable if you are: # - # # - # 1. Using batch sending, which is no longer supported in Synapse. # - # 2. Running the bridge in backfill-only mode connecting to another # - # instance for portal creation via websocket commands. # - # # - # In other words, unless you are Beeper, the rest of the backfill # - # section very likely does not apply to you. # - ######################################################################### - # Is this bridge only meant for backfilling chats? - only_backfill: false - - # Settings for immediate backfills. These backfills should generally be small and their main purpose is - # to populate each of the initial chats (as configured by max_initial_conversations) with a few messages - # so that you can continue conversations without losing context. - immediate: - # The maximum number of events to backfill initially. - max_events: 25 - # Settings for deferred backfills. The purpose of these backfills are to fill in the rest of - # the chat history that was not covered by the immediate backfills. - # These backfills generally should happen at a slower pace so as not to overload the homeserver. - # Each deferred backfill config should define a "stage" of backfill (i.e. the last week of messages). - # The fields are as follows: - # - start_days_ago: the number of days ago to start backfilling from. - # To indicate the start of time, use -1. For example, for a week ago, use 7. - # - max_batch_events: the number of events to send per batch. - # - batch_delay: the number of seconds to wait before backfilling each batch. - deferred: - # Last Week - - start_days_ago: 7 - max_batch_events: 50 - batch_delay: 5 - # Last Month - - start_days_ago: 30 - max_batch_events: 100 - batch_delay: 10 - # Last 3 months - - start_days_ago: 90 - max_batch_events: 250 - batch_delay: 10 - # The start of time - - start_days_ago: -1 - max_batch_events: 500 - batch_delay: 10 - - # Whether or not the bridge should periodically resync chat and contact info. - periodic_sync: true - # Should the bridge look through joined rooms to find existing portals if the database has none? - # This can be used to recover from bridge database loss. - find_portals_if_db_empty: false - # Media viewer settings. See https://gitlab.com/beeper/media-viewer for more info. - # Used to send media viewer links instead of full files for attachments that are too big for MMS. - media_viewer: - # The address to the media viewer. If null, media viewer links will not be used. - url: null - # The homeserver domain to pass to the media viewer to use for downloading media. - # If null, will use the server name configured in the homeserver section. - homeserver: null - # The minimum number of bytes in a file before the bridge switches to using the media viewer when sending MMS. - # Note that for unencrypted files, this will use a direct link to the homeserver rather than the media viewer. - sms_min_size: 409600 - # Same as above, but for iMessages. - imessage_min_size: 52428800 - # Template text when inserting media viewer URLs. - # %s is replaced with the actual URL. - template: "Full size attachment: %s" - # Should we convert heif images to jpeg before re-uploading? This increases - # compatibility, but adds generation loss (reduces quality). - convert_heif: true - # Should we convert tiff images to jpeg before re-uploading? This increases - # compatibility, but adds generation loss (reduces quality). - convert_tiff: true - # Modern Apple devices tend to use h265 encoding for video, which is a licensed standard and therefore not - # supported by most major browsers. If enabled, all video attachments will be converted according to the - # ffmpeg args. - convert_video: - enabled: false - # Convert to h264 format (supported by all major browsers) at decent quality while retaining original - # audio. Modify these args to do whatever encoding/quality you want. - ffmpeg_args: ["-c:v", "libx264", "-preset", "faster", "-crf", "22", "-c:a", "copy"] - extension: "mp4" - mime_type: "video/mp4" - # The prefix for commands. - command_prefix: "!im" - # Should we rewrite the sender in a DM to match the chat GUID? - # This is helpful when the sender ID shifts depending on the device they use, since - # the bridge is unable to add participants to the chat post-creation. - force_uniform_dm_senders: true - # Should SMS chats always be in the same room as iMessage chats with the same phone number? - disable_sms_portals: false - # iMessage has weird IDs for group chats, so getting all messages in the same MMS group chat into the same Matrix room - # may require rerouting some messages based on the fake ReplyToGUID that iMessage adds. - reroute_mms_group_replies: false - # Whether or not created rooms should have federation enabled. - # If false, created portal rooms will never be federated. - federate_rooms: true - # Send captions in the same message as images using MSC2530? - # This is currently not supported in most clients. - caption_in_message: false - # Whether to explicitly set the avatar and room name for private chat portal rooms. - # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms. - # If set to `always`, all DM rooms will have explicit names and avatars set. - # If set to `never`, DM rooms will never have names and avatars set. - private_chat_portal_meta: default - - # End-to-bridge encryption support options. - # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html - encryption: - # Allow encryption, work in group chat rooms with e2ee enabled - allow: false - # Default to encryption, force-enable encryption in all portals the bridge creates - # This will cause the bridge bot to be in private chats for the encryption to work properly. - default: false - # Whether or not to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data. - appservice: false - # Require encryption, drop any unencrypted messages. - require: false - # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. - # You must use a client that supports requesting keys from other users to use this feature. - allow_key_sharing: false - # Options for deleting megolm sessions from the bridge. - delete_keys: - # Beeper-specific: delete outbound sessions when hungryserv confirms - # that the user has uploaded the key to key backup. - delete_outbound_on_ack: false - # Don't store outbound sessions in the inbound table. - dont_store_outbound: false - # Ratchet megolm sessions forward after decrypting messages. - ratchet_on_decrypt: false - # Delete fully used keys (index >= max_messages) after decrypting messages. - delete_fully_used_on_decrypt: false - # Delete previous megolm sessions from same device when receiving a new one. - delete_prev_on_new_session: false - # Delete megolm sessions received from a device when the device is deleted. - delete_on_device_delete: false - # Periodically delete megolm sessions when 2x max_age has passed since receiving the session. - periodically_delete_expired: false - # What level of device verification should be required from users? - # - # Valid levels: - # unverified - Send keys to all device in the room. - # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. - # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). - # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. - # Note that creating user signatures from the bridge bot is not currently possible. - # verified - Require manual per-device verification - # (currently only possible by modifying the `trust` column in the `crypto_device` database table). - verification_levels: - # Minimum level for which the bridge should send keys to when bridging messages from WhatsApp to Matrix. - receive: unverified - # Minimum level that the bridge should accept for incoming Matrix messages. - send: unverified - # Minimum level that the bridge should require for accepting key requests. - share: cross-signed-tofu - # Options for Megolm room key rotation. These options allow you to - # configure the m.room.encryption event content. See: - # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for - # more information about that event. - rotation: - # Enable custom Megolm room key rotation settings. Note that these - # settings will only apply to rooms created after this option is - # set. - enable_custom: false - # The maximum number of milliseconds a session should be used - # before changing it. The Matrix spec recommends 604800000 (a week) - # as the default. - milliseconds: 604800000 - # The maximum number of messages that should be sent with a given a - # session before changing it. The Matrix spec recommends 100 as the - # default. - messages: 100 - - # Disable rotating keys when a user's devices change? - # You should not enable this option unless you understand all the implications. - disable_device_change_key_rotation: false - - # Settings for relay mode - relay: - # Whether relay mode should be allowed. - enabled: false - # A list of user IDs and server names who are allowed to be relayed through this bridge. Use * to allow everyone. - whitelist: [] - # The formats to use when relaying messages to iMessage. - message_formats: - m.text: "{{ .Sender.Displayname }}: {{ .Message }}" - m.notice: "{{ .Sender.Displayname }}: {{ .Message }}" - m.emote: "* {{ .Sender.Displayname }} {{ .Message }}" - m.file: "{{ .Sender.Displayname }} sent a file: {{ .FileName }}" - m.image: "{{ .Sender.Displayname }} sent an image: {{ .FileName }}" - m.audio: "{{ .Sender.Displayname }} sent an audio file: {{ .FileName }}" - m.video: "{{ .Sender.Displayname }} sent a video: {{ .FileName }}" - -# Logging config. See https://github.com/tulir/zeroconfig for details. -logging: - min_level: debug - writers: - - type: stdout - format: pretty-colored - - type: file - format: json - filename: ./logs/mautrix-imessage.log - max_size: 100 - max_backups: 10 - compress: true - -# This may be used by external config managers. mautrix-imessage does not read it, but will carry it across configuration migrations. -revision: 0 diff --git a/example-registration.yaml b/example-registration.yaml deleted file mode 100644 index dc5c981d..00000000 --- a/example-registration.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# appservice -> id from the config -id: imessage -# appservice -> as_token and hs_token from the config -as_token: random string -hs_token: random string -namespaces: - users: - # The localpart here is username_template from the config, but .+ instead of {{.}} - - regex: '@imessage_.+:example\.com' - exclusive: true - # Localpart here is appservice -> bot -> username from the config - - regex: '@imessagebot:example\.com' - exclusive: true -# Address that Synapse uses to contact mautrix-wsproxy, this might be -# something like "http://mautrix-wsproxy:29331" in a docker-compose -# setup or "http://localhost:29331" on bare metal; if using Docker you -# should make sure your networking is setup so this address is -# reachable from from inside the synapse container -url: "http://wsproxy.address:29331" -# Put a new random string here, it doesn't affect anything else -sender_localpart: random string -rate_limited: false diff --git a/findrooms.go b/findrooms.go deleted file mode 100644 index a3edfc3f..00000000 --- a/findrooms.go +++ /dev/null @@ -1,142 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "errors" - "fmt" - "strings" - "time" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -// FindPortalsFromMatrix finds portal rooms that the bridge bot is in and puts them in the local database. -func (br *IMBridge) FindPortalsFromMatrix() error { - br.Log.Infoln("Finding portal rooms from Matrix...") - resp, err := br.Bot.JoinedRooms() - if err != nil { - return fmt.Errorf("failed to get joined rooms: %w", err) - } - foundPortals := 0 - for _, roomID := range resp.JoinedRooms { - var roomState mautrix.RoomStateMap - // Bypass IntentAPI here, we don't want it to try to join any rooms. - roomState, err = br.Bot.Client.State(roomID) - if errors.Is(err, mautrix.MNotFound) || errors.Is(err, mautrix.MForbidden) { - // Expected error, just debug log and skip - br.Log.Debugfln("Skipping %s: failed to get room state (%v)", roomID, err) - } else if err != nil { - // Unexpected error, log warning - br.Log.Warnfln("Skipping %s: failed to get room state (%v)", roomID, err) - } else if br.findPortal(roomID, roomState) { - foundPortals++ - } - } - br.Log.Infofln("Portal finding completed, found %d portals", foundPortals) - return nil -} - -func (br *IMBridge) findPortal(roomID id.RoomID, state mautrix.RoomStateMap) bool { - if existingPortal := br.GetPortalByMXID(roomID); existingPortal != nil { - br.Log.Debugfln("Skipping %s: room is already a registered portal", roomID) - } else if bridgeInfo, err := br.findBridgeInfo(state); err != nil { - br.Log.Debugfln("Skipping %s: %s", roomID, err) - } else if err = br.checkMembers(state); err != nil { - br.Log.Debugfln("Skipping %s (to %s): %s", roomID, bridgeInfo.Channel.GUID, err) - } else if portal := br.GetPortalByGUID(bridgeInfo.Channel.GUID); len(portal.MXID) > 0 { - br.Log.Debugfln("Skipping %s (to %s): portal to chat already exists (%s)", roomID, portal.GUID, portal.MXID) - } else { - encryptionEvent, ok := state[event.StateEncryption][""] - isEncrypted := ok && encryptionEvent.Content.AsEncryption().Algorithm == id.AlgorithmMegolmV1 - if !isEncrypted && br.Config.Bridge.Encryption.Default { - br.Log.Debugfln("Skipping %s (to %s): room is not encrypted, but encryption is enabled by default", roomID, portal.GUID) - return false - } - - portal.MXID = roomID - portal.Name = bridgeInfo.Channel.DisplayName - portal.AvatarURL = bridgeInfo.Channel.AvatarURL.ParseOrIgnore() - portal.ThreadID = bridgeInfo.Channel.ThreadID - portal.Encrypted = isEncrypted - // TODO find last message timestamp somewhere - portal.BackfillStartTS = time.Now().UnixMilli() - portal.Update(nil) - br.Log.Infofln("Found portal %s to %s", roomID, portal.GUID) - return true - } - return false -} - -func (br *IMBridge) checkMembers(state mautrix.RoomStateMap) error { - members, ok := state[event.StateMember] - if !ok { - return errors.New("didn't find member list") - } - bridgeBotMember, ok := members[br.Bot.UserID.String()] - if !ok || bridgeBotMember.Content.AsMember().Membership != event.MembershipJoin { - return fmt.Errorf("bridge bot %s is not joined", br.Bot.UserID) - } - userMember, ok := members[br.user.MXID.String()] - if !ok || userMember.Content.AsMember().Membership != event.MembershipJoin { - return fmt.Errorf("user %s is not joined", br.user.MXID) - } - return nil -} - -func (br *IMBridge) findBridgeInfo(state mautrix.RoomStateMap) (*CustomBridgeInfoContent, error) { - evts, ok := state[event.StateBridge] - if !ok { - return nil, errors.New("no bridge info events found") - } - var highestTs int64 - var foundEvt *event.Event - for _, evt := range evts { - // Check that the state key is somewhat expected - if strings.HasPrefix(*evt.StateKey, bridgeInfoProto+"://") && - // Must be sent by the bridge bot or a ghost user - br.isBridgeOwnedMXID(evt.Sender) && - // Theoretically we might want to change the state key, so get the one with the highest timestamp - evt.Timestamp > highestTs { - - foundEvt = evt - highestTs = evt.Timestamp - } - } - if foundEvt == nil { - return nil, errors.New("no valid bridge info event found") - } - content, ok := foundEvt.Content.Parsed.(*CustomBridgeInfoContent) - if !ok { - br.Log.Debugfln("Content of %s: %s", foundEvt.ID, foundEvt.Content.VeryRaw) - return nil, fmt.Errorf("bridge info event %s has unexpected content (%T)", foundEvt.ID, foundEvt.Content.Parsed) - } else if content.BridgeBot != br.Bot.UserID { - return nil, fmt.Errorf("bridge info event %s has unexpected bridge bot (%s)", foundEvt.ID, content.BridgeBot) - } else if len(content.Channel.GUID) == 0 { - return nil, fmt.Errorf("bridge info event %s is missing the iMessage chat GUID", foundEvt.ID) - } - return content, nil -} - -func (br *IMBridge) isBridgeOwnedMXID(userID id.UserID) bool { - if userID == br.Bot.UserID { - return true - } - return br.IsGhost(userID) -} diff --git a/go.mod b/go.mod index dcac32da..d56d5c01 100644 --- a/go.mod +++ b/go.mod @@ -1,41 +1,48 @@ -module go.mau.fi/mautrix-imessage +module github.com/lrhodin/imessage -go 1.22.0 +go 1.25.0 -toolchain go1.23.3 +toolchain go1.25.9 require ( + github.com/beeper/bridge-manager v0.14.0 github.com/fsnotify/fsnotify v1.8.0 github.com/gabriel-vasile/mimetype v1.4.7 - github.com/gorilla/websocket v1.5.0 - github.com/mattn/go-sqlite3 v1.14.24 - github.com/rs/zerolog v1.31.0 - github.com/sahilm/fuzzy v0.1.0 - github.com/strukturag/libheif v1.19.5 - github.com/tidwall/gjson v1.17.0 - go.mau.fi/util v0.2.1 - golang.org/x/crypto v0.29.0 - golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f - golang.org/x/image v0.22.0 - maunium.net/go/mauflag v1.0.0 + github.com/mattn/go-sqlite3 v1.14.34 + github.com/rs/zerolog v1.34.0 + github.com/urfave/cli/v2 v2.27.7 + go.mau.fi/util v0.9.6 + golang.org/x/image v0.38.0 + gopkg.in/yaml.v3 v3.0.1 maunium.net/go/maulogger/v2 v2.4.1 - maunium.net/go/mautrix v0.16.1-0.20241127170113-4b4e60da048d + maunium.net/go/mautrix v0.26.3 ) require ( - github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/gorilla/mux v1.8.0 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect - github.com/lib/pq v1.10.9 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/coreos/go-systemd/v22 v22.6.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/lib/pq v1.11.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect + github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/yuin/goldmark v1.6.0 // indirect - go.mau.fi/zeroconfig v0.1.2 // indirect - golang.org/x/net v0.31.0 // indirect - golang.org/x/sys v0.27.0 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/yuin/goldmark v1.7.16 // indirect + go.mau.fi/zeroconfig v0.2.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + maunium.net/go/mauflag v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index c18339cf..f0a09a18 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,16 @@ -github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= -github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/beeper/bridge-manager v0.14.0 h1:7XeZfHeDiOuwLUe6UiX/HCywthw1s0Q7xhrmDzzW9FA= +github.com/beeper/bridge-manager v0.14.0/go.mod h1:pherlTADz3wkojdc2AvAsR3mS1yG5jF9/OaxkHqPy4Y= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= @@ -9,61 +18,75 @@ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= -github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= -github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= -github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/strukturag/libheif v1.19.5 h1:YLf0RO6mNQYeeNkFSk/Uto8EyUOCzDpLPjbEBoeV9Io= -github.com/strukturag/libheif v1.19.5/go.mod h1:E/PNRlmVtrtj9j2AvBZlrO4dsBDu6KfwDZn7X1Ce8Ks= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= -github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68= -github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mau.fi/util v0.2.1 h1:eazulhFE/UmjOFtPrGg6zkF5YfAyiDzQb8ihLMbsPWw= -go.mau.fi/util v0.2.1/go.mod h1:MjlzCQEMzJ+G8RsPawHzpLB8rwTo3aPIjG5FzBvQT/c= -go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto= -go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= -golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= -golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= -golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= -golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= +github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= +go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= +go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= +go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= +golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= @@ -74,5 +97,5 @@ maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8= maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho= -maunium.net/go/mautrix v0.16.1-0.20241127170113-4b4e60da048d h1:pZ2XsCIjSHGOA5Ey1aoOqx7meAiqPy1owiABOBlVxqs= -maunium.net/go/mautrix v0.16.1-0.20241127170113-4b4e60da048d/go.mod h1:XAjE9pTSGcr6vXaiNgQGiip7tddJ8FQV1a29u2QdBG4= +maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk= +maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38= diff --git a/heif.go b/heif.go deleted file mode 100644 index 8cef8796..00000000 --- a/heif.go +++ /dev/null @@ -1,65 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build libheif - -package main - -import ( - "bufio" - "bytes" - "fmt" - "image/jpeg" - - "github.com/strukturag/libheif/go/heif" -) - -const CanConvertHEIF = true - -func ConvertHEIF(data []byte) ([]byte, error) { - ctx, err := heif.NewContext() - if err != nil { - return nil, fmt.Errorf("can't create context: %s", err) - } - - if err := ctx.ReadFromMemory(data); err != nil { - return nil, fmt.Errorf("can't read from memory: %s", err) - } - - handle, err := ctx.GetPrimaryImageHandle() - if err != nil { - return nil, fmt.Errorf("can't read primary image: %s", err) - } - - heifImg, err := handle.DecodeImage(heif.ColorspaceUndefined, heif.ChromaUndefined, nil) - if err != nil { - return nil, fmt.Errorf("can't decode image: %s", err) - } - - img, err := heifImg.GetImage() - if err != nil { - return nil, fmt.Errorf("can't convert image: %s", err) - } - - var output bytes.Buffer - - err = jpeg.Encode(bufio.NewWriter(&output), img, nil) - if err != nil { - return nil, fmt.Errorf("Failed to encode: %v", err) - } - - return output.Bytes(), nil -} diff --git a/historysync.go b/historysync.go deleted file mode 100644 index 7ffc2384..00000000 --- a/historysync.go +++ /dev/null @@ -1,517 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "fmt" - "runtime/debug" - "time" - - "go.mau.fi/util/dbutil" - "golang.org/x/exp/slices" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/database" - "go.mau.fi/mautrix-imessage/imessage" -) - -var ( - PortalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType} - BackfillStatusEvent = event.Type{Type: "com.beeper.backfill_status", Class: event.StateEventType} -) - -func (user *User) handleHistorySyncsLoop(ctx context.Context) { - if !user.bridge.Config.Bridge.Backfill.OnlyBackfill || !user.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) { - user.log.Infofln("Not backfilling history since OnlyBackfill is disabled") - return - } - - // Start the backfill queue. - user.BackfillQueue = &BackfillQueue{ - BackfillQuery: user.bridge.DB.Backfill, - reCheckChannel: make(chan bool), - log: user.log.Sub("BackfillQueue"), - } - - // Handle all backfills in the same loop. Since new chats will not need to - // be handled by this loop, priority is all that is needed. - go user.HandleBackfillRequestsLoop(ctx) -} - -func (portal *Portal) lockBackfill() { - portal.backfillLock.Lock() - portal.backfillWait.Wait() - portal.backfillWait.Add(1) - select { - case portal.backfillStart <- struct{}{}: - default: - } -} - -func (portal *Portal) unlockBackfill() { - portal.backfillWait.Done() - portal.backfillLock.Unlock() -} - -func (portal *Portal) forwardBackfill() { - defer func() { - if err := recover(); err != nil { - portal.log.Errorfln("Panic while backfilling: %v\n%s", err, string(debug.Stack())) - } - }() - - var messages []*imessage.Message - var err error - var backfillID string - lastMessage := portal.bridge.DB.Message.GetLastInChat(portal.GUID) - if lastMessage == nil && portal.BackfillStartTS == 0 { - if portal.bridge.Config.Bridge.Backfill.InitialLimit <= 0 { - portal.log.Debugfln("Not backfilling: initial limit is 0") - return - } - portal.log.Debugfln("Fetching up to %d messages for initial backfill", portal.bridge.Config.Bridge.Backfill.InitialLimit) - backfillID = fmt.Sprintf("bridge-initial-%s::%d", portal.Identifier.LocalID, time.Now().UnixMilli()) - messages, err = portal.bridge.IM.GetMessagesWithLimit(portal.GUID, portal.bridge.Config.Bridge.Backfill.InitialLimit, backfillID) - } else if lastMessage != nil { - portal.log.Debugfln("Fetching messages since %s for catchup backfill", lastMessage.Time().String()) - backfillID = fmt.Sprintf("bridge-catchup-msg-%s::%s::%d", portal.Identifier.LocalID, lastMessage.GUID, time.Now().UnixMilli()) - messages, err = portal.bridge.IM.GetMessagesSinceDate(portal.GUID, lastMessage.Time().Add(1*time.Millisecond), backfillID) - } else if portal.BackfillStartTS != 0 { - startTime := time.UnixMilli(portal.BackfillStartTS) - portal.log.Debugfln("Fetching messages since %s for catchup backfill after portal recovery", startTime.String()) - backfillID = fmt.Sprintf("bridge-catchup-ts-%s::%d::%d", portal.Identifier.LocalID, startTime.UnixMilli(), time.Now().UnixMilli()) - messages, err = portal.bridge.IM.GetMessagesSinceDate(portal.GUID, startTime, backfillID) - } - allSkipped := true - for index, msg := range messages { - if portal.bridge.DB.Message.GetByGUID(msg.ChatGUID, msg.GUID, 0) != nil { - portal.log.Debugfln("Skipping duplicate message %s at start of forward backfill batch", msg.GUID) - continue - } - allSkipped = false - messages = messages[index:] - break - } - if err != nil { - portal.log.Errorln("Failed to fetch messages for backfilling:", err) - go portal.bridge.IM.SendBackfillResult(portal.GUID, backfillID, false, nil) - } else if len(messages) == 0 || allSkipped { - portal.log.Debugln("Nothing to backfill") - } else { - portal.sendBackfill(backfillID, messages, true, false, false) - } -} - -func (portal *Portal) deterministicEventID(messageID string, partIndex int) id.EventID { - data := fmt.Sprintf("%s/imessage/%s/%d", portal.MXID, messageID, partIndex) - sum := sha256.Sum256([]byte(data)) - domain := "imessage.apple.com" - if portal.bridge.Config.IMessage.Platform == "android" { - domain = "sms.android.local" - } - return id.EventID(fmt.Sprintf("$%s:%s", base64.RawURLEncoding.EncodeToString(sum[:]), domain)) -} - -type messageWithIndex struct { - *imessage.Message - Intent *appservice.IntentAPI - TapbackTarget *database.Message - Index int -} - -type messageIndex struct { - GUID string - Index int -} - -func (portal *Portal) convertBackfill(messages []*imessage.Message) ([]*event.Event, []messageWithIndex, map[messageIndex]int, bool, error) { - events := make([]*event.Event, 0, len(messages)) - metas := make([]messageWithIndex, 0, len(messages)) - metaIndexes := make(map[messageIndex]int, len(messages)) - unreadThreshold := time.Duration(portal.bridge.Config.Bridge.Backfill.UnreadHoursThreshold) * time.Hour - var isRead bool - for _, msg := range messages { - if msg.Tapback != nil { - continue - } - - intent := portal.getIntentForMessage(msg, nil) - converted := portal.convertiMessage(msg, intent) - for index, conv := range converted { - evt := &event.Event{ - Sender: intent.UserID, - Type: conv.Type, - Timestamp: msg.Time.UnixMilli(), - Content: event.Content{ - Parsed: conv.Content, - Raw: conv.Extra, - }, - } - var err error - evt.Type, err = portal.encrypt(intent, &evt.Content, evt.Type) - if err != nil { - return nil, nil, nil, false, err - } - intent.AddDoublePuppetValue(&evt.Content) - if portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { - evt.ID = portal.deterministicEventID(msg.GUID, index) - } - - events = append(events, evt) - metas = append(metas, messageWithIndex{msg, intent, nil, index}) - metaIndexes[messageIndex{msg.GUID, index}] = len(metas) - } - isRead = msg.IsRead || msg.IsFromMe || (unreadThreshold >= 0 && time.Since(msg.Time) > unreadThreshold) - } - return events, metas, metaIndexes, isRead, nil -} - -func (portal *Portal) convertTapbacks(messages []*imessage.Message) ([]*event.Event, []messageWithIndex, map[messageIndex]int, bool, error) { - events := make([]*event.Event, 0, len(messages)) - metas := make([]messageWithIndex, 0, len(messages)) - metaIndexes := make(map[messageIndex]int, len(messages)) - unreadThreshold := time.Duration(portal.bridge.Config.Bridge.Backfill.UnreadHoursThreshold) * time.Hour - var isRead bool - for _, msg := range messages { - //Only want tapbacks - if msg.Tapback == nil { - continue - } - - intent := portal.getIntentForMessage(msg, nil) - dbMessage := portal.bridge.DB.Message.GetByGUID(portal.GUID, msg.Tapback.TargetGUID, msg.Tapback.TargetPart) - if dbMessage == nil { - //TODO BUG: This occurs when trying to find the target reaction for a rich link, related to #183 - portal.log.Errorfln("Failed to get target message for tabpack, %+v", msg) - continue - } - - evt := &event.Event{ - Sender: intent.UserID, - Type: event.EventReaction, - Timestamp: msg.Time.UnixMilli(), - Content: event.Content{ - Parsed: &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - EventID: dbMessage.MXID, - Key: msg.Tapback.Type.Emoji(), - }, - }, - }, - } - - intent.AddDoublePuppetValue(&evt.Content) - if portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { - evt.ID = portal.deterministicEventID(msg.GUID, 0) - } - - events = append(events, evt) - metas = append(metas, messageWithIndex{msg, intent, dbMessage, 0}) - metaIndexes[messageIndex{msg.GUID, 0}] = len(metas) - - isRead = msg.IsRead || msg.IsFromMe || (unreadThreshold >= 0 && time.Since(msg.Time) > unreadThreshold) - } - return events, metas, metaIndexes, isRead, nil -} - -func (portal *Portal) sendBackfill(backfillID string, messages []*imessage.Message, forward, forwardIfNoMessages, markAsRead bool) (success bool) { - idMap := make(map[string][]id.EventID, len(messages)) - for _, msg := range messages { - idMap[msg.GUID] = []id.EventID{} - } - defer func() { - err := recover() - if err != nil { - success = false - portal.log.Errorfln("Backfill task panicked: %v\n%s", err, debug.Stack()) - } - portal.bridge.IM.SendBackfillResult(portal.GUID, backfillID, success, idMap) - }() - batchSending := portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) - - var validMessages []*imessage.Message - for _, msg := range messages { - if msg.ItemType != imessage.ItemTypeMessage && msg.Tapback == nil { - portal.log.Debugln("Skipping", msg.GUID, "in backfill (not a message)") - continue - } - intent := portal.getIntentForMessage(msg, nil) - if intent == nil { - portal.log.Debugln("Skipping", msg.GUID, "in backfill (didn't get an intent)") - continue - } - if msg.Tapback != nil && msg.Tapback.Remove { - //If we don't process it, there won't be a reaction; at least for BB, we never have to remove a reaction - portal.log.Debugln("Skipping", msg.GUID, "in backfill (it was a remove tapback)") - continue - } - - validMessages = append(validMessages, msg) - } - - events, metas, metaIndexes, isRead, err := portal.convertBackfill(validMessages) - if err != nil { - portal.log.Errorfln("Failed to convert messages for backfill: %v", err) - return false - } - portal.log.Debugfln("Converted %d messages into %d message events to backfill", len(messages), len(events)) - if len(events) == 0 { - return true - } - - eventIDs, sendErr := portal.sendBackfillToMatrixServer(batchSending, forward, forwardIfNoMessages, markAsRead, isRead, events, metas, metaIndexes) - if sendErr != nil { - return false - } - portal.addBackfillToDB(metas, eventIDs, idMap, backfillID) - - //We have to process tapbacks after all other messages because we need texts in the DB in order to target them - events, metas, metaIndexes, isRead, err = portal.convertTapbacks(validMessages) - if err != nil { - portal.log.Errorfln("Failed to convert tapbacks for backfill: %v", err) - return false - } - portal.log.Debugfln("Converted %d messages into %d tapbacks events to backfill", len(messages), len(events)) - if len(events) == 0 { - return true - } - - eventIDs, sendErr = portal.sendBackfillToMatrixServer(batchSending, forward, forwardIfNoMessages, markAsRead, isRead, events, metas, metaIndexes) - if sendErr != nil { - return false - } - portal.addBackfillToDB(metas, eventIDs, idMap, backfillID) - - portal.log.Infofln("Finished backfill %s", backfillID) - return true -} - -func (portal *Portal) addBackfillToDB(metas []messageWithIndex, eventIDs []id.EventID, idMap map[string][]id.EventID, backfillID string) { - for i, meta := range metas { - idMap[meta.GUID] = append(idMap[meta.GUID], eventIDs[i]) - } - txn, err := portal.bridge.DB.Begin() - if err != nil { - portal.log.Errorln("Failed to start transaction to save batch messages:", err) - } - portal.log.Debugfln("Inserting %d event IDs to database to finish backfill %s", len(eventIDs), backfillID) - portal.finishBackfill(txn, eventIDs, metas) - portal.Update(txn) - err = txn.Commit() - if err != nil { - portal.log.Errorln("Failed to commit transaction to save batch messages:", err) - } -} - -func (portal *Portal) sendBackfillToMatrixServer(batchSending, forward, forwardIfNoMessages, markAsRead, isRead bool, events []*event.Event, metas []messageWithIndex, - metaIndexes map[messageIndex]int) (eventIDs []id.EventID, err error) { - if batchSending { - req := &mautrix.ReqBeeperBatchSend{ - Events: events, - Forward: forward, - ForwardIfNoMessages: forwardIfNoMessages, - } - if isRead || markAsRead { - req.MarkReadBy = portal.bridge.user.MXID - } - resp, err := portal.MainIntent().BeeperBatchSend(portal.MXID, req) - if err != nil { - portal.log.Errorln("Failed to batch send history:", err) - return nil, err - } - eventIDs = resp.EventIDs - } else { - eventIDs = make([]id.EventID, len(events)) - for i, evt := range events { - meta := metas[i] - // Fill reply metadata for messages we just sent - if meta.ReplyToGUID != "" && !meta.ReplyProcessed { - replyIndex, ok := metaIndexes[messageIndex{meta.ReplyToGUID, meta.ReplyToPart}] - if ok && replyIndex > 0 && replyIndex < len(eventIDs) && len(eventIDs[replyIndex]) > 0 { - evt.Content.AsMessage().RelatesTo = (&event.RelatesTo{}).SetReplyTo(eventIDs[replyIndex]) - } - } - resp, err := meta.Intent.SendMassagedMessageEvent(portal.MXID, evt.Type, &evt.Content, evt.Timestamp) - if err != nil { - portal.log.Errorfln("Failed to send event #%d in history: %v", i, err) - return nil, err - } - eventIDs[i] = resp.EventID - } - if (isRead || markAsRead) && portal.bridge.user.DoublePuppetIntent != nil { - lastReadEvent := eventIDs[len(eventIDs)-1] - err := portal.markRead(portal.bridge.user.DoublePuppetIntent, lastReadEvent, time.Time{}) - if err != nil { - portal.log.Warnfln("Failed to mark %s as read with double puppet: %v", lastReadEvent, err) - } - } - } - return eventIDs, nil -} - -func (portal *Portal) finishBackfill(txn dbutil.Transaction, eventIDs []id.EventID, metas []messageWithIndex) { - for i, info := range metas { - if info.Tapback != nil { - if info.Tapback.Remove { - continue - } - dbTapback := portal.bridge.DB.Tapback.New() - dbTapback.PortalGUID = portal.GUID - dbTapback.SenderGUID = info.Sender.String() - dbTapback.MessageGUID = info.TapbackTarget.GUID - dbTapback.MessagePart = info.TapbackTarget.Part - dbTapback.GUID = info.GUID - dbTapback.Type = info.Tapback.Type - dbTapback.MXID = eventIDs[i] - dbTapback.Insert(txn) - } else { - dbMessage := portal.bridge.DB.Message.New() - dbMessage.PortalGUID = portal.GUID - dbMessage.SenderGUID = info.Sender.String() - dbMessage.GUID = info.GUID - dbMessage.Part = info.Index - dbMessage.Timestamp = info.Time.UnixMilli() - dbMessage.MXID = eventIDs[i] - dbMessage.Insert(txn) - } - } -} - -func (user *User) backfillInChunks(req *database.Backfill, portal *Portal) { - if len(portal.MXID) == 0 { - user.log.Errorfln("Portal %s has no room ID, but backfill was requested", portal.GUID) - return - } - portal.Sync(false) - - backfillState := user.bridge.DB.Backfill.GetBackfillState(user.MXID, portal.GUID) - if backfillState == nil { - backfillState = user.bridge.DB.Backfill.NewBackfillState(user.MXID, portal.GUID) - } - backfillState.SetProcessingBatch(true) - defer backfillState.SetProcessingBatch(false) - portal.updateBackfillStatus(backfillState) - - var timeStart = imessage.AppleEpoch - if req.TimeStart != nil { - timeStart = *req.TimeStart - user.log.Debugfln("Limiting backfill to start at %v", timeStart) - } - - var timeEnd time.Time - var forwardIfNoMessages, shouldMarkAsRead bool - if req.TimeEnd != nil { - timeEnd = *req.TimeEnd - user.log.Debugfln("Limiting backfill to end at %v", req.TimeEnd) - forwardIfNoMessages = true - } else { - firstMessage := portal.bridge.DB.Message.GetFirstInChat(portal.GUID) - if firstMessage != nil { - timeEnd = firstMessage.Time().Add(-1 * time.Millisecond) - user.log.Debugfln("Limiting backfill to end at %v", timeEnd) - } else { - // Portal is empty, but no TimeEnd was set. - user.log.Errorln("Portal %s is empty, but no TimeEnd was set", portal.MXID) - return - } - } - - backfillID := fmt.Sprintf("bridge-chunk-%s::%s-%s::%d", portal.GUID, timeStart, timeEnd, time.Now().UnixMilli()) - - // If the message was before the unread hours threshold, mark it as - // read. - lastMessages, err := user.bridge.IM.GetMessagesWithLimit(portal.GUID, 1, backfillID) - if err != nil { - user.log.Errorfln("Failed to get last message from database") - return - } else if len(lastMessages) == 1 { - shouldMarkAsRead = user.bridge.Config.Bridge.Backfill.UnreadHoursThreshold > 0 && - lastMessages[0].Time.Before(time.Now().Add(time.Duration(-user.bridge.Config.Bridge.Backfill.UnreadHoursThreshold)*time.Hour)) - } - - var allMsgs []*imessage.Message - if req.MaxTotalEvents >= 0 { - allMsgs, err = user.bridge.IM.GetMessagesBeforeWithLimit(portal.GUID, timeEnd, req.MaxTotalEvents) - } else { - allMsgs, err = user.bridge.IM.GetMessagesBetween(portal.GUID, timeStart, timeEnd) - } - if err != nil { - user.log.Errorfln("Failed to get messages between %v and %v: %v", req.TimeStart, timeEnd, err) - return - } - - if len(allMsgs) == 0 { - user.log.Debugfln("Not backfilling %s (%v - %v): no bridgeable messages found", portal.GUID, timeStart, timeEnd) - return - } - - user.log.Infofln("Backfilling %d messages in %s, %d messages at a time (queue ID: %d)", len(allMsgs), portal.GUID, req.MaxBatchEvents, req.QueueID) - toBackfill := allMsgs[0:] - for len(toBackfill) > 0 { - var msgs []*imessage.Message - if len(toBackfill) <= req.MaxBatchEvents || req.MaxBatchEvents < 0 { - msgs = toBackfill - toBackfill = nil - } else { - msgs = toBackfill[:req.MaxBatchEvents] - toBackfill = toBackfill[req.MaxBatchEvents:] - } - - if len(msgs) > 0 { - time.Sleep(time.Duration(req.BatchDelay) * time.Second) - user.log.Debugfln("Backfilling %d messages in %s (queue ID: %d)", len(msgs), portal.GUID, req.QueueID) - - // The sendBackfill function wants the messages in order, but the - // queries give it in reversed order. - slices.Reverse(msgs) - portal.sendBackfill(backfillID, msgs, false, forwardIfNoMessages, shouldMarkAsRead) - } - } - user.log.Debugfln("Finished backfilling %d messages in %s (queue ID: %d)", len(allMsgs), portal.GUID, req.QueueID) - - if req.TimeStart == nil && req.TimeEnd == nil { - // If both the start time and end time are nil, then this is the max - // history backfill, so there is no more history to backfill. - backfillState.BackfillComplete = true - backfillState.FirstExpectedTimestamp = uint64(allMsgs[len(allMsgs)-1].Time.UnixMilli()) - backfillState.Upsert() - portal.updateBackfillStatus(backfillState) - } -} - -func (portal *Portal) updateBackfillStatus(backfillState *database.BackfillState) { - backfillStatus := "backfilling" - if backfillState.BackfillComplete { - backfillStatus = "complete" - } - - _, err := portal.bridge.Bot.SendStateEvent(portal.MXID, BackfillStatusEvent, "", map[string]any{ - "status": backfillStatus, - "first_timestamp": backfillState.FirstExpectedTimestamp * 1000, - }) - if err != nil { - portal.log.Errorln("Error sending backfill status event:", err) - } -} diff --git a/imessage.go b/imessage.go deleted file mode 100644 index 65661cef..00000000 --- a/imessage.go +++ /dev/null @@ -1,307 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "time" - - log "maunium.net/go/maulogger/v2" - - "go.mau.fi/mautrix-imessage/imessage" -) - -type iMessageHandler struct { - bridge *IMBridge - log log.Logger - stop chan struct{} -} - -func NewiMessageHandler(bridge *IMBridge) *iMessageHandler { - return &iMessageHandler{ - bridge: bridge, - log: bridge.Log.Sub("iMessage"), - stop: make(chan struct{}), - } -} - -func (imh *iMessageHandler) Start() { - messages := imh.bridge.IM.MessageChan() - readReceipts := imh.bridge.IM.ReadReceiptChan() - typingNotifications := imh.bridge.IM.TypingNotificationChan() - chats := imh.bridge.IM.ChatChan() - contacts := imh.bridge.IM.ContactChan() - messageStatuses := imh.bridge.IM.MessageStatusChan() - backfillTasks := imh.bridge.IM.BackfillTaskChan() - for { - var start time.Time - var thing string - select { - case msg := <-messages: - thing = "message" - start = time.Now() - imh.HandleMessage(msg) - case rr := <-readReceipts: - thing = "read receipt" - start = time.Now() - imh.HandleReadReceipt(rr) - case notif := <-typingNotifications: - thing = "typing notification" - start = time.Now() - imh.HandleTypingNotification(notif) - case chat := <-chats: - thing = "chat" - start = time.Now() - imh.HandleChat(chat) - case contact := <-contacts: - thing = "contact" - start = time.Now() - imh.HandleContact(contact) - case status := <-messageStatuses: - thing = "message status" - start = time.Now() - imh.HandleMessageStatus(status) - case backfillTask := <-backfillTasks: - thing = "backfill task" - start = time.Now() - imh.HandleBackfillTask(backfillTask) - case <-imh.stop: - return - } - imh.log.Debugfln( - "Handled %s in %s (queued: %dm/%dr/%dt/%dch/%dct/%ds/%db)", - thing, time.Since(start), - len(messages), len(readReceipts), len(typingNotifications), len(chats), len(contacts), len(messageStatuses), len(backfillTasks), - ) - } -} - -const PortalBufferTimeout = 10 * time.Second - -func (imh *iMessageHandler) rerouteGroupMMS(portal *Portal, msg *imessage.Message) *Portal { - if !imh.bridge.Config.Bridge.RerouteSMSGroupReplies || - !portal.Identifier.IsGroup || - portal.Identifier.Service != "SMS" || - msg.ReplyToGUID == "" || - portal.MXID != "" { - return portal - } - mergedChatGUID := imh.bridge.DB.MergedChat.Get(portal.GUID) - if mergedChatGUID != "" && mergedChatGUID != portal.GUID { - newPortal := imh.bridge.GetPortalByGUID(mergedChatGUID) - if newPortal.MXID != "" { - imh.log.Debugfln("Rerouted %s from %s to %s based on merged_chat table", msg.GUID, msg.ChatGUID, portal.GUID) - return newPortal - } - } - checkedChatGUIDs := []string{msg.ChatGUID} - isCheckedGUID := func(guid string) bool { - for _, checkedGUID := range checkedChatGUIDs { - if guid == checkedGUID { - return true - } - } - return false - } - replyMsg := msg - for i := 0; i < 20; i++ { - chatGUID := imh.bridge.DB.Message.FindChatByGUID(replyMsg.ReplyToGUID) - if chatGUID != "" && !isCheckedGUID(chatGUID) { - newPortal := imh.bridge.GetPortalByGUID(chatGUID) - if newPortal.MXID != "" { - imh.log.Debugfln("Rerouted %s from %s to %s based on reply metadata (found reply in local db)", msg.GUID, msg.ChatGUID, portal.GUID) - imh.log.Debugfln("Merging %+v -> %s", checkedChatGUIDs, newPortal.GUID) - imh.bridge.DB.MergedChat.Set(nil, newPortal.GUID, checkedChatGUIDs...) - return newPortal - } - checkedChatGUIDs = append(checkedChatGUIDs, chatGUID) - } - var err error - replyMsg, err = imh.bridge.IM.GetMessage(replyMsg.ReplyToGUID) - if err != nil { - imh.log.Warnfln("Failed to get reply target %s for rerouting %s: %v", replyMsg.ReplyToGUID, msg.GUID, err) - break - } - if !isCheckedGUID(replyMsg.ChatGUID) { - newPortal := imh.bridge.GetPortalByGUID(chatGUID) - if newPortal.MXID != "" { - imh.log.Debugfln("Rerouted %s from %s to %s based on reply metadata (got reply msg from connector)", msg.GUID, msg.ChatGUID, portal.GUID) - imh.log.Debugfln("Merging %+v -> %s", checkedChatGUIDs, newPortal.GUID) - imh.bridge.DB.MergedChat.Set(nil, newPortal.GUID, checkedChatGUIDs...) - return newPortal - } - checkedChatGUIDs = append(checkedChatGUIDs, chatGUID) - } - } - imh.log.Debugfln("Didn't find any existing room to reroute %s into (checked portals %+v)", msg.GUID, checkedChatGUIDs) - return portal -} - -func (imh *iMessageHandler) updateChatGUIDByThreadID(portal *Portal, threadID string) *Portal { - if len(portal.MXID) > 0 || !portal.Identifier.IsGroup || threadID == "" || portal.bridge.Config.IMessage.Platform == "android" { - return portal - } - existingByThreadID := imh.bridge.FindPortalsByThreadID(threadID) - if len(existingByThreadID) > 1 { - imh.log.Warnfln("Found multiple portals with thread ID %s (message chat guid: %s)", threadID, portal.GUID) - } else if len(existingByThreadID) == 0 { - // no need to log, this is just an ordinary new group - } else if existingByThreadID[0].MXID != "" { - imh.log.Infofln("Found existing portal %s for thread ID %s, merging %s into it", existingByThreadID[0].GUID, threadID, portal.GUID) - existingByThreadID[0].reIDInto(portal.GUID, portal, true, false) - return existingByThreadID[0] - } else { - imh.log.Infofln("Found existing portal %s for thread ID %s, but it doesn't have a room", existingByThreadID[0].GUID, threadID, portal.GUID) - } - return portal -} - -func (imh *iMessageHandler) getPortalFromMessage(msg *imessage.Message) *Portal { - portal := imh.rerouteGroupMMS(imh.bridge.GetPortalByGUID(msg.ChatGUID), msg) - return portal -} - -func (imh *iMessageHandler) HandleMessage(msg *imessage.Message) { - imh.log.Debugfln("Received incoming message %s in %s (%s)", msg.GUID, msg.ChatGUID, msg.ThreadID) - // TODO trace log - //imh.log.Debugfln("Received incoming message: %+v", msg) - portal := imh.updateChatGUIDByThreadID( - imh.rerouteGroupMMS( - imh.bridge.GetPortalByGUID(msg.ChatGUID), - msg, - ), - msg.ThreadID, - ) - if msg.ThreadID != "" && msg.ThreadID != portal.ThreadID { - portal.log.Infoln("Found portal thread ID in message: %s (prev: %s)", msg.ThreadID, portal.ThreadID) - portal.ThreadID = msg.ThreadID - if len(portal.MXID) > 0 { - portal.Update(nil) - portal.UpdateBridgeInfo() - } - } - if len(portal.MXID) == 0 { - portal.log.Infoln("Creating Matrix room to handle message") - err := portal.CreateMatrixRoom(nil, nil) - if err != nil { - imh.log.Warnfln("Failed to create Matrix room to handle message: %v", err) - return - } - } - select { - case portal.Messages <- msg: - case <-time.After(PortalBufferTimeout): - imh.log.Errorln("Portal message buffer is still full after 10 seconds, dropping message %s", msg.GUID) - } -} - -func (imh *iMessageHandler) HandleMessageStatus(status *imessage.SendMessageStatus) { - portal := imh.bridge.GetPortalByGUID(status.ChatGUID) - if len(portal.MXID) == 0 { - imh.log.Debugfln("Ignoring message status for message from unknown portal %s/%s", status.GUID, status.ChatGUID) - return - } - select { - case portal.MessageStatuses <- status: - case <-time.After(PortalBufferTimeout): - imh.log.Errorln("Portal message status buffer is still full after 10 seconds, dropping %+v", *status) - } -} - -func (imh *iMessageHandler) HandleReadReceipt(rr *imessage.ReadReceipt) { - portal := imh.bridge.GetPortalByGUID(rr.ChatGUID) - if len(portal.MXID) == 0 { - imh.log.Debugfln("Ignoring read receipt in unknown portal %s", rr.ChatGUID) - return - } - select { - case portal.ReadReceipts <- rr: - case <-time.After(PortalBufferTimeout): - imh.log.Errorln("Portal read receipt buffer is still full after 10 seconds, dropping %+v", *rr) - } -} - -func (imh *iMessageHandler) HandleTypingNotification(notif *imessage.TypingNotification) { - portal := imh.bridge.GetPortalByGUID(notif.ChatGUID) - if len(portal.MXID) == 0 { - return - } - _, err := portal.MainIntent().UserTyping(portal.MXID, notif.Typing, 60*time.Second) - if err != nil { - action := "typing" - if !notif.Typing { - action = "not typing" - } - portal.log.Warnln("Failed to mark %s as %s in %s: %v", portal.MainIntent().UserID, action, portal.MXID, err) - } -} - -func (imh *iMessageHandler) HandleChat(chat *imessage.ChatInfo) { - chat.Identifier = imessage.ParseIdentifier(chat.JSONChatGUID) - if chat.Delete { - portal := imh.bridge.GetPortalByGUIDIfExists(chat.Identifier.String()) - if portal != nil { - portal.zlog.Info().Msg("Received delete command, deleting and cleaning up portal...") - portal.Delete() - portal.Cleanup(false) - portal.zlog.Info().Msg("Portal cleanup completed") - } else { - imh.log.Warnfln("Received delete command for unknown portal %s", chat.Identifier.String()) - } - return - } - portal := imh.bridge.GetPortalByGUID(chat.Identifier.String()) - portal = imh.updateChatGUIDByThreadID(portal, chat.ThreadID) - if len(portal.MXID) > 0 { - portal.log.Infoln("Syncing Matrix room to handle chat command") - portal.SyncWithInfo(chat) - } else if !chat.NoCreateRoom { - portal.log.Infoln("Creating Matrix room to handle chat command") - err := portal.CreateMatrixRoom(chat, nil) - if err != nil { - imh.log.Warnfln("Failed to create Matrix room to handle chat command: %v", err) - return - } - } -} - -func (imh *iMessageHandler) HandleBackfillTask(task *imessage.BackfillTask) { - if !imh.bridge.Config.Bridge.Backfill.Enable { - imh.log.Warnfln("Connector sent backfill task, but backfill is disabled in bridge config") - imh.bridge.IM.SendBackfillResult(task.ChatGUID, task.BackfillID, false, nil) - return - } - portal := imh.bridge.GetPortalByGUID(task.ChatGUID) - if len(portal.MXID) == 0 { - portal.log.Errorfln("Tried to backfill chat %s with no portal", portal.GUID) - imh.bridge.IM.SendBackfillResult(portal.GUID, task.BackfillID, false, nil) - return - } - portal.log.Debugfln("Running backfill %s in background", task.BackfillID) - go portal.sendBackfill(task.BackfillID, task.Messages, false, false, false) -} - -func (imh *iMessageHandler) HandleContact(contact *imessage.Contact) { - puppet := imh.bridge.GetPuppetByGUID(contact.UserGUID) - if len(puppet.MXID) > 0 { - puppet.log.Infoln("Syncing Puppet to handle contact command") - puppet.SyncWithContact(contact) - } -} - -func (imh *iMessageHandler) Stop() { - close(imh.stop) -} diff --git a/imessage/bluebubbles/README.md b/imessage/bluebubbles/README.md deleted file mode 100644 index eed398fe..00000000 --- a/imessage/bluebubbles/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# BlueBubbles iMessage Bridge Interface - -## Prerequisties - -1. Ensure you have a Mac System with the Messages App working -1. Install [BlueBubbles Server](https://bluebubbles.app/downloads/server/) on it. - 1. Accept all the defaults while installing the server - 1. Ignore Private API during the initial install, it can be enabled later. -1. (Optional) Enable [BlueBubbles Private API](https://docs.bluebubbles.app/private-api/installation) - 1. Note: This requires disabling System Integrity Protection (SIP), which is not a wise thing to do on a Mac you use out in the world on a regular basis. - 1. Not enabling Private API makes the following features unavailable: - 1. "Tap Backs": aka Emoji Reactions - 1. Please let us know if there's more you find that doesn't work... -1. Familiarize yourself with the [BlueBubbles API](https://documenter.getpostman.com/view/765844/UV5RnfwM#4e5fd735-bd88-41c1-bc8f-96394b91f5e6) - -## Using the Bridge (Start with the Prerequisties!) - -1. Download the most recent `bbctl` from the most recent [GitHub Actions](https://github.com/beeper/bridge-manager/actions) build -1. Move it somewhere on your system that is in your `$PATH`, and `chmod +x bbctl` -1. Run `bbctl login` to login to your Beeper Account -1. Download the most recent `mautrix-imessage` from the [`bluebubbles` branch](https://mau.dev/mautrix/imessage/-/artifacts) -1. Run `bbctl run --custom-startup-command ~/PATH/TO/EXTRACTED/ARTIFACT/mautrix-imessage --param 'imessage_platform=bluebubbles' sh-imessage` -1. When prompted, provide your `Server Address` listed in the BlueBubbles server UI -1. When prompted, provide your `Server Password` listed in the BlueBubbles server UI - -## Development - - - -1. Download the most recent `bbctl` from the most recent [GitHub Actions](https://github.com/beeper/bridge-manager/actions) build -1. Move it somewhere on your system that is in your `$PATH`, and `chmod +x bbctl` -1. Run `bbctl login` to login to your Beeper Account -1. Install [`pre-commit`](https://pre-commit.com/#install) - -### Running Locally - -1. Clone this repository: `git clone git@github.com:mautrix/imessage.git` -1. `cd` into `imessage` -1. Setup `pre-commit` hooks: `pre-commit install` -1. Switch to the `bluebubbles` branch: `git checkout bluebubbles` - 1. This is temporary while we're developing this feature. - 1. You'll know you're in the right spot if you can see this README in your local code. -1. Run the following command to launch the bridge in development mode: - -```bash -bbctl run --local-dev --param 'bluebubbles_url=' --param 'bluebubbles_password=' --param 'imessage_platform=bluebubbles' sh-imessage -``` - -_NOTE_: Double check that the `config.yaml` that was automatically generated has the correct values set for `bluebubbles_url` and `bluebubbles_password`, as sometimes `bbctl run` doesn't copy the `param` properly. - -#### Troubleshooting - -If you encounter strange errors, try resetting your environment: - -```bash -bbctl delete sh-imessage -rm ./mautrix-imessage ./mautrix-imessage.db config.yaml -``` - -Then start from the `bbctl run ...` command, and ensure that `bluebubbles_url` and `bluebubbles_password` are set in your config. - -### Contributing - -1. Find an open issue or bug -1. Create and switch a fork of this repository and branch -1. Hack away -1. Submit a PR - -#### Join us the community chat - -1. In the Beeper Desktop App, click the Settings Gear (Cog) at the top -1. Add a server: `maunium.net` -1. Find the iMessage Room: `#imessage:maunium.net` -1. Say hello! diff --git a/imessage/bluebubbles/api.go b/imessage/bluebubbles/api.go deleted file mode 100644 index 3d3d86fa..00000000 --- a/imessage/bluebubbles/api.go +++ /dev/null @@ -1,1907 +0,0 @@ -package bluebubbles - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "math/rand" - "mime/multipart" - "net/http" - "net/url" - "os" - "strconv" - "strings" - "sync" - "time" - "unicode" - - "github.com/gorilla/websocket" - "github.com/rs/zerolog" - "github.com/sahilm/fuzzy" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/imessage" -) - -const ( - // Ref: https://github.com/BlueBubblesApp/bluebubbles-server/blob/master/packages/server/src/server/events.ts - NewMessage string = "new-message" - MessageSendError string = "message-send-error" - MessageUpdated string = "updated-message" - ParticipantRemoved string = "participant-removed" - ParticipantAdded string = "participant-added" - ParticipantLeft string = "participant-left" - GroupIconChanged string = "group-icon-changed" - GroupIconRemoved string = "group-icon-removed" - ChatReadStatusChanged string = "chat-read-status-changed" - TypingIndicator string = "typing-indicator" - GroupNameChanged string = "group-name-change" - IMessageAliasRemoved string = "imessage-alias-removed" -) - -type blueBubbles struct { - bridge imessage.Bridge - log zerolog.Logger - ws *websocket.Conn - messageChan chan *imessage.Message - receiptChan chan *imessage.ReadReceipt - typingChan chan *imessage.TypingNotification - chatChan chan *imessage.ChatInfo - contactChan chan *imessage.Contact - messageStatusChan chan *imessage.SendMessageStatus - backfillTaskChan chan *imessage.BackfillTask - - contactsLastRefresh time.Time - contacts []Contact - - bbRequestLock sync.Mutex - - usingPrivateAPI bool -} - -func NewBlueBubblesConnector(bridge imessage.Bridge) (imessage.API, error) { - return &blueBubbles{ - bridge: bridge, - log: bridge.GetZLog().With().Str("component", "bluebubbles").Logger(), - - messageChan: make(chan *imessage.Message, 256), - receiptChan: make(chan *imessage.ReadReceipt, 32), - typingChan: make(chan *imessage.TypingNotification, 32), - chatChan: make(chan *imessage.ChatInfo, 32), - contactChan: make(chan *imessage.Contact, 2048), - messageStatusChan: make(chan *imessage.SendMessageStatus, 32), - backfillTaskChan: make(chan *imessage.BackfillTask, 32), - }, nil -} - -func init() { - imessage.Implementations["bluebubbles"] = NewBlueBubblesConnector -} -func (bb *blueBubbles) Start(readyCallback func()) error { - bb.log.Trace().Msg("Start") - - // Preload some caches - bb.usingPrivateAPI = bb.isPrivateAPI() - bb.RefreshContactList() - - if err := bb.connectAndListen(); err != nil { - return err - } - - // Notify main this API is fully loaded - readyCallback() - - return nil -} - -func (bb *blueBubbles) Stop() { - bb.log.Trace().Msg("Stop") - bb.stopListening() -} - -func (bb *blueBubbles) connectAndListen() error { - ws, err := bb.connectToWebSocket() - if err != nil { - return err - } - - bb.ws = ws - go bb.listenWebSocket() - - return nil -} - -func (bb *blueBubbles) connectToWebSocket() (*websocket.Conn, error) { - ws, _, err := websocket.DefaultDialer.Dial(bb.wsUrl(), nil) - if err != nil { - return nil, err - } - err = ws.WriteMessage(websocket.TextMessage, []byte("40")) - if err != nil { - ws.Close() // Close the connection if write fails - return nil, err - } - return ws, nil -} - -func (bb *blueBubbles) listenWebSocket() { - for { - if err := bb.pollMessages(); err != nil { - bb.log.Error().Err(err).Msg("Error polling messages from WebSocket") - // Reconnect logic here - if err := bb.reconnect(); err != nil { - bb.log.Error().Err(err).Msg("Failed to reconnect to WebSocket") - bb.stopListening() - return - } else { - return - } - } - } -} - -func (bb *blueBubbles) pollMessages() error { - _, payload, err := bb.ws.ReadMessage() - if err != nil { - return err - } - - if bytes.Equal(payload, []byte("2")) { - bb.log.Debug().Msg("Received ping from BlueBubbles websocket") - bb.ws.WriteMessage(websocket.TextMessage, []byte("3")) - return nil - } - - if bytes.HasPrefix(payload, []byte("42")) { - payload = bytes.TrimPrefix(payload, []byte("42")) - - var incomingWebsocketMessage []json.RawMessage - if err := json.Unmarshal(payload, &incomingWebsocketMessage); err != nil { - bb.log.Error().Err(err).Msg("Error parsing message from BlueBubbles websocket") - return err - } - - var websocketMessageType string - if err := json.Unmarshal(incomingWebsocketMessage[0], &websocketMessageType); err != nil { - bb.log.Error().Err(err).Msg("Error parsing message type from BlueBubbles websocket") - return err - } - - switch websocketMessageType { - case NewMessage: - err = bb.handleNewMessage(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling new message") - } - case MessageSendError: - err = bb.handleMessageSendError(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling message send error") - } - case MessageUpdated: - err = bb.handleMessageUpdated(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling message updated") - } - case ParticipantRemoved: - err = bb.handleParticipantRemoved(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling participant removed") - } - case ParticipantAdded: - err = bb.handleParticipantAdded(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling participant added") - } - case ParticipantLeft: - err = bb.handleParticipantLeft(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling participant left") - } - case GroupIconChanged: - err = bb.handleGroupIconChanged(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling group icon changed") - } - case GroupIconRemoved: - err = bb.handleGroupIconRemoved(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling group icon removed") - } - case ChatReadStatusChanged: - err = bb.handleChatReadStatusChanged(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling chat read status changed") - } - case TypingIndicator: - err = bb.handleTypingIndicator(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling typing indicator") - } - case GroupNameChanged: - err = bb.handleGroupNameChanged(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling group name changed") - } - case IMessageAliasRemoved: - err = bb.handleIMessageAliasRemoved(incomingWebsocketMessage[1]) - if err != nil { - bb.log.Error().Err(err).Msg("Error handling iMessage alias removed") - } - default: - bb.log.Warn().Any("WebsocketMessageType", incomingWebsocketMessage[0]).Msg("Unknown websocket message type") - } - } - return nil -} - -func (bb *blueBubbles) reconnect() error { - const maxRetryCount = 12 - retryCount := 0 - - bb.stopListening() - - for { - bb.log.Info().Msg("Attempting to reconnect to BlueBubbles WebSocket...") - if retryCount >= maxRetryCount { - err := errors.New("maximum retry attempts reached") - bb.log.Error().Err(err).Msg("Maximum retry attempts reached, stopping reconnection attempts to BlueBubbles.") - return err - } - retryCount++ - // Exponential backoff: 2^retryCount * 100ms - sleepTime := time.Duration(math.Pow(2, float64(retryCount))) * 100 * time.Millisecond - bb.log.Info().Dur("sleepTime", sleepTime).Msg("Sleeping specified duration before retrying...") - time.Sleep(sleepTime) - if err := bb.connectAndListen(); err != nil { - bb.log.Error().Err(err).Msg("Error reconnecting to WebSocket") - } else { - bb.log.Info().Msg("Successfully reconnected to BlueBubbles websocket.") - return nil - } - } -} - -func (bb *blueBubbles) stopListening() { - if bb.ws != nil { - bb.ws.WriteMessage(websocket.CloseMessage, []byte{}) - bb.ws.Close() - } -} - -func (bb *blueBubbles) handleNewMessage(rawMessage json.RawMessage) (err error) { - bb.log.Trace().RawJSON("rawMessage", rawMessage).Msg("handleNewMessage") - - var data Message - err = json.Unmarshal(rawMessage, &data) - if err != nil { - bb.log.Warn().Err(err).RawJSON("rawMessage", rawMessage).Str("event", "handleNewMessage").Msg("Failed to parse event data") - return err - } - - message, err := bb.convertBBMessageToiMessage(data) - - if err != nil { - bb.log.Warn().Err(err).RawJSON("rawMessage", rawMessage).Str("event", "handleNewMessage").Msg("Failed to convert message data") - return err - } - - select { - case bb.messageChan <- message: - default: - bb.log.Warn().Msg("Incoming message buffer is full") - } - - if message.IsEdited || message.IsRetracted { - return nil // the regular message channel should handle edits and unsends updates - } else if message.IsRead { - select { - case bb.receiptChan <- &imessage.ReadReceipt{ - SenderGUID: message.ChatGUID, - IsFromMe: !message.IsFromMe, - ChatGUID: message.ChatGUID, - ReadUpTo: message.GUID, - ReadAt: message.ReadAt, - }: - default: - bb.log.Warn().Msg("Incoming message buffer is full") - } - } else if message.IsDelivered { - select { - case bb.messageStatusChan <- &imessage.SendMessageStatus{ - GUID: message.GUID, - ChatGUID: message.ChatGUID, - Status: "delivered", - Service: imessage.ParseIdentifier(message.ChatGUID).Service, - }: - default: - bb.log.Warn().Msg("Incoming message buffer is full") - } - } - - return nil -} - -func (bb *blueBubbles) handleMessageSendError(rawMessage json.RawMessage) (err error) { - bb.log.Trace().RawJSON("rawMessage", rawMessage).Msg("handleMessageSendError") - return nil // beeper should get the error back during the send response -} - -func (bb *blueBubbles) handleMessageUpdated(rawMessage json.RawMessage) (err error) { - bb.log.Trace().RawJSON("rawMessage", rawMessage).Msg("handleMessageUpdated") - - var data Message - err = json.Unmarshal(rawMessage, &data) - if err != nil { - bb.log.Warn().Err(err).RawJSON("rawMessage", rawMessage).Str("event", "handleMessageUpdated").Msg("Failed to parse event data") - return err - } - - message, err := bb.convertBBMessageToiMessage(data) - - if err != nil { - bb.log.Warn().Err(err).RawJSON("rawMessage", rawMessage).Str("event", "handleMessageUpdated").Msg("Failed to convert message data") - return err - } - - if message.IsEdited || message.IsRetracted { - select { - case bb.messageChan <- message: - default: - bb.log.Warn().Msg("Incoming message buffer is full") - } - } else if message.IsRead { - select { - case bb.receiptChan <- &imessage.ReadReceipt{ - SenderGUID: message.ChatGUID, - IsFromMe: !message.IsFromMe, - ChatGUID: message.ChatGUID, - ReadUpTo: message.GUID, - ReadAt: message.ReadAt, - }: - default: - bb.log.Warn().Msg("Incoming message buffer is full") - } - } else if message.IsDelivered { - select { - case bb.messageStatusChan <- &imessage.SendMessageStatus{ - GUID: message.GUID, - ChatGUID: message.ChatGUID, - Status: "delivered", - Service: imessage.ParseIdentifier(message.ChatGUID).Service, - }: - default: - bb.log.Warn().Msg("Incoming message buffer is full") - } - } - - return nil -} - -func (bb *blueBubbles) handleParticipantRemoved(rawMessage json.RawMessage) (err error) { - bb.log.Trace().RawJSON("rawMessage", rawMessage).Msg("handleParticipantRemoved") - - return bb.chatRoomUpdate(rawMessage, "handleParticipantAdded") -} - -func (bb *blueBubbles) handleParticipantAdded(rawMessage json.RawMessage) (err error) { - bb.log.Trace().RawJSON("rawMessage", rawMessage).Msg("handleParticipantAdded") - - return bb.chatRoomUpdate(rawMessage, "handleParticipantAdded") -} - -func (bb *blueBubbles) handleParticipantLeft(rawMessage json.RawMessage) (err error) { - bb.log.Trace().RawJSON("rawMessage", rawMessage).Msg("handleParticipantLeft") - - return bb.chatRoomUpdate(rawMessage, "handleParticipantLeft") -} - -func (bb *blueBubbles) handleGroupIconChanged(rawMessage json.RawMessage) (err error) { - bb.log.Trace().RawJSON("rawMessage", rawMessage).Msg("handleGroupIconChanged") - // BB also fires the new message event where the icon gets updated, NOP here - return nil -} - -func (bb *blueBubbles) handleGroupIconRemoved(rawMessage json.RawMessage) (err error) { - bb.log.Trace().RawJSON("rawMessage", rawMessage).Msg("handleGroupIconRemoved") - // BB also fires the new message event where the icon gets removed, NOP here - return nil -} - -func (bb *blueBubbles) handleGroupNameChanged(data json.RawMessage) (err error) { - bb.log.Trace().RawJSON("data", data).Msg("handleGroupNameChanged") - // BB also fires the new message event where the name gets updated, NOP here - return nil -} - -func (bb *blueBubbles) chatRoomUpdate(rawMessage json.RawMessage, eventName string) (err error) { - - var data Message - err = json.Unmarshal(rawMessage, &data) - if err != nil { - bb.log.Warn().Err(err).RawJSON("rawMessage", rawMessage).Str("event", eventName).Msg("Failed to parse event data") - return err - } - - if len(data.Chats) != 1 { - bb.log.Error().Interface("rawMessage", rawMessage).Str("event", eventName).Msg("Received chat update event without a chat to update") - } - - chat, err := bb.convertBBChatToiMessageChat(data.Chats[0]) - - if err != nil { - bb.log.Warn().Err(err).RawJSON("rawMessage", rawMessage).Str("event", eventName).Msg("Failed to convert chat data") - return err - } - - select { - case bb.chatChan <- chat: - default: - bb.log.Warn().Msg("Incoming chat buffer is full") - } - - return nil -} - -func (bb *blueBubbles) handleChatReadStatusChanged(data json.RawMessage) (err error) { - bb.log.Trace().RawJSON("data", data).Msg("handleChatReadStatusChanged") - - var rec MessageReadResponse - err = json.Unmarshal(data, &rec) - if err != nil { - bb.log.Warn().Err(err).Msg("Failed to parse incoming read receipt") - return nil - } - - chatInfo, err := bb.getChatInfo(rec.ChatGUID) - if err != nil { - bb.log.Warn().Err(err).Str("chatID", rec.ChatGUID).Str("event", "handleChatReadStatusChanged").Msg("Failed to fetch chat info") - return nil - } - - if chatInfo.Data.LastMessage == nil { - bb.log.Warn().Msg("Chat info is missing last message") - return nil - } - - lastMessage, err := bb.getMessage(chatInfo.Data.LastMessage.GUID) - if err != nil { - bb.log.Warn().Err(err).Msg("Failed to get last message") - return nil - } - - var now = time.Now() - - var receipt = imessage.ReadReceipt{ - SenderGUID: lastMessage.Handle.Address, // TODO: Make sure this is the right field? - IsFromMe: false, // changing this to false as I believe read receipts will always be from others - ChatGUID: rec.ChatGUID, - ReadUpTo: chatInfo.Data.LastMessage.GUID, - ReadAt: now, - JSONUnixReadAt: timeToFloat(now), - } - - select { - case bb.receiptChan <- &receipt: - default: - bb.log.Warn().Msg("Incoming receipt buffer is full") - } - return nil -} - -func (bb *blueBubbles) handleTypingIndicator(data json.RawMessage) (err error) { - bb.log.Trace().RawJSON("data", data).Msg("handleTypingIndicator") - - var typingNotification TypingNotification - err = json.Unmarshal(data, &typingNotification) - if err != nil { - return err - } - - notif := imessage.TypingNotification{ - ChatGUID: typingNotification.GUID, - Typing: typingNotification.Display, - } - - select { - case bb.typingChan <- ¬if: - default: - bb.log.Warn().Msg("Incoming typing notification buffer is full") - } - return nil -} - -func (bb *blueBubbles) handleIMessageAliasRemoved(data json.RawMessage) (err error) { - bb.log.Trace().RawJSON("data", data).Msg("handleIMessageAliasRemoved") - return ErrNotImplemented -} - -// These functions should all be "get" -ting data FROM bluebubbles - -var ErrNotImplemented = errors.New("not implemented") - -func (bb *blueBubbles) queryChatMessages(query MessageQueryRequest, allResults []Message, paginate bool) ([]Message, error) { - bb.log.Info().Interface("query", query).Msg("queryChatMessages") - - var resp MessageQueryResponse - err := bb.apiGet(fmt.Sprintf("/api/v1/chat/%s/message", query.ChatGUID), bb.messageQueryRequestToMap(&query), &resp) - if err != nil { - return nil, err - } - - allResults = append(allResults, resp.Data...) - - nextPageOffset := resp.Metadata.Offset + resp.Metadata.Limit - - // Determine the limit for the next page - var nextLimit int - if query.Max != nil && *query.Max > 0 { - nextLimit = int(math.Min(float64(*query.Max), 1000)) - } else { - nextLimit = 1000 - } - - // If there are more messages to fetch and pagination is enabled - if paginate && (nextPageOffset < resp.Metadata.Total) { - // If the next page offset exceeds the maximum limit, adjust the query - if nextLimit > 0 && nextPageOffset+int64(nextLimit) > resp.Metadata.Total { - nextLimit = int(resp.Metadata.Total - nextPageOffset) - } - - // Update the query with the new offset and limit - query.Offset = int(nextPageOffset) - query.Limit = nextLimit - - // Recursively call the function for the next page - return bb.queryChatMessages(query, allResults, paginate) - } - - return allResults, nil -} - -func (bb *blueBubbles) messageQueryRequestToMap(req *MessageQueryRequest) map[string]string { - m := make(map[string]string) - - m["limit"] = fmt.Sprintf("%d", req.Limit) - m["offset"] = fmt.Sprintf("%d", req.Offset) - m["sort"] = string(req.Sort) - - if req.Before != nil { - m["before"] = fmt.Sprintf("%d", *req.Before) - } - - if req.After != nil { - m["after"] = fmt.Sprintf("%d", *req.After) - } - - // Handling slice of MessageQueryWith - if len(req.With) > 0 { - with := "" - for index, withItem := range req.With { - if index == 0 { - with = string(withItem) - } else { - with = with + "," + string(withItem) - } - } - m["with"] = with - } - - return m -} - -func (bb *blueBubbles) GetMessagesSinceDate(chatID string, minDate time.Time, backfillID string) (resp []*imessage.Message, err error) { - bb.log.Trace().Str("chatID", chatID).Time("minDate", minDate).Str("backfillID", backfillID).Msg("GetMessagesSinceDate") - - after := minDate.UnixNano() / int64(time.Millisecond) - request := MessageQueryRequest{ - ChatGUID: chatID, - Limit: 100, - Offset: 0, - With: []MessageQueryWith{ - MessageQueryWith(MessageQueryWithChatParticipants), - MessageQueryWith(MessageQueryWithAttachment), - MessageQueryWith(MessageQueryWithHandle), - MessageQueryWith(MessageQueryWithAttributeBody), - MessageQueryWith(MessageQueryWithMessageSummary), - MessageQueryWith(MessageQueryWithPayloadData), - }, - After: &after, - Sort: MessageQuerySortDesc, - } - - messages, err := bb.queryChatMessages(request, []Message{}, true) - if err != nil { - bb.log.Error().Err(err).Interface("request", request).Str("chatID", chatID).Time("minDate", minDate).Str("backfillID", backfillID).Str("search", "GetMessagesSinceDate").Msg("Failed to query chat Messages") - return nil, err - } - - for _, messsage := range messages { - imessage, err := bb.convertBBMessageToiMessage(messsage) - if err != nil { - bb.log.Warn().Err(err).Msg("Failed to convert message from BlueBubbles format to Matrix format") - continue - } - resp = append(resp, imessage) - } - - resp = reverseList(resp) - - return resp, nil -} - -func (bb *blueBubbles) GetMessagesBetween(chatID string, minDate, maxDate time.Time) (resp []*imessage.Message, err error) { - bb.log.Trace().Str("chatID", chatID).Time("minDate", minDate).Time("maxDate", maxDate).Msg("GetMessagesBetween") - - after := minDate.UnixNano() / int64(time.Millisecond) - before := maxDate.UnixNano() / int64(time.Millisecond) - request := MessageQueryRequest{ - ChatGUID: chatID, - Limit: 100, - Offset: 0, - With: []MessageQueryWith{ - MessageQueryWith(MessageQueryWithChatParticipants), - MessageQueryWith(MessageQueryWithAttachment), - MessageQueryWith(MessageQueryWithHandle), - MessageQueryWith(MessageQueryWithAttributeBody), - MessageQueryWith(MessageQueryWithMessageSummary), - MessageQueryWith(MessageQueryWithPayloadData), - }, - After: &after, - Before: &before, - Sort: MessageQuerySortDesc, - } - - messages, err := bb.queryChatMessages(request, []Message{}, true) - if err != nil { - bb.log.Error().Err(err).Interface("request", request).Str("chatID", chatID).Time("minDate", minDate).Time("maxDate", maxDate).Str("search", "GetMessagesBetween").Msg("Failed to query chat Messages") - return nil, err - } - - for _, messsage := range messages { - imessage, err := bb.convertBBMessageToiMessage(messsage) - if err != nil { - bb.log.Warn().Err(err).Msg("Failed to convert message from BlueBubbles format to Matrix format") - continue - } - resp = append(resp, imessage) - } - - resp = reverseList(resp) - - return resp, nil -} - -func (bb *blueBubbles) GetMessagesBeforeWithLimit(chatID string, before time.Time, limit int) (resp []*imessage.Message, err error) { - bb.log.Trace().Str("chatID", chatID).Time("before", before).Int("limit", limit).Msg("GetMessagesBeforeWithLimit") - - _before := before.UnixNano() / int64(time.Millisecond) - - queryLimit := limit - if queryLimit > 1000 { - queryLimit = 1000 - } - - request := MessageQueryRequest{ - ChatGUID: chatID, - Limit: queryLimit, - Max: &limit, - Offset: 0, - With: []MessageQueryWith{ - MessageQueryWith(MessageQueryWithChatParticipants), - MessageQueryWith(MessageQueryWithAttachment), - MessageQueryWith(MessageQueryWithHandle), - MessageQueryWith(MessageQueryWithAttributeBody), - MessageQueryWith(MessageQueryWithMessageSummary), - MessageQueryWith(MessageQueryWithPayloadData), - }, - Before: &_before, - Sort: MessageQuerySortDesc, - } - - messages, err := bb.queryChatMessages(request, []Message{}, false) - if err != nil { - bb.log.Error().Err(err).Interface("request", request).Str("chatID", chatID).Time("before", before).Int("limit", limit).Str("search", "GetMessagesBeforeWithLimit").Msg("Failed to query chat Messages") - return nil, err - } - - for _, messsage := range messages { - imessage, err := bb.convertBBMessageToiMessage(messsage) - if err != nil { - bb.log.Warn().Err(err).Msg("Failed to convert message from BlueBubbles format to Matrix format") - continue - } - resp = append(resp, imessage) - } - - resp = reverseList(resp) - - return resp, nil -} - -func (bb *blueBubbles) GetMessagesWithLimit(chatID string, limit int, backfillID string) (resp []*imessage.Message, err error) { - bb.log.Trace().Str("chatID", chatID).Int("limit", limit).Str("backfillID", backfillID).Msg("GetMessagesWithLimit") - - queryLimit := limit - if queryLimit > 1000 { - queryLimit = 1000 - } - - request := MessageQueryRequest{ - ChatGUID: chatID, - Limit: queryLimit, - Max: &limit, - Offset: 0, - With: []MessageQueryWith{ - MessageQueryWith(MessageQueryWithChatParticipants), - MessageQueryWith(MessageQueryWithAttachment), - MessageQueryWith(MessageQueryWithHandle), - MessageQueryWith(MessageQueryWithAttributeBody), - MessageQueryWith(MessageQueryWithMessageSummary), - MessageQueryWith(MessageQueryWithPayloadData), - }, - Sort: MessageQuerySortDesc, - } - - messages, err := bb.queryChatMessages(request, []Message{}, false) - if err != nil { - bb.log.Error().Err(err).Interface("request", request).Str("chatID", chatID).Int("limit", limit).Str("backfillID", backfillID).Str("search", "GetMessagesWithLimit").Msg("Failed to query chat Messages") - return nil, err - } - - for _, messsage := range messages { - imessage, err := bb.convertBBMessageToiMessage(messsage) - if err != nil { - bb.log.Warn().Err(err).Msg("Failed to convert message from BlueBubbles format to Matrix format") - continue - } - resp = append(resp, imessage) - } - - resp = reverseList(resp) - - return resp, nil -} - -func (bb *blueBubbles) getMessage(guid string) (*Message, error) { - bb.log.Trace().Str("guid", guid).Msg("getMessage") - - var messageResponse MessageResponse - - err := bb.apiGet(fmt.Sprintf("/api/v1/message/%s", guid), map[string]string{ - "with": "chats", - }, &messageResponse) - if err != nil { - return nil, err - } - - return &messageResponse.Data, nil -} - -func (bb *blueBubbles) GetMessage(guid string) (resp *imessage.Message, err error) { - bb.log.Trace().Str("guid", guid).Msg("GetMessage") - - message, err := bb.getMessage(guid) - - if err != nil { - bb.log.Err(err).Str("guid", guid).Any("response", message).Msg("Failed to get a message from BlueBubbles") - - return nil, err - } - - resp, err = bb.convertBBMessageToiMessage(*message) - - if err != nil { - bb.log.Err(err).Str("guid", guid).Any("response", message).Msg("Failed to parse a message from BlueBubbles") - - return nil, err - } - - return resp, nil -} - -func (bb *blueBubbles) queryChats(query ChatQueryRequest, allResults []Chat) ([]Chat, error) { - bb.log.Info().Interface("query", query).Msg("queryChatMessages") - - var resp ChatQueryResponse - err := bb.apiPost("/api/v1/chat/query", query, &resp) - if err != nil { - return nil, err - } - - allResults = append(allResults, resp.Data...) - - nextPageOffset := resp.Metadata.Offset + resp.Metadata.Limit - if nextPageOffset < resp.Metadata.Total { - query.Offset = nextPageOffset - query.Limit = resp.Metadata.Limit - return bb.queryChats(query, allResults) - } - - return allResults, nil -} - -func (bb *blueBubbles) GetChatsWithMessagesAfter(minDate time.Time) (resp []imessage.ChatIdentifier, err error) { - bb.log.Trace().Time("minDate", minDate).Msg("GetChatsWithMessagesAfter") - - request := ChatQueryRequest{ - Limit: 1000, - Offset: 0, - With: []ChatQueryWith{ - ChatQueryWithLastMessage, - ChatQueryWithSMS, - }, - Sort: QuerySortLastMessage, - } - - chats, err := bb.queryChats(request, []Chat{}) - if err != nil { - bb.log.Error().Err(err).Time("minDate", minDate).Str("search", "GetChatsWithMessagesAfter").Msg("Failed to search for chats") - return nil, err - } - - for _, chat := range chats { - if chat.LastMessage == nil { - continue - } - if (chat.LastMessage.DateCreated / 1000) < minDate.Unix() { - continue - } - resp = append(resp, imessage.ChatIdentifier{ - ChatGUID: chat.GUID, - ThreadID: chat.GroupID, - }) - } - - return resp, nil -} - -func (bb *blueBubbles) matchHandleToContact(address string) *Contact { - var matchedContact *Contact - - numericAddress := numericOnly(address) - - for _, c := range bb.contacts { - var contact *Contact - - // extract only the numbers of every phone (removes `-` and `+`) - var numericPhones []string - for _, e := range c.PhoneNumbers { - numericPhones = append(numericPhones, numericOnly(e.Address)) - } - - var emailStrings = convertEmails(c.Emails) - - var phoneStrings = convertPhones(c.PhoneNumbers) - - // check for exact matches for either an email or phone - if strings.Contains(address, "@") && containsString(emailStrings, address) { - contact = &Contact{} // Create a new instance - *contact = c - } else if containsString(phoneStrings, numericAddress) { - contact = &Contact{} // Create a new instance - *contact = c - } - - for _, p := range numericPhones { - matchLengths := []int{15, 14, 13, 12, 11, 10, 9, 8, 7} - if containsInt(matchLengths, len(p)) && strings.HasSuffix(numericAddress, p) { - contact = &Contact{} // Create a new instance - *contact = c - } - } - - // Contacts with a source type of "db" were imported into BB and preferable - if contact != nil && contact.SourceType == "db" { - return contact - } - - // Contacts with a source type of "api" are stored on the mac and can be used as fallback in case an imported one isn't found - if contact != nil && matchedContact == nil { - matchedContact = &Contact{} // Create a new instance - *matchedContact = *contact - } - } - - return matchedContact -} - -func (bb *blueBubbles) SearchContactList(input string) ([]*imessage.Contact, error) { - bb.log.Trace().Str("input", input).Msg("SearchContactList") - - var matchedContacts []*imessage.Contact - - for _, contact := range bb.contacts { - - contactFields := []string{ - strings.ToLower(contact.FirstName + " " + contact.LastName), - strings.ToLower(contact.DisplayName), - strings.ToLower(contact.Nickname), - strings.ToLower(contact.Nickname), - strings.ToLower(contact.Nickname), - } - - for _, phoneNumber := range contact.PhoneNumbers { - contactFields = append(contactFields, phoneNumber.Address) - } - - for _, email := range contact.Emails { - contactFields = append(contactFields, email.Address) - } - - matches := fuzzy.Find(strings.ToLower(input), contactFields) - - if len(matches) > 0 { //&& matches[0].Score >= 0 - imessageContact, _ := bb.convertBBContactToiMessageContact(&contact) - matchedContacts = append(matchedContacts, imessageContact) - continue - } - } - - return matchedContacts, nil -} - -func (bb *blueBubbles) GetContactInfo(identifier string) (resp *imessage.Contact, err error) { - bb.log.Trace().Str("identifier", identifier).Msg("GetContactInfo") - - contact := bb.matchHandleToContact(identifier) - - bb.log.Trace().Str("identifier", identifier).Interface("contact", contact).Msg("GetContactInfo: found a contact") - - if contact != nil { - resp, _ = bb.convertBBContactToiMessageContact(contact) - return resp, nil - - } - err = errors.New("no contacts found for address") - bb.log.Err(err).Str("identifier", identifier).Msg("No contacts matched address, aborting contact retrieval") - return nil, err -} - -func (bb *blueBubbles) GetContactList() (resp []*imessage.Contact, err error) { - bb.log.Trace().Msg("GetContactList") - - for _, contact := range bb.contacts { - imessageContact, _ := bb.convertBBContactToiMessageContact(&contact) - resp = append(resp, imessageContact) - } - - return resp, nil -} - -func (bb *blueBubbles) RefreshContactList() error { - bb.log.Trace().Msg("refreshContactsList") - - var contactResponse ContactResponse - - err := bb.apiGet("/api/v1/contact", map[string]string{ - "extraProperties": "avatar", - }, &contactResponse) - if err != nil { - return err - } - - // save contacts for later - bb.contacts = contactResponse.Data - bb.contactsLastRefresh = time.Now() - - return nil -} - -func (bb *blueBubbles) getChatInfo(chatID string) (*ChatResponse, error) { - bb.log.Trace().Str("chatID", chatID).Msg("getChatInfo") - - var chatResponse ChatResponse - - // DEVNOTE: it doesn't appear we should URL Encode the chatID... 😬 - // the BlueBubbles API returned 404s, sometimes, with URL encoding - err := bb.apiGet(fmt.Sprintf("/api/v1/chat/%s", chatID), map[string]string{ - "with": "participants,lastMessage", - }, &chatResponse) - if err != nil { - return nil, err - } - - if chatResponse.Data == nil { - return nil, errors.New("chat is missing data payload") - } - - return &chatResponse, nil -} - -func (bb *blueBubbles) GetChatInfo(chatID, threadID string) (*imessage.ChatInfo, error) { - bb.log.Trace().Str("chatID", chatID).Str("threadID", threadID).Msg("GetChatInfo") - - chatResponse, err := bb.getChatInfo(chatID) - if err != nil { - bb.log.Error().Err(err).Str("chatID", chatID).Str("threadID", threadID).Msg("Failed to fetch chat info") - return nil, err - } - - chatInfo, err := bb.convertBBChatToiMessageChat(*chatResponse.Data) - if err != nil { - bb.log.Error().Err(err).Str("chatID", chatID).Str("threadID", threadID).Msg("Failed to convert chat info") - return nil, err - } - - return chatInfo, nil -} - -func (bb *blueBubbles) GetGroupAvatar(chatID string) (*imessage.Attachment, error) { - bb.log.Trace().Str("chatID", chatID).Msg("GetGroupAvatar") - - chatResponse, err := bb.getChatInfo(chatID) - if err != nil { - bb.log.Error().Err(err).Str("chatID", chatID).Msg("Failed to fetch chat info") - return nil, err - } - - if chatResponse.Data.Properties == nil || - len(chatResponse.Data.Properties) < 1 { - return nil, nil - } - - properties := chatResponse.Data.Properties[0] - - if properties.GroupPhotoGUID == nil { - return nil, nil - } - - attachment, err := bb.downloadAttachment(*properties.GroupPhotoGUID) - if err != nil { - bb.log.Error().Err(err).Str("chatID", chatID).Msg("Failed to download group avatar") - return nil, err - } - - return attachment, nil -} - -// These functions all provide "channels" to allow concurrent processing in the bridge -func (bb *blueBubbles) MessageChan() <-chan *imessage.Message { - return bb.messageChan -} - -func (bb *blueBubbles) ReadReceiptChan() <-chan *imessage.ReadReceipt { - return bb.receiptChan -} - -func (bb *blueBubbles) TypingNotificationChan() <-chan *imessage.TypingNotification { - return bb.typingChan -} - -func (bb *blueBubbles) ChatChan() <-chan *imessage.ChatInfo { - return bb.chatChan -} - -func (bb *blueBubbles) ContactChan() <-chan *imessage.Contact { - return bb.contactChan -} - -func (bb *blueBubbles) MessageStatusChan() <-chan *imessage.SendMessageStatus { - return bb.messageStatusChan -} - -func (bb *blueBubbles) BackfillTaskChan() <-chan *imessage.BackfillTask { - return bb.backfillTaskChan -} - -func (bb *blueBubbles) SendMessage(chatID, text string, replyTo string, replyToPart int, richLink *imessage.RichLink, metadata imessage.MessageMetadata) (*imessage.SendResponse, error) { - bb.log.Trace().Str("chatID", chatID).Str("text", text).Str("replyTo", replyTo).Int("replyToPart", replyToPart).Any("richLink", richLink).Interface("metadata", metadata).Msg("SendMessage") - - var method string - if bb.usingPrivateAPI { - method = "private-api" - } else { - // we have to use apple-script and send a second message - method = "apple-script" - } - - request := SendTextRequest{ - ChatGUID: chatID, - Method: method, - Message: text, - TempGUID: fmt.Sprintf("temp-%s", RandString(8)), - SelectedMessageGUID: replyTo, - PartIndex: replyToPart, - } - - var res SendTextResponse - - err := bb.apiPost("/api/v1/message/text", request, &res) - if err != nil { - bb.log.Error().Any("response", res).Msg("Failure when sending message to BlueBubbles") - return nil, err - } - - if res.Status != 200 { - bb.log.Error().Int64("statusCode", res.Status).Any("response", res).Msg("Failure when sending message to BlueBubbles") - - return nil, errors.New("could not send message") - } - - return &imessage.SendResponse{ - GUID: res.Data.GUID, - Service: res.Data.Handle.Service, - Time: time.UnixMilli(res.Data.DateCreated), - }, nil -} - -func (bb *blueBubbles) UnsendMessage(chatID, targetGUID string, targetPart int) (*imessage.SendResponse, error) { - bb.log.Trace().Str("chatID", chatID).Str("targetGUID", targetGUID).Int("targetPart", targetPart).Msg("UnsendMessage") - - if !bb.usingPrivateAPI { - bb.log.Warn().Str("chatID", chatID).Str("targetGUID", targetGUID).Int("targetPart", targetPart).Msg("The private-api isn't enabled in BlueBubbles, can't unsend message") - return nil, errors.ErrUnsupported - } - - request := UnsendMessage{ - PartIndex: targetPart, - } - - var res UnsendMessageResponse - - err := bb.apiPost("/api/v1/message/"+targetGUID+"/unsend", request, &res) - if err != nil { - bb.log.Error().Any("response", res).Msg("Failure when unsending message in BlueBubbles") - return nil, err - } - - if res.Status != 200 { - bb.log.Error().Int64("statusCode", res.Status).Any("response", res).Msg("Failure when unsending message in BlueBubbles") - - return nil, errors.New("could not unsend message") - } - - return &imessage.SendResponse{ - GUID: res.Data.GUID, - Service: res.Data.Handle.Service, - Time: time.UnixMilli(res.Data.DateCreated), - }, nil -} - -func (bb *blueBubbles) EditMessage(chatID string, targetGUID string, newText string, targetPart int) (*imessage.SendResponse, error) { - bb.log.Trace().Str("chatID", chatID).Str("targetGUID", targetGUID).Str("newText", newText).Int("targetPart", targetPart).Msg("EditMessage") - - if !bb.usingPrivateAPI { - bb.log.Warn().Str("chatID", chatID).Str("targetGUID", targetGUID).Str("newText", newText).Int("targetPart", targetPart).Msg("The private-api isn't enabled in BlueBubbles, can't edit message") - return nil, errors.ErrUnsupported - } - - request := EditMessage{ - EditedMessage: newText, - BackwwardsCompatibilityMessage: "Edited to \"" + newText + "\"", - PartIndex: targetPart, - } - - var res EditMessageResponse - - err := bb.apiPost("/api/v1/message/"+targetGUID+"/edit", request, &res) - if err != nil { - bb.log.Error().Any("response", res).Msg("Failure when editing message in BlueBubbles") - return nil, err - } - - if res.Status != 200 { - bb.log.Error().Int64("statusCode", res.Status).Any("response", res).Msg("Failure when editing message in BlueBubbles") - - return nil, errors.New("could not edit message") - } - - return &imessage.SendResponse{ - GUID: res.Data.GUID, - Service: res.Data.Handle.Service, - Time: time.UnixMilli(res.Data.DateCreated), - }, nil -} - -func (bb *blueBubbles) isPrivateAPI() bool { - var serverInfo ServerInfoResponse - err := bb.apiGet("/api/v1/server/info", nil, &serverInfo) - if err != nil { - bb.log.Error().Err(err).Msg("Failed to get server info from BlueBubbles") - return false - } - - privateAPI := serverInfo.Data.PrivateAPI - - return privateAPI -} - -func (bb *blueBubbles) SendFile(chatID, text, filename string, pathOnDisk string, replyTo string, replyToPart int, mimeType string, voiceMemo bool, metadata imessage.MessageMetadata) (*imessage.SendResponse, error) { - bb.log.Trace().Str("chatID", chatID).Str("text", text).Str("filename", filename).Str("pathOnDisk", pathOnDisk).Str("replyTo", replyTo).Int("replyToPart", replyToPart).Str("mimeType", mimeType).Bool("voiceMemo", voiceMemo).Interface("metadata", metadata).Msg("SendFile") - - attachment, err := os.ReadFile(pathOnDisk) - if err != nil { - return nil, err - } - - bb.log.Info().Int("attachmentSize", len(attachment)).Msg("Read attachment from disk") - - var method string - if bb.usingPrivateAPI { - method = "private-api" - } else { - // we have to use apple-script and send a second message - method = "apple-script" - } - - formData := map[string]interface{}{ - "chatGuid": chatID, - "tempGuid": fmt.Sprintf("temp-%s", RandString(8)), - "name": filename, - "method": method, - "attachment": attachment, - "isAudioMessage": voiceMemo, - "selectedMessageGuid": replyTo, - "partIndex": replyToPart, - } - - if bb.usingPrivateAPI { - formData["subject"] = text - } - - path := "/api/v1/message/attachment" - - var response SendTextResponse - if err := bb.apiPostAsFormData(path, formData, &response); err != nil { - return nil, err - } - - if !bb.usingPrivateAPI { - bb.SendMessage(chatID, text, replyTo, replyToPart, nil, nil) - } - - var imessageSendResponse = imessage.SendResponse{ - GUID: response.Data.GUID, - Service: response.Data.Handle.Service, - Time: time.UnixMilli(response.Data.DateCreated), - } - - return &imessageSendResponse, nil -} - -func (bb *blueBubbles) SendFileCleanup(sendFileDir string) { - _ = os.RemoveAll(sendFileDir) -} - -func (bb *blueBubbles) SendTapback(chatID, targetGUID string, targetPart int, tapback imessage.TapbackType, remove bool) (*imessage.SendResponse, error) { - bb.log.Trace().Str("chatID", chatID).Str("targetGUID", targetGUID).Int("targetPart", targetPart).Interface("tapback", tapback).Bool("remove", remove).Msg("SendTapback") - - var tapbackName = tapback.Name() - - if !bb.usingPrivateAPI { - bb.log.Warn().Str("chatID", chatID).Str("targetGUID", targetGUID).Str("tapbackName", tapbackName).Bool("remove", remove).Msg("The private-api isn't enabled in BlueBubbles, can't send tapback") - return nil, errors.ErrUnsupported - } - - if remove { - tapbackName = "-" + tapbackName - } - - request := SendReactionRequest{ - ChatGUID: chatID, - SelectedMessageGUID: targetGUID, - PartIndex: targetPart, - Reaction: tapbackName, - } - - var res SendReactionResponse - - err := bb.apiPost("/api/v1/message/react", request, &res) - if err != nil { - return nil, err - } - - if res.Status != 200 { - bb.log.Error().Any("response", res).Msg("Failure when sending message to BlueBubbles") - - return nil, errors.New("could not send message") - - } - - return &imessage.SendResponse{ - GUID: res.Data.GUID, - Service: res.Data.Handle.Service, - Time: time.UnixMilli(res.Data.DateCreated), - }, nil -} - -func (bb *blueBubbles) SendReadReceipt(chatID, readUpTo string) error { - bb.log.Trace().Str("chatID", chatID).Str("readUpTo", readUpTo).Msg("SendReadReceipt") - - if !bb.usingPrivateAPI { - bb.log.Warn().Str("chatID", chatID).Msg("The private-api isn't enabled in BlueBubbles, can't send read receipt") - return errors.ErrUnsupported - } - - var res ReadReceiptResponse - err := bb.apiPost(fmt.Sprintf("/api/v1/chat/%s/read", chatID), nil, &res) - - if err != nil { - return err - } - - if res.Status != 200 { - bb.log.Error().Any("response", res).Str("chatID", chatID).Msg("Failure when marking a chat as read") - - return errors.New("could not mark chat as read") - } - - bb.log.Trace().Str("chatID", chatID).Msg("Marked a chat as Read") - - return nil -} - -func (bb *blueBubbles) SendTypingNotification(chatID string, typing bool) error { - bb.log.Trace().Str("chatID", chatID).Bool("typing", typing).Msg("SendTypingNotification") - - if !bb.usingPrivateAPI { - bb.log.Warn().Str("chatID", chatID).Bool("typing", typing).Msg("The private-api isn't enabled in BlueBubbles, can't send typing notification") - return errors.ErrUnsupported - } - - var res TypingResponse - var err error - - if typing { - err = bb.apiPost(fmt.Sprintf("/api/v1/chat/%s/typing", chatID), nil, &res) - } else { - err = bb.apiDelete(fmt.Sprintf("/api/v1/chat/%s/typing", chatID), nil, &res) - } - - if err != nil { - return err - } - - if res.Status != 200 { - bb.log.Error().Any("response", res).Str("chatID", chatID).Bool("typing", typing).Msg("Failure when updating typing status") - - return errors.New("could not update typing status") - } - - bb.log.Trace().Str("chatID", chatID).Bool("typing", typing).Msg("Update typing status") - - return nil -} - -func (bb *blueBubbles) ResolveIdentifier(address string) (string, error) { - bb.log.Trace().Str("address", address).Msg("ResolveIdentifier") - - var identifierResponse ResolveIdentifierResponse - - var handle = address - if !strings.Contains(address, "@") { - handle = "+" + numericOnly(address) - } - - err := bb.apiGet(fmt.Sprintf("/api/v1/handle/%s", handle), nil, &identifierResponse) - if err != nil { - bb.log.Error().Any("response", identifierResponse).Str("address", address).Str("handle", handle).Msg("Failure when Resolving Identifier") - return "", err - } - - if identifierResponse.Data.Service == "" || identifierResponse.Data.Address == "" { - bb.log.Warn().Any("response", identifierResponse).Str("address", address).Msg("No results found for provided identifier. Assuming 'iMessage' service.") - return "iMessage;-;" + handle, nil - } - - return identifierResponse.Data.Service + ";-;" + identifierResponse.Data.Address, nil -} - -func (bb *blueBubbles) PrepareDM(guid string) error { - bb.log.Trace().Str("guid", guid).Msg("PrepareDM") - return nil -} - -func (bb *blueBubbles) CreateGroup(users []string) (*imessage.CreateGroupResponse, error) { - bb.log.Trace().Interface("users", users).Msg("CreateGroup") - return nil, errors.ErrUnsupported -} - -// Helper functions - -func (bb *blueBubbles) wsUrl() string { - u, err := url.Parse(strings.Replace(bb.bridge.GetConnectorConfig().BlueBubblesURL, "http", "ws", 1)) - if err != nil { - bb.log.Error().Err(err).Msg("Error parsing BlueBubbles URL") - // TODO error handling for bad config - return "" - } - - u.Path = "socket.io/" - - q := u.Query() - q.Add("guid", bb.bridge.GetConnectorConfig().BlueBubblesPassword) - q.Add("EIO", "4") - q.Add("transport", "websocket") - u.RawQuery = q.Encode() - - url := u.String() - - return url -} - -func (bb *blueBubbles) apiURL(path string, queryParams map[string]string) string { - u, err := url.Parse(bb.bridge.GetConnectorConfig().BlueBubblesURL) - if err != nil { - bb.log.Error().Err(err).Msg("Error parsing BlueBubbles URL") - // TODO error handling for bad config - return "" - } - - u.Path = path - - q := u.Query() - q.Add("password", bb.bridge.GetConnectorConfig().BlueBubblesPassword) - - for key, value := range queryParams { - q.Add(key, value) - } - - u.RawQuery = q.Encode() - - url := u.String() - - return url -} - -func (bb *blueBubbles) apiGet(path string, queryParams map[string]string, target interface{}) (err error) { - url := bb.apiURL(path, queryParams) - - bb.bbRequestLock.Lock() - response, err := http.Get(url) - bb.bbRequestLock.Unlock() - if err != nil { - bb.log.Error().Err(err).Msg("Error making GET request") - return err - } - defer response.Body.Close() - - responseBody, err := io.ReadAll(response.Body) - if err != nil { - bb.log.Error().Err(err).Msg("Error reading response body") - return err - } - - if err := json.Unmarshal(responseBody, target); err != nil { - bb.log.Error().Err(err).Msg("Error unmarshalling response body") - return err - } - - return nil -} - -func (bb *blueBubbles) apiPost(path string, payload interface{}, target interface{}) error { - return bb.apiRequest("POST", path, payload, target) -} - -func (bb *blueBubbles) apiDelete(path string, payload interface{}, target interface{}) error { - return bb.apiRequest("DELETE", path, payload, target) -} - -func (bb *blueBubbles) apiRequest(method, path string, payload interface{}, target interface{}) (err error) { - url := bb.apiURL(path, map[string]string{}) - - var payloadJSON []byte - if payload != nil { - payloadJSON, err = json.Marshal(payload) - if err != nil { - bb.log.Error().Err(err).Msg("Error marshalling payload") - return err - } - } - - req, err := http.NewRequest(method, url, bytes.NewBuffer(payloadJSON)) - if err != nil { - bb.log.Error().Err(err).Str("method", method).Msg("Error creating request") - return err - } - - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - bb.bbRequestLock.Lock() - response, err := client.Do(req) - bb.bbRequestLock.Unlock() - if err != nil { - bb.log.Error().Err(err).Str("method", method).Msg("Error making request") - return err - } - defer response.Body.Close() - - responseBody, err := io.ReadAll(response.Body) - if err != nil { - bb.log.Error().Err(err).Msg("Error reading response body") - return err - } - - if err := json.Unmarshal(responseBody, target); err != nil { - bb.log.Error().Err(err).Msg("Error unmarshalling response body") - return err - } - - return nil -} - -func (bb *blueBubbles) apiPostAsFormData(path string, formData map[string]interface{}, target interface{}) error { - url := bb.apiURL(path, map[string]string{}) - - // Create a new buffer to store the file content - var body bytes.Buffer - writer := multipart.NewWriter(&body) - - for key, value := range formData { - switch v := value.(type) { - case int, bool: - writer.WriteField(key, fmt.Sprint(v)) - case string: - writer.WriteField(key, v) - case []byte: - part, err := writer.CreateFormFile(key, "file.bin") - if err != nil { - bb.log.Error().Err(err).Msg("Error creating form-data field") - return err - } - _, err = part.Write(v) - if err != nil { - bb.log.Error().Err(err).Msg("Error writing file to form-data") - return err - } - default: - return fmt.Errorf("unable to serialze %s (type %T) into form-data", key, v) - } - } - - // Close the multipart writer - writer.Close() - - // Make the HTTP POST request - bb.bbRequestLock.Lock() - response, err := http.Post(url, writer.FormDataContentType(), &body) - bb.bbRequestLock.Unlock() - if err != nil { - bb.log.Error().Err(err).Msg("Error making POST request") - return err - } - defer response.Body.Close() - - responseBody, err := io.ReadAll(response.Body) - if err != nil { - bb.log.Error().Err(err).Msg("Error reading response body") - return err - } - - if err := json.Unmarshal(responseBody, target); err != nil { - bb.log.Error().Err(err).Msg("Error unmarshalling response body") - return err - } - - bb.log.Trace() - - return nil -} - -func (bb *blueBubbles) convertBBContactToiMessageContact(bbContact *Contact) (*imessage.Contact, error) { - var convertedID string - var imageData []byte - var err error - - switch id := bbContact.ID.(type) { - case string: - // id is already a string, use it as is - convertedID = id - case float64: - // id is a float, convert it to a string - convertedID = strconv.FormatFloat(id, 'f', -1, 64) - default: - bb.log.Error().Interface("id", id).Msg("Unknown type for contact ID") - convertedID = "" - } - - if *bbContact.Avatar != "" { - imageData, err = base64.StdEncoding.DecodeString(*bbContact.Avatar) - if err != nil { - bb.log.Error().Err(err).Str("DisplayName", bbContact.DisplayName).Msg("Error decoding contact avatar") - } - } - - return &imessage.Contact{ - FirstName: bbContact.FirstName, - LastName: bbContact.LastName, - Nickname: bbContact.DisplayName, - Phones: convertPhones(bbContact.PhoneNumbers), - Emails: convertEmails(bbContact.Emails), - UserGUID: convertedID, - Avatar: imageData, - }, nil -} - -func (bb *blueBubbles) convertBBMessageToiMessage(bbMessage Message) (*imessage.Message, error) { - - var message imessage.Message - - // Convert bluebubbles.Message to imessage.Message - message.GUID = bbMessage.GUID - message.Time = time.UnixMilli(bbMessage.DateCreated) - message.Subject = bbMessage.Subject - message.Text = bbMessage.Text - message.ChatGUID = bbMessage.Chats[0].GUID - - // bbMessage.Handle seems to always be the other person, - // so the sender/target depends on whether the message is from you - if bbMessage.IsFromMe { - message.JSONTargetGUID = bbMessage.Handle.Address - message.Target = imessage.Identifier{ - LocalID: bbMessage.Handle.Address, - Service: bbMessage.Handle.Service, - IsGroup: false, - } - } else { - message.JSONSenderGUID = bbMessage.Handle.Address - message.Sender = imessage.Identifier{ - LocalID: bbMessage.Handle.Address, - Service: bbMessage.Handle.Service, - IsGroup: false, - } - } - - message.Service = bbMessage.Handle.Service - message.IsFromMe = bbMessage.IsFromMe - message.IsRead = bbMessage.DateRead != 0 - if message.IsRead { - message.ReadAt = time.UnixMilli(bbMessage.DateRead) - } - message.IsDelivered = bbMessage.DateDelivered != 0 - message.IsSent = bbMessage.DateCreated != 0 // assume yes because we made it to this part of the code - message.IsEmote = false // emojis seem to send either way, and BB doesn't say whether there is one or not - message.IsAudioMessage = bbMessage.IsAudioMessage - message.IsEdited = bbMessage.DateEdited != 0 - message.IsRetracted = bbMessage.DateRetracted != 0 - - message.ReplyToGUID = bbMessage.ThreadOriginatorGUID - - // TODO: ReplyToPart from bluebubbles looks like "0:0:17" in one test I did - // I don't know what the value means, or how to parse it - // num, err := strconv.Atoi(bbMessage.ThreadOriginatorPart) - // if err != nil { - // bb.log.Err(err).Str("ThreadOriginatorPart", bbMessage.ThreadOriginatorPart).Msg("Unable to convert ThreadOriginatorPart to an int") - // } else { - // message.ReplyToPart = num - // } - - // Tapbacks - if bbMessage.AssociatedMessageGUID != "" && - bbMessage.AssociatedMessageType != "" { - message.Tapback = &imessage.Tapback{ - TargetGUID: bbMessage.AssociatedMessageGUID, - Type: bb.convertBBTapbackToImessageTapback(bbMessage.AssociatedMessageType), - } - message.Tapback.Parse() - } else { - message.Tapback = nil - } - - // Attachments - message.Attachments = make([]*imessage.Attachment, len(bbMessage.Attachments)) - for i, attachment := range bbMessage.Attachments { - attachment, err := bb.downloadAttachment(attachment.GUID) - if err != nil { - bb.log.Err(err).Str("attachmentGUID", attachment.GUID).Msg("Failed to download attachment") - continue - } - - message.Attachments[i] = attachment - } - - // Group name, member, and avatar changes all come through as messages - // with a special ItemType to denote it isn't a regular message - message.ItemType = imessage.ItemType(bbMessage.ItemType) - - // Changes based on the ItemType, but denotes user or icon add vs remove actions - message.GroupActionType = imessage.GroupActionType(bbMessage.GroupActionType) - message.NewGroupName = bbMessage.GroupTitle - - // TODO Richlinks - // message.RichLink = - - message.ThreadID = bbMessage.ThreadOriginatorGUID - - return &message, nil -} - -func (bb *blueBubbles) convertBBTapbackToImessageTapback(associatedMessageType string) (tbType imessage.TapbackType) { - if strings.Contains(associatedMessageType, "love") { - tbType = imessage.TapbackLove - } else if strings.Contains(associatedMessageType, "dislike") { - tbType = imessage.TapbackDislike - } else if strings.Contains(associatedMessageType, "like") { - tbType = imessage.TapbackLike - } else if strings.Contains(associatedMessageType, "laugh") { - tbType = imessage.TapbackLaugh - } else if strings.Contains(associatedMessageType, "emphasize") { - tbType = imessage.TapbackEmphasis - } else if strings.Contains(associatedMessageType, "question") { - tbType = imessage.TapbackQuestion - } - - if strings.Contains(associatedMessageType, "-") { - tbType += imessage.TapbackRemoveOffset - } - return tbType -} - -func (bb *blueBubbles) convertBBChatToiMessageChat(bbChat Chat) (*imessage.ChatInfo, error) { - members := make([]string, len(bbChat.Participants)) - - for i, participant := range bbChat.Participants { - members[i] = participant.Address - } - - chatInfo := &imessage.ChatInfo{ - JSONChatGUID: bbChat.GUID, - Identifier: imessage.ParseIdentifier(bbChat.GUID), - DisplayName: bbChat.DisplayName, - Members: members, - ThreadID: bbChat.GroupID, - } - - return chatInfo, nil -} - -func (bb *blueBubbles) downloadAttachment(guid string) (attachment *imessage.Attachment, err error) { - bb.log.Trace().Str("guid", guid).Msg("downloadAttachment") - - var attachmentResponse AttachmentResponse - err = bb.apiGet(fmt.Sprintf("/api/v1/attachment/%s", guid), map[string]string{}, &attachmentResponse) - if err != nil { - bb.log.Err(err).Str("guid", guid).Msg("Failed to get attachment from BlueBubbles") - return nil, err - } - - url := bb.apiURL(fmt.Sprintf("/api/v1/attachment/%s/download", guid), map[string]string{}) - - response, err := http.Get(url) - if err != nil { - bb.log.Error().Err(err).Msg("Error making GET request") - return nil, err - } - defer response.Body.Close() - - tempFile, err := os.CreateTemp(os.TempDir(), guid) - if err != nil { - bb.log.Error().Err(err).Msg("Error creating temp file") - return nil, err - } - defer tempFile.Close() - - _, err = io.Copy(tempFile, response.Body) - if err != nil { - bb.log.Error().Err(err).Msg("Error copying response body to temp file") - return nil, err - } - - return &imessage.Attachment{ - GUID: guid, // had trouble renaming this one (guid) - PathOnDisk: tempFile.Name(), - FileName: attachmentResponse.Data.TransferName, - MimeType: attachmentResponse.Data.MimeType, - }, nil -} - -func convertPhones(phoneNumbers []PhoneNumber) []string { - var phones []string - for _, phone := range phoneNumbers { - // Convert the phone number format as needed - phones = append(phones, phone.Address) - } - return phones -} - -func convertEmails(emails []Email) []string { - var emailAddresses []string - for _, email := range emails { - // Convert the email address format as needed - emailAddresses = append(emailAddresses, email.Address) - } - return emailAddresses -} - -func timeToFloat(time time.Time) float64 { - if time.IsZero() { - return 0 - } - return float64(time.Unix()) + float64(time.Nanosecond())/1e9 -} - -var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - -func RandString(n int) string { - b := make([]rune, n) - for i := range b { - b[i] = letterRunes[rand.Intn(len(letterRunes))] - } - return string(b) -} - -func reverseList(input []*imessage.Message) []*imessage.Message { - // Get the length of the slice - length := len(input) - - // Create a new slice to store the reversed elements - reversed := make([]*imessage.Message, length) - - // Iterate over the original slice in reverse order - for i, value := range input { - reversed[length-i-1] = value - } - - return reversed -} - -func containsString(slice []string, str string) bool { - for _, s := range slice { - if s == str { - return true - } - } - return false -} - -func containsInt(slice []int, num int) bool { - for _, n := range slice { - if n == num { - return true - } - } - return false -} - -func numericOnly(s string) string { - var result strings.Builder - for _, char := range s { - if unicode.IsDigit(char) { - result.WriteRune(char) - } - } - return result.String() -} - -// These functions are probably not necessary - -func (bb *blueBubbles) SendMessageBridgeResult(chatID, messageID string, eventID id.EventID, success bool) { -} -func (bb *blueBubbles) SendBackfillResult(chatID, backfillID string, success bool, idMap map[string][]id.EventID) { -} -func (bb *blueBubbles) SendChatBridgeResult(guid string, mxid id.RoomID) { -} -func (bb *blueBubbles) NotifyUpcomingMessage(eventID id.EventID) { -} -func (bb *blueBubbles) PreStartupSyncHook() (resp imessage.StartupSyncHookResponse, err error) { - return imessage.StartupSyncHookResponse{ - SkipSync: false, - }, nil -} -func (bb *blueBubbles) PostStartupSyncHook() { -} - -func (bb *blueBubbles) Capabilities() imessage.ConnectorCapabilities { - return imessage.ConnectorCapabilities{ - MessageSendResponses: true, - SendTapbacks: bb.usingPrivateAPI, - UnsendMessages: bb.usingPrivateAPI, - EditMessages: bb.usingPrivateAPI, - SendReadReceipts: bb.usingPrivateAPI, - SendTypingNotifications: bb.usingPrivateAPI, - SendCaptions: true, - BridgeState: false, - MessageStatusCheckpoints: false, - DeliveredStatus: bb.usingPrivateAPI, - ContactChatMerging: false, - RichLinks: false, - ChatBridgeResult: false, - } -} diff --git a/imessage/bluebubbles/interface.go b/imessage/bluebubbles/interface.go deleted file mode 100644 index f9c12bd4..00000000 --- a/imessage/bluebubbles/interface.go +++ /dev/null @@ -1,323 +0,0 @@ -package bluebubbles - -type PageMetadata struct { - Count int64 `json:"count"` - Total int64 `json:"total"` - Offset int64 `json:"offset"` - Limit int64 `json:"limit"` -} - -type MessageQuerySort string - -const ( - MessageQuerySortAsc MessageQuerySort = "ASC" - MessageQuerySortDesc MessageQuerySort = "DESC" -) - -type MessageQueryRequest struct { - // TODO Other Fields - ChatGUID string `json:"chatGuid"` - Limit int `json:"limit"` - Max *int `json:"max"` - Offset int `json:"offset"` - With []MessageQueryWith `json:"with"` - Sort MessageQuerySort `json:"sort"` - Before *int64 `json:"before,omitempty"` - After *int64 `json:"after,omitempty"` -} - -type MessageQueryWith string - -const ( - MessageQueryWithChat ChatQueryWith = "chat" - MessageQueryWithChatParticipants ChatQueryWith = "chat.participants" - MessageQueryWithAttachment ChatQueryWith = "attachment" - MessageQueryWithHandle ChatQueryWith = "handle" - MessageQueryWithSMS ChatQueryWith = "sms" - MessageQueryWithAttributeBody ChatQueryWith = "message.attributedBody" - MessageQueryWithMessageSummary ChatQueryWith = "message.messageSummaryInfo" - MessageQueryWithPayloadData ChatQueryWith = "message.payloadData" -) - -type MessageQueryResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data []Message `json:"data"` - Metadata PageMetadata `json:"metadata"` -} - -type ChatQuerySort string - -const ( - QuerySortLastMessage ChatQuerySort = "lastmessage" -) - -type ChatQueryRequest struct { - // TODO Other Fields - Limit int64 `json:"limit"` - Offset int64 `json:"offset"` - With []ChatQueryWith `json:"with"` - Sort ChatQuerySort `json:"sort"` -} - -type ChatQueryWith string - -const ( - ChatQueryWithSMS ChatQueryWith = "sms" - ChatQueryWithLastMessage ChatQueryWith = "lastMessage" -) - -type ChatQueryResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data []Chat `json:"data"` - Metadata PageMetadata `json:"metadata"` -} - -type ChatResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data *Chat `json:"data,omitempty"` -} - -type Chat struct { - // TODO How to get timestamp - GUID string `json:"guid"` - ChatIdentifier string `json:"chatIdentifier"` - GroupID string `json:"groupId,omitempty"` - DisplayName string `json:"displayName"` - Participants []Participant `json:"participants"` - LastMessage *Message `json:"lastMessage,omitempty"` - Properties []ChatProperties `json:"properties,omitempty"` -} - -type ChatProperties struct { - GroupPhotoGUID *string `json:"groupPhotoGuid,omitempty"` -} - -type Participant struct { - Address string `json:"address"` -} - -type ContactQueryRequest struct { - Addresses []string `json:"addresses"` -} - -type ContactResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data []Contact `json:"data"` -} - -type Contact struct { - PhoneNumbers []PhoneNumber `json:"phoneNumbers,omitempty"` - Emails []Email `json:"emails,omitempty"` - FirstName string `json:"firstName,omitempty"` - LastName string `json:"lastName,omitempty"` - DisplayName string `json:"displayName,omitempty"` - Nickname string `json:"nickname,omitempty"` - Birthday string `json:"birthday,omitempty"` - Avatar *string `json:"avatar,omitempty"` - SourceType string `json:"sourceType,omitempty"` - // DEVNOTE this field is a string unless importing from a vCard - ID any `json:"id,omitempty"` -} - -type PhoneNumber struct { - Address string `json:"address,omitempty"` - ID any `json:"id,omitempty"` -} - -type Email struct { - Address string `json:"address,omitempty"` - ID any `json:"id,omitempty"` -} - -type TypingNotification struct { - Display bool `json:"display"` - GUID string `json:"guid"` -} - -type Message struct { - AssociatedMessageGUID string `json:"associatedMessageGuid,omitempty"` - AssociatedMessageType string `json:"associatedMessageType,omitempty"` - Attachments []Attachment `json:"attachments,omitempty"` - AttributedBody []any `json:"attributedBody,omitempty"` - BalloonBundleID any `json:"balloonBundleId,omitempty"` - Chats []Chat `json:"chats,omitempty"` - DateCreated int64 `json:"dateCreated,omitempty"` - DateDelivered int64 `json:"dateDelivered,omitempty"` - DateEdited int64 `json:"dateEdited,omitempty"` - DateRead int64 `json:"dateRead,omitempty"` - DateRetracted int64 `json:"dateRetracted,omitempty"` - Error int `json:"error,omitempty"` - ExpressiveSendStyleID any `json:"expressiveSendStyleId,omitempty"` - GroupActionType int `json:"groupActionType,omitempty"` - GroupTitle string `json:"groupTitle,omitempty"` - GUID string `json:"guid,omitempty"` - Handle Handle `json:"handle,omitempty"` - HandleID int `json:"handleId,omitempty"` - HasDDResults bool `json:"hasDdResults,omitempty"` - HasPayloadData bool `json:"hasPayloadData,omitempty"` - IsArchived bool `json:"isArchived,omitempty"` - IsAudioMessage bool `json:"isAudioMessage,omitempty"` - IsAutoReply bool `json:"isAutoReply,omitempty"` - IsCorrupt bool `json:"isCorrupt,omitempty"` - IsDelayed bool `json:"isDelayed,omitempty"` - IsExpired bool `json:"isExpired,omitempty"` - IsForward bool `json:"isForward,omitempty"` - IsFromMe bool `json:"isFromMe,omitempty"` - IsServiceMessage bool `json:"isServiceMessage,omitempty"` - IsSpam bool `json:"isSpam,omitempty"` - IsSystemMessage bool `json:"isSystemMessage,omitempty"` - ItemType int `json:"itemType,omitempty"` - MessageSummaryInfo any `json:"messageSummaryInfo,omitempty"` - OriginalROWID int `json:"originalROWID,omitempty"` - OtherHandle int `json:"otherHandle,omitempty"` - PartCount int `json:"partCount,omitempty"` - PayloadData any `json:"payloadData,omitempty"` - ReplyToGUID string `json:"replyToGuid,omitempty"` - ShareDirection int `json:"shareDirection,omitempty"` - ShareStatus int `json:"shareStatus,omitempty"` - Subject string `json:"subject,omitempty"` - Text string `json:"text,omitempty"` - ThreadOriginatorGUID string `json:"threadOriginatorGuid,omitempty"` - ThreadOriginatorPart string `json:"threadOriginatorPart,omitempty"` - TimeExpressiveSendStyleID any `json:"timeExpressiveSendStyleId,omitempty"` - WasDeliveredQuietly bool `json:"wasDeliveredQuietly,omitempty"` -} - -type Attachment struct { - OriginalRowID int `json:"originalROWID,omitempty"` - GUID string `json:"guid,omitempty"` - UTI string `json:"uti,omitempty"` - MimeType string `json:"mimeType,omitempty"` - TransferName string `json:"transferName,omitempty"` - TotalBytes int64 `json:"totalBytes,omitempty"` - TransferState int `json:"transferState,omitempty"` - IsOutgoing bool `json:"isOutgoing,omitempty"` - HideAttachment bool `json:"hideAttachment,omitempty"` - IsSticker bool `json:"isSticker,omitempty"` - OriginalGUID string `json:"originalGuid,omitempty"` - HasLivePhoto bool `json:"hasLivePhoto,omitempty"` - Height int64 `json:"height,omitempty"` - Width int64 `json:"width,omitempty"` - Metadata any `json:"metadata,omitempty"` -} - -type AttachmentResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data Attachment `json:"data"` -} - -type GetMessagesResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data []Message `json:"data"` - Error any `json:"error,omitempty"` -} - -type MessageResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data Message `json:"data"` - Error any `json:"error,omitempty"` -} - -type Handle struct { - Address string `json:"address,omitempty"` - Country string `json:"country,omitempty"` - OriginalROWID int `json:"originalROWID,omitempty"` - Service string `json:"service,omitempty"` - UncanonicalizedID any `json:"uncanonicalizedId,omitempty"` -} - -type SendTextRequest struct { - ChatGUID string `json:"chatGuid"` - TempGUID string `json:"tempGuid"` - Method string `json:"method"` - Message string `json:"message"` - EffectID string `json:"effectId,omitempty"` - Subject string `json:"subject,omitempty"` - SelectedMessageGUID string `json:"selectedMessageGuid,omitempty"` - PartIndex int `json:"partIndex,omitempty"` -} - -type UnsendMessage struct { - PartIndex int `json:"partIndex"` -} - -type EditMessage struct { - EditedMessage string `json:"editedMessage"` - BackwwardsCompatibilityMessage string `json:"backwardsCompatibilityMessage"` - PartIndex int `json:"partIndex"` -} - -type UnsendMessageResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data Message `json:"data,omitempty"` - Error any `json:"error"` -} - -type EditMessageResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data Message `json:"data,omitempty"` - Error any `json:"error"` -} - -type SendTextResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data Message `json:"data,omitempty"` - Error any `json:"error,omitempty"` -} - -type SendReactionRequest struct { - ChatGUID string `json:"chatGuid"` - Reaction string `json:"reaction"` - SelectedMessageGUID string `json:"selectedMessageGuid"` - PartIndex int `json:"partIndex"` -} - -type SendReactionResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data Message `json:"data,omitempty"` - Error any `json:"error"` -} - -type ReadReceiptResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Error any `json:"error"` -} - -type TypingResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Error any `json:"error"` -} - -type MessageReadResponse struct { - ChatGUID string `json:"chatGuid"` - Read bool `json:"read"` -} - -type ServerInfo struct { - PrivateAPI bool `json:"private_api"` -} - -type ServerInfoResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data ServerInfo `json:"data"` -} - -type ResolveIdentifierResponse struct { - Status int64 `json:"status"` - Message string `json:"message"` - Data Handle `json:"data"` -} diff --git a/imessage/interface.go b/imessage/interface.go index 45e96eb7..253bbd61 100644 --- a/imessage/interface.go +++ b/imessage/interface.go @@ -28,7 +28,7 @@ import ( "maunium.net/go/mautrix/id" - "go.mau.fi/mautrix-imessage/ipc" + "github.com/lrhodin/imessage/ipc" ) var ( @@ -61,6 +61,7 @@ type API interface { GetMessagesWithLimit(chatID string, limit int, backfillID string) ([]*Message, error) GetChatsWithMessagesAfter(minDate time.Time) ([]ChatIdentifier, error) GetMessage(guid string) (*Message, error) + GetMessageGUIDsSince(chatID string, minDate time.Time) ([]string, error) MessageChan() <-chan *Message ReadReceiptChan() <-chan *ReadReceipt TypingNotificationChan() <-chan *TypingNotification diff --git a/imessage/ios/ipc.go b/imessage/ios/ipc.go deleted file mode 100644 index e24c6d38..00000000 --- a/imessage/ios/ipc.go +++ /dev/null @@ -1,695 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ios - -import ( - "context" - "encoding/json" - "errors" - "math" - "os" - "strings" - "time" - - log "maunium.net/go/maulogger/v2" - - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/imessage" - "go.mau.fi/mautrix-imessage/ipc" -) - -const ( - IncomingMessage ipc.Command = "message" - IncomingReadReceipt ipc.Command = "read_receipt" - IncomingTypingNotification ipc.Command = "typing" - IncomingChat ipc.Command = "chat" - IncomingChatID ipc.Command = "chat_id" - IncomingPingServer ipc.Command = "ping_server" - IncomingBridgeStatus ipc.Command = "bridge_status" - IncomingContact ipc.Command = "contact" - IncomingMessageIDQuery ipc.Command = "message_ids_after_time" - IncomingPushKey ipc.Command = "push_key" - IncomingSendMessageStatus ipc.Command = "send_message_status" - IncomingBackfillTask ipc.Command = "backfill" -) - -func floatToTime(unix float64) (time.Time, bool) { - sec, dec := math.Modf(unix) - intSec := int64(sec) - if intSec < 1e10 { - return time.Unix(intSec, int64(dec*(1e9))), false - } else if intSec < 1e13 { - return time.UnixMilli(intSec), true - } else if intSec < 1e16 { - return time.UnixMicro(intSec), true - } else { - return time.Unix(0, intSec), true - } -} - -func timeToFloat(time time.Time) float64 { - if time.IsZero() { - return 0 - } - return float64(time.Unix()) + float64(time.Nanosecond())/1e9 -} - -type APIWithIPC interface { - imessage.API - SetIPC(*ipc.Processor) - SetContactProxy(api imessage.ContactAPI) - SetChatInfoProxy(api imessage.ChatInfoAPI) -} - -type iOSConnector struct { - IPC *ipc.Processor - bridge imessage.Bridge - log log.Logger - messageChan chan *imessage.Message - receiptChan chan *imessage.ReadReceipt - typingChan chan *imessage.TypingNotification - chatChan chan *imessage.ChatInfo - contactChan chan *imessage.Contact - messageStatusChan chan *imessage.SendMessageStatus - backfillTaskChan chan *imessage.BackfillTask - isAndroid bool - contactProxy imessage.ContactAPI - chatInfoProxy imessage.ChatInfoAPI -} - -func NewPlainiOSConnector(logger log.Logger, bridge imessage.Bridge) APIWithIPC { - return &iOSConnector{ - log: logger, - bridge: bridge, - messageChan: make(chan *imessage.Message, 256), - receiptChan: make(chan *imessage.ReadReceipt, 32), - typingChan: make(chan *imessage.TypingNotification, 32), - chatChan: make(chan *imessage.ChatInfo, 32), - contactChan: make(chan *imessage.Contact, 2048), - messageStatusChan: make(chan *imessage.SendMessageStatus, 32), - backfillTaskChan: make(chan *imessage.BackfillTask, 32), - isAndroid: bridge.GetConnectorConfig().Platform == "android", - } -} - -func NewiOSConnector(bridge imessage.Bridge) (imessage.API, error) { - ios := NewPlainiOSConnector(bridge.GetLog().Sub("iMessage").Sub("iOS"), bridge) - ios.SetIPC(bridge.GetIPC()) - return ios, nil -} - -func init() { - imessage.Implementations["ios"] = NewiOSConnector - imessage.Implementations["android"] = NewiOSConnector -} - -func (ios *iOSConnector) SetIPC(proc *ipc.Processor) { - ios.IPC = proc -} - -func (ios *iOSConnector) SetContactProxy(api imessage.ContactAPI) { - ios.contactProxy = api -} - -func (ios *iOSConnector) SetChatInfoProxy(api imessage.ChatInfoAPI) { - ios.chatInfoProxy = api -} - -func (ios *iOSConnector) Start(readyCallback func()) error { - ios.IPC.SetHandler(IncomingMessage, ios.handleIncomingMessage) - ios.IPC.SetHandler(IncomingReadReceipt, ios.handleIncomingReadReceipt) - ios.IPC.SetHandler(IncomingTypingNotification, ios.handleIncomingTypingNotification) - ios.IPC.SetHandler(IncomingChat, ios.handleIncomingChat) - ios.IPC.SetHandler(IncomingChatID, ios.handleChatIDChange) - ios.IPC.SetHandler(IncomingPingServer, ios.handleIncomingServerPing) - ios.IPC.SetHandler(IncomingBridgeStatus, ios.handleIncomingStatus) - ios.IPC.SetHandler(IncomingContact, ios.handleIncomingContact) - ios.IPC.SetHandler(IncomingMessageIDQuery, ios.handleMessageIDQuery) - ios.IPC.SetHandler(IncomingPushKey, ios.handlePushKey) - ios.IPC.SetHandler(IncomingSendMessageStatus, ios.handleIncomingSendMessageStatus) - ios.IPC.SetHandler(IncomingBackfillTask, ios.handleIncomingBackfillTask) - readyCallback() - return nil -} - -func (ios *iOSConnector) Stop() {} - -func (ios *iOSConnector) postprocessMessage(message *imessage.Message, source string) { - if len(message.Service) == 0 { - message.Service = imessage.ParseIdentifier(message.ChatGUID).Service - } - if !message.IsFromMe { - message.Sender = imessage.ParseIdentifier(message.JSONSenderGUID) - } - if len(message.JSONTargetGUID) > 0 { - message.Target = imessage.ParseIdentifier(message.JSONTargetGUID) - } - var warn bool - message.Time, warn = floatToTime(message.JSONUnixTime) - if warn { - ios.log.Warnfln("Incorrect precision timestamp in %s (from %s): %v", message.GUID, source, message.JSONUnixTime) - } - message.ReadAt, warn = floatToTime(message.JSONUnixReadAt) - if warn { - ios.log.Warnfln("Incorrect precision read at timestamp in %s (from %s): %v", message.GUID, source, message.JSONUnixReadAt) - } - if message.Tapback != nil { - _, err := message.Tapback.Parse() - if err != nil { - ios.log.Warnfln("Failed to parse tapback in %s: %v", message.GUID, err) - } - } - if len(message.NewGroupName) > 0 && message.ItemType != imessage.ItemTypeName { - ios.log.Warnfln("Autocorrecting item_type of message %s where new_group_name is set to %d (name change)", message.GUID, imessage.ItemTypeName) - message.ItemType = imessage.ItemTypeName - } else if message.ItemType == imessage.ItemTypeMessage && message.GroupActionType > 0 { - ios.log.Warnfln("Autocorrecting item_type of message %s where group_action_type is set to %d (avatar change)", message.GUID, imessage.ItemTypeAvatar) - message.ItemType = imessage.ItemTypeAvatar - } - if message.Attachment != nil && message.Attachments == nil { - ios.log.Warnfln("Autocorrecting single attachment -> attachments array in message %s", message.GUID) - message.Attachments = []*imessage.Attachment{message.Attachment} - } else if message.Attachments != nil && len(message.Attachments) > 0 && message.Attachment == nil { - message.Attachment = message.Attachments[0] - } -} - -func (ios *iOSConnector) handleIncomingMessage(data json.RawMessage) interface{} { - var message imessage.Message - err := json.Unmarshal(data, &message) - if err != nil { - ios.log.Warnln("Failed to parse incoming message: %v", err) - return nil - } - ios.postprocessMessage(&message, "incoming message") - select { - case ios.messageChan <- &message: - default: - ios.log.Warnln("Incoming message buffer is full") - } - return nil -} - -func (ios *iOSConnector) handleIncomingReadReceipt(data json.RawMessage) interface{} { - var receipt imessage.ReadReceipt - err := json.Unmarshal(data, &receipt) - if err != nil { - ios.log.Warnln("Failed to parse incoming read receipt: %v", err) - return nil - } - var warn bool - receipt.ReadAt, warn = floatToTime(receipt.JSONUnixReadAt) - if warn { - ios.log.Warnfln("Incorrect precision timestamp in incoming read receipt for %s: %v", receipt.ReadUpTo, receipt.JSONUnixReadAt) - } - - select { - case ios.receiptChan <- &receipt: - default: - ios.log.Warnln("Incoming receipt buffer is full") - } - return nil -} - -func (ios *iOSConnector) handleIncomingTypingNotification(data json.RawMessage) interface{} { - var notif imessage.TypingNotification - err := json.Unmarshal(data, ¬if) - if err != nil { - ios.log.Warnln("Failed to parse incoming typing notification: %v", err) - return nil - } - select { - case ios.typingChan <- ¬if: - default: - ios.log.Warnln("Incoming typing notification buffer is full") - } - return nil -} - -func (ios *iOSConnector) handleIncomingChat(data json.RawMessage) interface{} { - var chat imessage.ChatInfo - err := json.Unmarshal(data, &chat) - if err != nil { - ios.log.Warnln("Failed to parse incoming chat:", err) - return nil - } - chat.Identifier = imessage.ParseIdentifier(chat.JSONChatGUID) - select { - case ios.chatChan <- &chat: - default: - ios.log.Warnln("Incoming chat buffer is full") - } - return nil -} - -type ChatIDChangeRequest struct { - OldGUID string `json:"old_guid"` - NewGUID string `json:"new_guid"` -} - -type ChatIDChangeResponse struct { - Changed bool `json:"changed"` -} - -func (ios *iOSConnector) handleChatIDChange(data json.RawMessage) interface{} { - var chatIDChange ChatIDChangeRequest - err := json.Unmarshal(data, &chatIDChange) - if err != nil { - ios.log.Warnln("Failed to parse chat ID change:", err) - return nil - } - return &ChatIDChangeResponse{ - Changed: ios.bridge.ReIDPortal(chatIDChange.OldGUID, chatIDChange.NewGUID, false), - } -} - -type MessageIDQueryRequest struct { - ChatGUID string `json:"chat_guid"` - AfterTime float64 `json:"after_time"` -} - -type MessageIDQueryResponse struct { - IDs []string `json:"ids"` -} - -func (ios *iOSConnector) handleMessageIDQuery(data json.RawMessage) interface{} { - var query MessageIDQueryRequest - err := json.Unmarshal(data, &query) - if err != nil { - ios.log.Warnln("Failed to parse message ID query:", err) - return nil - } - ts, warn := floatToTime(query.AfterTime) - if warn { - ios.log.Warnfln("Incorrect precision timestamp in message ID query for %s: %v", query.ChatGUID, query.AfterTime) - } - return &MessageIDQueryResponse{ - IDs: ios.bridge.GetMessagesSince(query.ChatGUID, ts), - } -} - -func (ios *iOSConnector) handlePushKey(data json.RawMessage) interface{} { - var query imessage.PushKeyRequest - err := json.Unmarshal(data, &query) - if err != nil { - ios.log.Warnln("Failed to parse set push key request:", err) - return nil - } - ios.bridge.SetPushKey(&query) - return nil -} - -func (ios *iOSConnector) handleIncomingServerPing(_ json.RawMessage) interface{} { - start, server, end := ios.bridge.PingServer() - return &PingServerResponse{ - Start: timeToFloat(start), - Server: timeToFloat(server), - End: timeToFloat(end), - } -} - -func (ios *iOSConnector) handleIncomingStatus(data json.RawMessage) interface{} { - var state imessage.BridgeStatus - err := json.Unmarshal(data, &state) - if err != nil { - ios.log.Warnln("Failed to parse incoming status update:", err) - return nil - } - ios.bridge.SendBridgeStatus(state) - return nil -} - -func (ios *iOSConnector) handleIncomingContact(data json.RawMessage) interface{} { - var contact imessage.Contact - err := json.Unmarshal(data, &contact) - if err != nil { - ios.log.Warnln("Failed to parse incoming contact:", err) - return nil - } - select { - case ios.contactChan <- &contact: - default: - ios.log.Warnln("Incoming contact buffer is full") - } - return nil -} - -func (ios *iOSConnector) handleIncomingSendMessageStatus(data json.RawMessage) interface{} { - var status imessage.SendMessageStatus - err := json.Unmarshal(data, &status) - if len(status.Service) == 0 { - status.Service = imessage.ParseIdentifier(status.ChatGUID).Service - } - if err != nil { - ios.log.Warnln("Failed to parse incoming send message status:", err) - return nil - } - select { - case ios.messageStatusChan <- &status: - default: - ios.log.Warnln("Incoming send message status buffer is full") - } - return nil -} - -func (ios *iOSConnector) handleIncomingBackfillTask(data json.RawMessage) interface{} { - var task imessage.BackfillTask - err := json.Unmarshal(data, &task) - if err != nil { - ios.log.Warnln("Failed to parse incoming backfill task:", err) - return nil - } - select { - case ios.backfillTaskChan <- &task: - default: - ios.log.Warnln("Incoming backfill task buffer is full") - } - return nil -} - -func (ios *iOSConnector) GetMessagesSinceDate(chatID string, minDate time.Time, backfillID string) ([]*imessage.Message, error) { - resp := make([]*imessage.Message, 0) - err := ios.IPC.Request(context.Background(), ReqGetMessagesAfter, &GetMessagesAfterRequest{ - ChatGUID: chatID, - Timestamp: timeToFloat(minDate), - BackfillID: backfillID, - }, &resp) - for _, msg := range resp { - ios.postprocessMessage(msg, "messages since date") - } - return resp, err -} - -func (ios *iOSConnector) GetMessagesBetween(chatID string, minDate, maxDate time.Time) ([]*imessage.Message, error) { - panic("not implemented") -} - -func (ios *iOSConnector) GetMessagesBeforeWithLimit(chatID string, before time.Time, limit int) ([]*imessage.Message, error) { - panic("not implemented") -} - -func (ios *iOSConnector) GetMessagesWithLimit(chatID string, limit int, backfillID string) ([]*imessage.Message, error) { - resp := make([]*imessage.Message, 0) - err := ios.IPC.Request(context.Background(), ReqGetRecentMessages, &GetRecentMessagesRequest{ - ChatGUID: chatID, - Limit: limit, - BackfillID: backfillID, - }, &resp) - for _, msg := range resp { - ios.postprocessMessage(msg, "messages with limit") - } - return resp, err -} - -func (ios *iOSConnector) GetMessage(guid string) (resp *imessage.Message, err error) { - return resp, ios.IPC.Request(context.Background(), ReqGetMessage, &GetMessageRequest{ - GUID: guid, - }, &resp) -} - -func (ios *iOSConnector) GetChatsWithMessagesAfter(minDate time.Time) (resp []imessage.ChatIdentifier, err error) { - return resp, ios.IPC.Request(context.Background(), ReqGetChats, &GetChatsRequest{ - MinTimestamp: timeToFloat(minDate), - }, &resp) -} - -func (ios *iOSConnector) MessageChan() <-chan *imessage.Message { - return ios.messageChan -} - -func (ios *iOSConnector) ReadReceiptChan() <-chan *imessage.ReadReceipt { - return ios.receiptChan -} - -func (ios *iOSConnector) TypingNotificationChan() <-chan *imessage.TypingNotification { - return ios.typingChan -} - -func (ios *iOSConnector) ChatChan() <-chan *imessage.ChatInfo { - return ios.chatChan -} - -func (ios *iOSConnector) ContactChan() <-chan *imessage.Contact { - return ios.contactChan -} - -func (ios *iOSConnector) MessageStatusChan() <-chan *imessage.SendMessageStatus { - return ios.messageStatusChan -} - -func (ios *iOSConnector) BackfillTaskChan() <-chan *imessage.BackfillTask { - return ios.backfillTaskChan -} - -func (ios *iOSConnector) GetContactInfo(identifier string) (*imessage.Contact, error) { - if ios.contactProxy != nil { - return ios.contactProxy.GetContactInfo(identifier) - } - var resp imessage.Contact - err := ios.IPC.Request(context.Background(), ReqGetContact, &GetContactRequest{UserGUID: identifier}, &resp) - if err != nil { - return nil, err - } - return &resp, nil -} - -func (ios *iOSConnector) GetContactList() ([]*imessage.Contact, error) { - if ios.contactProxy != nil { - return ios.contactProxy.GetContactList() - } - var resp GetContactListResponse - err := ios.IPC.Request(context.Background(), ReqGetContactList, nil, &resp) - return resp.Contacts, err -} - -func (ios *iOSConnector) SearchContactList(searchTerms string) ([]*imessage.Contact, error) { - return nil, errors.New("not implemented") -} - -func (ios *iOSConnector) RefreshContactList() error { - return errors.New("not implemented") -} - -func (ios *iOSConnector) GetChatInfo(chatID, threadID string) (*imessage.ChatInfo, error) { - var resp imessage.ChatInfo - err := ios.IPC.Request(context.Background(), ReqGetChat, &GetChatRequest{ChatGUID: chatID, ThreadID: threadID}, &resp) - if err != nil { - if ios.chatInfoProxy != nil { - ios.log.Warnfln("Failed to get chat info for %s: %v, falling back to chat info proxy", chatID, err) - return ios.chatInfoProxy.GetChatInfo(chatID, threadID) - } - return nil, err - } - return &resp, nil -} - -func (ios *iOSConnector) GetGroupAvatar(chatID string) (*imessage.Attachment, error) { - var resp imessage.Attachment - err := ios.IPC.Request(context.Background(), ReqGetChatAvatar, &GetChatRequest{ChatGUID: chatID}, &resp) - if err != nil { - if ios.chatInfoProxy != nil { - ios.log.Warnfln("Failed to get group avatar for %s: %v, falling back to chat info proxy", chatID, err) - return ios.chatInfoProxy.GetGroupAvatar(chatID) - } - return nil, err - } - return &resp, nil -} - -func (ios *iOSConnector) SendMessage(chatID, text string, replyTo string, replyToPart int, richLink *imessage.RichLink, metadata imessage.MessageMetadata) (*imessage.SendResponse, error) { - var resp imessage.SendResponse - err := ios.IPC.Request(context.Background(), ReqSendMessage, &SendMessageRequest{ - ChatGUID: chatID, - Text: text, - ReplyTo: replyTo, - ReplyToPart: replyToPart, - RichLink: richLink, - Metadata: metadata, - }, &resp) - if err == nil { - var warn bool - resp.Time, warn = floatToTime(resp.UnixTime) - if warn { - ios.log.Warnfln("Incorrect precision timestamp in message send response %s: %v", resp.GUID, resp.UnixTime) - } - } - if len(resp.Service) == 0 { - resp.Service = imessage.ParseIdentifier(chatID).Service - } - return &resp, err -} - -func (ios *iOSConnector) SendFile(chatID, text, filename string, pathOnDisk string, replyTo string, replyToPart int, mimeType string, voiceMemo bool, metadata imessage.MessageMetadata) (*imessage.SendResponse, error) { - var resp imessage.SendResponse - err := ios.IPC.Request(context.Background(), ReqSendMedia, &SendMediaRequest{ - ChatGUID: chatID, - Text: text, - Attachment: imessage.Attachment{ - FileName: filename, - PathOnDisk: pathOnDisk, - MimeType: mimeType, - }, - ReplyTo: replyTo, - ReplyToPart: replyToPart, - IsAudioMessage: voiceMemo, - Metadata: metadata, - }, &resp) - if err == nil { - var warn bool - resp.Time, warn = floatToTime(resp.UnixTime) - if warn { - ios.log.Warnfln("Incorrect precision timestamp in file message send response %s: %v", resp.GUID, resp.UnixTime) - } - } - return &resp, err -} - -func (ios *iOSConnector) SendFileCleanup(sendFileDir string) { - _ = os.RemoveAll(sendFileDir) -} - -func (ios *iOSConnector) SendTapback(chatID, targetGUID string, targetPart int, tapback imessage.TapbackType, remove bool) (*imessage.SendResponse, error) { - if remove { - tapback += imessage.TapbackRemoveOffset - } - var resp imessage.SendResponse - err := ios.IPC.Request(context.Background(), ReqSendTapback, &SendTapbackRequest{ - ChatGUID: chatID, - TargetGUID: targetGUID, - TargetPart: targetPart, - Type: tapback, - }, &resp) - if err != nil { - return nil, err - } - return &resp, err -} - -func (ios *iOSConnector) SendReadReceipt(chatID, readUpTo string) error { - return ios.IPC.Send(ReqSendReadReceipt, &SendReadReceiptRequest{ - ChatGUID: chatID, - ReadUpTo: readUpTo, - }) -} - -func (ios *iOSConnector) SendTypingNotification(chatID string, typing bool) error { - return ios.IPC.Send(ReqSetTyping, &SetTypingRequest{ - ChatGUID: chatID, - Typing: typing, - }) -} - -func (ios *iOSConnector) SendMessageBridgeResult(chatID, messageID string, eventID id.EventID, success bool) { - if !ios.isAndroid { - // Only android needs message bridging confirmations - return - } - _ = ios.IPC.Send(ReqMessageBridgeResult, &MessageBridgeResult{ - ChatGUID: chatID, - GUID: messageID, - EventID: eventID, - Success: success, - }) -} - -func (ios *iOSConnector) SendBackfillResult(chatID, backfillID string, success bool, idMap map[string][]id.EventID) { - if !ios.isAndroid { - // Only android needs message bridging confirmations - return - } - if idMap == nil { - idMap = map[string][]id.EventID{} - } - _ = ios.IPC.Send(ReqBackfillResult, &BackfillResult{ - ChatGUID: chatID, - BackfillID: backfillID, - Success: success, - MessageIDs: idMap, - }) -} - -func (ios *iOSConnector) SendChatBridgeResult(guid string, mxid id.RoomID) { - _ = ios.IPC.Send(ReqChatBridgeResult, &ChatBridgeResult{ - ChatGUID: guid, - MXID: mxid, - }) -} - -func (ios *iOSConnector) NotifyUpcomingMessage(eventID id.EventID) { - if !ios.isAndroid { - // Only android needs to be notified about upcoming messages to stay awake - return - } - _ = ios.IPC.Send(ReqUpcomingMessage, &UpcomingMessage{EventID: eventID}) -} - -func (ios *iOSConnector) PreStartupSyncHook() (resp imessage.StartupSyncHookResponse, err error) { - err = ios.IPC.Request(context.Background(), ReqPreStartupSync, nil, &resp) - return -} - -func (ios *iOSConnector) PostStartupSyncHook() { - _ = ios.IPC.Send(ReqPostStartupSync, nil) -} - -func (ios *iOSConnector) ResolveIdentifier(identifier string) (string, error) { - if ios.isAndroid { - return imessage.Identifier{ - LocalID: identifier, - Service: "SMS", - IsGroup: false, - }.String(), nil - } - req := ResolveIdentifierRequest{Identifier: identifier} - var resp ResolveIdentifierResponse - err := ios.IPC.Request(context.Background(), ReqResolveIdentifier, &req, &resp) - // Hack: barcelona probably shouldn't return mailto: or tel: - resp.GUID = strings.Replace(resp.GUID, "iMessage;-;mailto:", "iMessage;-;", 1) - resp.GUID = strings.Replace(resp.GUID, "iMessage;-;tel:", "iMessage;-;", 1) - return resp.GUID, err -} - -func (ios *iOSConnector) PrepareDM(guid string) error { - if ios.isAndroid { - return nil - } - return ios.IPC.Request(context.Background(), ReqPrepareDM, &PrepareDMRequest{GUID: guid}, nil) -} - -func (ios *iOSConnector) CreateGroup(users []string) (*imessage.CreateGroupResponse, error) { - var resp imessage.CreateGroupResponse - err := ios.IPC.Request(context.Background(), ReqCreateGroup, &CreateGroupRequest{GUIDs: users}, &resp) - if err != nil { - return nil, err - } - return &resp, nil -} - -func (ios *iOSConnector) Capabilities() imessage.ConnectorCapabilities { - return imessage.ConnectorCapabilities{ - MessageSendResponses: true, - MessageStatusCheckpoints: ios.isAndroid, - SendTapbacks: !ios.isAndroid, - SendReadReceipts: !ios.isAndroid, - SendTypingNotifications: !ios.isAndroid, - SendCaptions: ios.isAndroid, - BridgeState: false, - ContactChatMerging: !ios.isAndroid, - ChatBridgeResult: ios.isAndroid, - } -} diff --git a/imessage/ios/ipc.md b/imessage/ios/ipc.md deleted file mode 100644 index 6e871782..00000000 --- a/imessage/ios/ipc.md +++ /dev/null @@ -1,270 +0,0 @@ -# iMessage bridge protocol - -## Setup (when mautrix-imessage is the subprocess) -The bridge needs a config file that has the homeserver details, access tokens -and other such things. Brooklyn needs to get that config file from somewhere -and point the bridge at it when running. The setup UX should just be scanning -a QR code. - -1. User scans QR code with Brooklyn on iPhone. The QR code contains a URL, - which may end in a newline. Strip away the newline if necessary. -2. Start the mautrix-imessage subprocess with `--url --output-redirect`. - The second flag tells the bridge to follow potential redirects in the URL - and output the direct URL using the `config_url` IPC command. -3. Save the URL from the output and just pass `--url ` on future runs. - -When the bridge is started, it will download the config from the given URL and -save it to the file specified with the `-c` flag (defaults to `config.yaml`). - -There should also be some "logout" button that forgets the URL and deletes the -config file. - -## IPC -The protocol is based on sending JSON objects separated by newlines (`\n`). - -Requests can be sent in both directions. Requests must contain a `command` -field that specifies the type of request. - -Requests can also contain an `id` field with an integer value, which is used -when responding to the request. If the `id` field is not present, a response -must not be sent. If the `id` field is present, a response must be sent, even -if the command is not recognized. Responses must use a type of `response` or -`error` with the same ID as the request. - -IDs should never be reused within the same connection. An incrementing integer -is a good option for unique request IDs. - -All other request parameters and response data must be in the `data` field. -The field may be an array or an object depending on the request type. - -Responses with `"command": "error"` must include an object in the `data` field -with a human-readable error message in the `message` field and some simple -error code in the `code` field. - -### Examples - -```json -{ - "command": "get_chat", - "id": 123, - "data": { - "chat_guid": "iMessage;+;chat123456" - } -} -``` - -Success response: - -```json -{ - "command": "response", - "id": 123, - "data": { - "title": "iMessage testing", - "members": ["+1234567890", "+3581234567", "user@example.com"] - } -} -``` - -Error response: - -```json -{ - "command": "error", - "id": 123, - "data": { - "code": "not_found", - "message": "That chat does not exist" - } -} -``` - -Another error response: - -```json -{ - "command": "error", - "id": 123, - "data": { - "code": "unknown_command", - "message": "Unknown command 'get_chat'" - } -} -``` - -### Requests - -#### to Brooklyn -* Send a message (request type `send_message`) - * `chat_guid` (str) - Chat identifier - * `text` (str) - Text to send - * Response should contain the sent message `guid`, `timestamp`, and (preliminary) `service` - * If the service is omitted, it defaults to the service of the chat. -* Send a media message (request type `send_media`) - * `chat_guid` (str) - Chat identifier - * `text` (str) - An optional caption to send with the media - * `path_on_disk` (str) - The path to the file on disk - * `file_name` (str) - The user-facing name of the file - * `mime_type` (str) - The mime type of the file - * Response should contain the sent message `guid` and `timestamp` -* Send (or remove) a tapback (request type `send_tapback`) - * `chat_guid` (str) - Chat identifier - * `target_guid` (str) - The target message ID - * `target_part` (int) - The target message part index - * `type` (int) - The type of tapback to send - * `metadata` (any) - Metadata to send with the message. Pass any valid JSON - * Response should contain the sent tapback `guid` and `timestamp` - * Removing tapbacks is done by sending a 300x type instead of 200x (same as iMessage internally) -* Send a read receipt (request type `send_read_receipt`) - * `chat_guid` (str) - Chat identifier - * `read_up_to` (str, UUID) - The GUID of the last read message -* Send a typing notification (request type `set_typing`) - * `chat_guid` (str) - The chat where the user is typing. - * `typing` (bool) - Whether to send or cancel the typing notification. -* Get list of chats with messages after date (request type `get_chats`) - * `min_timestamp` (double) - Unix timestamp - * Response should be an array of objects with the following fields: - * `chat_guid` (str) - The chat ID -* Get chat info (request type `get_chat`) - * `chat_guid` (str) - Chat identifier, e.g. `iMessage;+;chat123456` - * Response contains: - * `title` (displayname of group, if it is one) - * `members` (list of participant user identifiers) -* Get group chat avatar (request type `get_chat_avatar`) - * `chat_guid` (str) - Group chat identifier - * Response contains the same data as message `attachment`s: `mime_type`, - `path_on_disk` and `file_name` -* Get contact info (request type `get_contact`) - * `user_guid` (str) - User identifier, e.g. `iMessage;-;+123456` - or `SMS;-;+123456` - * Returns contact info - * `first_name` (str) - * `last_name` (str) - * `nickname` (str) - * `avatar` (base64 str) - The avatar image data. I think they're small - enough that it doesn't need to go through the disk. - * `phones` (list of str) - * `emails` (list of str) -* Get full contact list (request type `get_contact_list`) - * Returns an object with a `contacts` key that contains a list of contacts in the same format as `get_contact` - * There should be an additional `primary_identifier` field if the primary identifier of the contact is known. - * When the user starts a chat with the contact, that identifier will be passed to `resolve_identifier`. - * Avatars can be omitted in this case. -* Get messages after a specific timestamp (request type `get_messages_after`) - * Request includes `chat_guid` and `timestamp` - * Returns list of messages (see incoming messages format below) - * List should be sorted by timestamp in ascending order -* Get X most recent messages (request type `get_recent_messages`) - * Request includes `chat_guid`, `limit` and `backfill_id` - * Same return type as with `get_messages_after` -* Resolve an identifier into a private chat GUID (request type `resolve_identifier`) - * `identifier` (str) - International phone number or email - * Returns `guid` (str) with a GUID for the chat with the user. - * If the identifier isn't valid or messages can't be sent to it, return a - standard error response with an appropriate message. -* Prepare for startup sync (request type `pre_startup_sync`) - * Sent when the bridge is starting and is about to do the startup sync. - The sync won't start until this request responds. - * Optionally, the response may contain `"skip_sync": true` to skip the startup sync. -* Finish startup sync (request type `post_startup_sync`) - * Sent when the bridge has completed startup sync. - * Doesn't need a response. -* Prepare a new private chat (request type `prepare_dm`) - * `guid` (str) - The GUID of the user to start a chat with - * Doesn't return anything (just acknowledge with an empty response). -* Confirm a message being bridged (request type `message_bridge_result`). - * Has fields `chat_guid`, `message_guid`, `event_id`, and `success`. - * Doesn't have an ID, so it doesn't need to be responded to. - * Only enabled for android-sms. -* Confirm a backfill was completed (request type `backfill_result`). - * `chat_guid` (str) - The chat where the backfill happened. - * `backfill_id` (str) - The backfill ID, either provided in `get_recent_messages` or the `backfill` task. - * `success` (bool) - Whether the batch was successful. - * `message_ids` (object) - Map from message GUID to list of Matrix event IDs. - The event ID list for a given message may be empty if the message was received, but wasn't recognized. - When `success` is `false`, the map may be entirely empty. -* Notification of portal room ID for a chat GUID (request type `chat_bridge_result`) - * Has fields `chat_guid`, `mxid` - * Only enabled for android-sms. -* Notification of an upcoming message (request type `upcoming_message`) - * Has field `event_id` - * Only enabled for android-sms. - -#### to mautrix-imessage -* Incoming messages (request type `message`) - * `guid` (str, UUID) - Global message ID - * `timestamp` (double) - Unix timestamp - * `subject` (str) - Message subject, usually empty - * `text` (str) - Message text - * `chat_guid` (str) - Chat identifier, e.g. `iMessage;+;chat`, - `iMessage;-;+123456` or `SMS;-;+123456` - * `sender_guid` (str) - User identifier, e.g. `iMessage;-;+123456` or - `SMS;-;+123456`. Not required if `is_from_me` is true. - * `is_from_me` (bool) - True if the message was sent by the local user - * `is_read` (bool) - True if the message was already read - * `service` (str) - Explicitly states the origin service (e.g. `SMS`, `iMessage`), most useful when using chat merging - * If the service is omitted, it defaults to the service of the chat. - * `thread_originator_guid` (str, UUID, optional) - The thread originator message ID - * `thread_originator_part` (int) - The thread originator message part index (e.g. 0) - * `attachments` (list of objects, optional) - Attachment info (media messages, maybe stickers?) - * `mime_type` (str, optional) - The mime type of the file, optional - * `file_name` (str) - The user-facing file name - * `path_on_disk` (str) - The file path on disk that the bridge can read - * `associated_message` (object, optional) - Associated message info (tapback/sticker) - * `target_guid` (str) - The message that this event is targeting, e.g. `p:0/` - * `type` (int) - The type of association (1000 = sticker, 200x = tapback, 300x = tapback remove) - * `error_notice` (str, optional) - An error notice to send to Matrix. Can be a dedicated message (with `item_type` = -100) or a part of a real message. - * `item_type` (int, optional) - Message type, 0 = normal message, 1 = member change, 2 = name change, 3 = avatar change, -100 = error notice. - * `group_action_type` (int, optional) - Group action type, which is a subtype of `item_type` - * For member changes, 0 = add member, 1 = remove member - * For avatar changes, 1 = set avatar, 2 = remove avatar - * `target_guid` (str, optional) - For member change messages, the user identifier of the user being changed. - * `new_group_title` (str, optional) - New name for group when the message was a group name change - * `metadata` (any) - Metadata sent with the message. Any valid JSON may be present here. -* Incoming read receipts (request type `read_receipt`) - * `sender_guid` (str) - the user who sent the read receipt. Not required if `is_from_me` is true. - * `is_from_me` (bool) - True if the read receipt is from the local user (e.g. from another device). - * `chat_guid` (str) - The chat where the read receipt is. - * `read_up_to` (str, UUID) - The GUID of the last read message. - * `read_at` (double) - Unix timestamp when the read receipt happened. -* Incoming typing notifications (request type `typing`) - * `chat_guid` (str) - The chat where the user is typing. - * `typing` (bool) - Whether the user is typing or not. -* Chat info changes and new chats (request type `chat`) - * Same info as `get_chat` responses: `title`, `members`, plus a `chat_guid` field to identify the chat. - * `no_create_room` can be set to `true` to disable creating a new room if one doesn't exist. - * `delete` can be set to `true` to delete the portal room. Other fields are ignored if this is set. -* Chat ID change (request type `chat_id`) - * `old_guid` (str) - The old chat GUID. - * `new_guid` (str) - The new chat GUID. - * Returns `changed` with a boolean indicating whether the change was applied. - * If false, it means a chat with the new GUID already existed, or a chat with the old GUID didn't exist. -* Contact info changes (request type `contact`) - * Same info as `get_contact` responses, plus a `user_guid` field to identify the contact. -* Outgoing message status (request type `send_message_status`) - * `guid` (str, UUID) - The GUID of the message that the status update is about. - * `chat_guid` (str) - The GUID of the chat from which this message originated - * `status` (str, enum) - The current status of the message. - * Allowed values: `sent`, `delivered`, `failed` - * `message` (str) - A human-readable description of the status, if needed. - * `status_code` (str) - A machine-readable identifier for the current status. - * `service` (str) - The service the outgoing message will be sent on. If the message is downgraded to SMS, you should send this payload again with service set to `SMS`. - * If the service is omitted, it defaults to the service of the chat. -* Pinging the Matrix websocket (request type `ping_server`) - * Used to ensure that the websocket connection is alive. Should be called if there's some reason to believe - the connection may have silently failed, e.g. when the device wakes up from sleep. - * Doesn't take any parameters. Responds with three timestamps: `start`, `server` and `end`. -* Sending status updates (request type `bridge_status`) - * Inform the server about iMessage connection issues. - * `state_event` (str, enum) - The state of the bridge. - * Allowed values: `STARTING`, `UNCONFIGURED`, `CONNECTING`, `BACKFILLING`, `CONNECTED`, `TRANSIENT_DISCONNECT`, `BAD_CREDENTIALS`, `UNKNOWN_ERROR`, `LOGGED_OUT` - * `error` (str) - An error code that the user's client application can use if it needs to do something special to handle the error. - * `message` (str) - Human-readable error message. - * `remote_id` (str, optional) - The iMessage user ID of the bridge user. - * `remote_name` (str, optional) - The iMessage displayname of the bridge user. -* Get bridged message IDs after certain time (request type `message_ids_after_time`) - * `chat_guid` (str) - The chat GUID to get the message IDs from. - * `after_time` (double) - The unix timestamp after which to find messages. -* Backfill task (request type `backfill`) - * `chat_guid` (str) - The chat GUID to backfill. - * `messages` (list of objects) - The messages to backfill. Same list format as `get_recent_messages`. Sorted from oldest to newest message. diff --git a/imessage/ios/requests.go b/imessage/ios/requests.go deleted file mode 100644 index e367d687..00000000 --- a/imessage/ios/requests.go +++ /dev/null @@ -1,164 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ios - -import ( - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/imessage" - "go.mau.fi/mautrix-imessage/ipc" -) - -const ( - ReqSendMessage ipc.Command = "send_message" - ReqSendMedia ipc.Command = "send_media" - ReqSendTapback ipc.Command = "send_tapback" - ReqSendReadReceipt ipc.Command = "send_read_receipt" - ReqSetTyping ipc.Command = "set_typing" - ReqGetChats ipc.Command = "get_chats" - ReqGetChat ipc.Command = "get_chat" - ReqGetChatAvatar ipc.Command = "get_chat_avatar" - ReqGetContact ipc.Command = "get_contact" - ReqGetContactList ipc.Command = "get_contact_list" - ReqGetMessagesAfter ipc.Command = "get_messages_after" - ReqGetRecentMessages ipc.Command = "get_recent_messages" - ReqGetMessage ipc.Command = "get_message" - ReqPreStartupSync ipc.Command = "pre_startup_sync" - ReqPostStartupSync ipc.Command = "post_startup_sync" - ReqResolveIdentifier ipc.Command = "resolve_identifier" - ReqPrepareDM ipc.Command = "prepare_dm" - ReqCreateGroup ipc.Command = "prepare_group_chat" - ReqMessageBridgeResult ipc.Command = "message_bridge_result" - ReqChatBridgeResult ipc.Command = "chat_bridge_result" - ReqBackfillResult ipc.Command = "backfill_result" - ReqUpcomingMessage ipc.Command = "upcoming_message" -) - -type SendMessageRequest struct { - ChatGUID string `json:"chat_guid"` - Text string `json:"text"` - ReplyTo string `json:"reply_to"` - ReplyToPart int `json:"reply_to_part"` - RichLink *imessage.RichLink `json:"rich_link,omitempty"` - Metadata imessage.MessageMetadata `json:"metadata,omitempty"` -} - -type SendMediaRequest struct { - ChatGUID string `json:"chat_guid"` - Text string `json:"text"` - imessage.Attachment - ReplyTo string `json:"reply_to"` - ReplyToPart int `json:"reply_to_part"` - IsAudioMessage bool `json:"is_audio_message"` - Metadata imessage.MessageMetadata `json:"metadata,omitempty"` -} - -type SendTapbackRequest struct { - ChatGUID string `json:"chat_guid"` - TargetGUID string `json:"target_guid"` - TargetPart int `json:"target_part"` - Type imessage.TapbackType `json:"type"` -} - -type SendReadReceiptRequest struct { - ChatGUID string `json:"chat_guid"` - ReadUpTo string `json:"read_up_to"` -} - -type SetTypingRequest struct { - ChatGUID string `json:"chat_guid"` - Typing bool `json:"typing"` -} - -type GetChatRequest struct { - ChatGUID string `json:"chat_guid"` - ThreadID string `json:"thread_id"` -} - -type GetChatsRequest struct { - MinTimestamp float64 `json:"min_timestamp"` -} - -type GetContactRequest struct { - UserGUID string `json:"user_guid"` -} - -type GetContactListResponse struct { - Contacts []*imessage.Contact `json:"contacts"` -} - -type GetRecentMessagesRequest struct { - ChatGUID string `json:"chat_guid"` - Limit int `json:"limit"` - BackfillID string `json:"backfill_id"` -} - -type GetMessageRequest struct { - GUID string `json:"guid"` -} - -type GetMessagesAfterRequest struct { - ChatGUID string `json:"chat_guid"` - Timestamp float64 `json:"timestamp"` - BackfillID string `json:"backfill_id"` -} - -type PingServerResponse struct { - Start float64 `json:"start_ts"` - Server float64 `json:"server_ts"` - End float64 `json:"end_ts"` -} - -type ResolveIdentifierRequest struct { - Identifier string `json:"identifier"` -} - -type ResolveIdentifierResponse struct { - GUID string `json:"guid"` -} - -type PrepareDMRequest struct { - GUID string `json:"guid"` -} - -type CreateGroupRequest struct { - GUIDs []string `json:"guids"` -} - -type MessageBridgeResult struct { - ChatGUID string `json:"chat_guid"` - GUID string `json:"message_guid"` - EventID id.EventID `json:"event_id,omitempty"` - Success bool `json:"success"` -} - -type ChatBridgeResult struct { - ChatGUID string `json:"chat_guid"` - MXID id.RoomID `json:"mxid"` -} - -type BackfillResult struct { - ChatGUID string `json:"chat_guid"` - BackfillID string `json:"backfill_id"` - Success bool `json:"success"` - - MessageIDs map[string][]id.EventID `json:"message_ids"` -} - -type UpcomingMessage struct { - EventID id.EventID `json:"event_id"` -} diff --git a/imessage/mac-nosip/contactproxy.go b/imessage/mac-nosip/contactproxy.go deleted file mode 100644 index 1be76fe9..00000000 --- a/imessage/mac-nosip/contactproxy.go +++ /dev/null @@ -1,43 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2023 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build darwin && !ios - -package mac_nosip - -import ( - "fmt" - - log "maunium.net/go/maulogger/v2" - - "go.mau.fi/mautrix-imessage/imessage" - "go.mau.fi/mautrix-imessage/imessage/mac" -) - -func setupContactProxy(log log.Logger) (imessage.ContactAPI, error) { - store := mac.NewContactStore() - err := store.RequestContactAccess() - if err != nil { - return nil, fmt.Errorf("failed to request contact access: %w", err) - } else if store.HasContactAccess { - log.Infoln("Contact access is allowed") - } else { - log.Warnln("Contact access is not allowed") - } - return store, nil -} - -var setupChatInfoProxy = mac.NewChatInfoDatabase diff --git a/imessage/mac-nosip/nocontactproxy.go b/imessage/mac-nosip/nocontactproxy.go deleted file mode 100644 index cfc3c929..00000000 --- a/imessage/mac-nosip/nocontactproxy.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build !darwin || ios - -package mac_nosip - -import ( - "errors" - - log "maunium.net/go/maulogger/v2" - - "go.mau.fi/mautrix-imessage/imessage" -) - -func setupContactProxy(log log.Logger) (imessage.ContactAPI, error) { - return nil, errors.New("can't use native contact access: not compiled for a Mac") -} - -func setupChatInfoProxy(log log.Logger) (imessage.ChatInfoAPI, error) { - return nil, errors.New("can't use native chat info access: not compiled for a Mac") -} diff --git a/imessage/mac-nosip/nosip.go b/imessage/mac-nosip/nosip.go deleted file mode 100644 index 631c805c..00000000 --- a/imessage/mac-nosip/nosip.go +++ /dev/null @@ -1,334 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package mac_nosip - -import ( - "encoding/json" - "errors" - "fmt" - "net" - "os" - "os/exec" - "runtime" - "strings" - "syscall" - "time" - - log "maunium.net/go/maulogger/v2" - - "go.mau.fi/mautrix-imessage/imessage" - "go.mau.fi/mautrix-imessage/imessage/ios" - "go.mau.fi/mautrix-imessage/ipc" -) - -const IncomingLog ipc.Command = "log" -const ReqPing ipc.Command = "ping" - -type MacNoSIPConnector struct { - ios.APIWithIPC - path string - args []string - proc *exec.Cmd - log log.Logger - procLog log.Logger - printPayloadContent bool - pingInterval time.Duration - stopPinger chan bool - unixSocket string - unixServer net.Listener - stopping bool - locale string - env []string - - chatInfoProxy imessage.API -} - -type NoopContacts struct{} - -func (n NoopContacts) GetContactInfo(_ string) (*imessage.Contact, error) { - return nil, nil -} - -func (n NoopContacts) GetContactList() ([]*imessage.Contact, error) { - return []*imessage.Contact{}, nil -} - -func (n NoopContacts) SearchContactList(searchTerms string) ([]*imessage.Contact, error) { - return nil, errors.New("not implemented") -} - -func (n NoopContacts) RefreshContactList() error { - return errors.New("not implemented") -} - -func NewMacNoSIPConnector(bridge imessage.Bridge) (imessage.API, error) { - logger := bridge.GetLog().Sub("iMessage").Sub("Mac-noSIP") - processLogger := bridge.GetLog().Sub("iMessage").Sub("Barcelona") - iosConn := ios.NewPlainiOSConnector(logger, bridge) - contactsMode := bridge.GetConnectorConfig().ContactsMode - switch contactsMode { - case "mac": - contactProxy, err := setupContactProxy(logger) - if err != nil { - return nil, err - } - iosConn.SetContactProxy(contactProxy) - case "disable": - iosConn.SetContactProxy(NoopContacts{}) - case "ipc": - default: - return nil, fmt.Errorf("unknown contacts mode %q", contactsMode) - } - chatInfoProxy, err := setupChatInfoProxy(logger.Sub("ChatInfoProxy")) - if err != nil { - logger.Warnfln("Failed to set up chat info proxy: %v", err) - } else { - iosConn.SetChatInfoProxy(chatInfoProxy) - } - unixSocket := bridge.GetConnectorConfig().UnixSocket - if unixSocket == "" { - unixSocket = "mautrix-imessage.sock" - } - return &MacNoSIPConnector{ - APIWithIPC: iosConn, - path: bridge.GetConnectorConfig().IMRestPath, - args: bridge.GetConnectorConfig().IMRestArgs, - log: logger, - procLog: processLogger, - printPayloadContent: bridge.GetConnectorConfig().LogIPCPayloads, - pingInterval: time.Duration(bridge.GetConnectorConfig().PingInterval) * time.Second, - stopPinger: make(chan bool, 8), - unixSocket: unixSocket, - locale: bridge.GetConnectorConfig().HackySetLocale, - env: bridge.GetConnectorConfig().Environment, - }, nil -} - -func (mac *MacNoSIPConnector) run(command string, args ...string) (string, error) { - output, err := exec.Command(command, args...).CombinedOutput() - if err != nil { - err = fmt.Errorf("failed to execute %s: %w", command, err) - } - return strings.TrimSpace(string(output)), err -} - -func (mac *MacNoSIPConnector) fixLocale() { - if mac.locale == "" { - return - } - mac.log.Debugln("Checking user locale") - locale, err := mac.run("/usr/bin/defaults", "read", "-g", "AppleLocale") - if err != nil { - mac.log.Warnfln("Failed to read current locale: %v", err) - return - } - if locale != mac.locale { - _, err = mac.run("/usr/bin/defaults", "write", "-g", "AppleLocale", mac.locale) - if err != nil { - mac.log.Warnfln("Failed to change locale: %v", err) - } else { - mac.log.Infofln("Changed user locale from %s to %s", locale, mac.locale) - } - } else { - mac.log.Debugln("User locale is already set to", locale) - } -} - -func (mac *MacNoSIPConnector) Start(readyCallback func()) error { - if mac.locale != "" { - mac.fixLocale() - } - mac.log.Debugln("Preparing to execute", mac.path) - args := append(mac.args, "--unix-socket", mac.unixSocket) - mac.proc = exec.Command(mac.path, args...) - mac.proc.Env = append(os.Environ(), mac.env...) - mac.proc.Stdout = mac.procLog.Sub("Stdout").Writer(log.LevelInfo) - mac.proc.Stderr = mac.procLog.Sub("Stderr").Writer(log.LevelError) - - if runtime.GOOS == "ios" { - mac.log.Debugln("Running Barcelona connector on iOS, temp files will be world-readable") - imessage.TempFilePermissions = 0644 - imessage.TempDirPermissions = 0755 - } - - var err error - if _, err = os.Stat(mac.unixSocket); err == nil { - mac.log.Debugln("Unlinking existing unix socket") - err = syscall.Unlink(mac.unixSocket) - if err != nil { - mac.log.Warnln("Error unlinking existing unix socket:", err) - } - } - mac.unixServer, err = net.Listen("unix", mac.unixSocket) - if err != nil { - return fmt.Errorf("failed to open unix socket: %w", err) - } - - err = mac.proc.Start() - if err != nil { - return fmt.Errorf("failed to start Barcelona: %w", err) - } - go func() { - err := mac.proc.Wait() - if mac.stopping { - return - } - if err != nil { - mac.log.Errorfln("Barcelona died with exit code %d and error %v, exiting bridge...", mac.proc.ProcessState.ExitCode(), err) - } else { - mac.log.Errorfln("Barcelona died with exit code %d, exiting bridge...", mac.proc.ProcessState.ExitCode()) - } - _ = mac.unixServer.Close() - _ = syscall.Unlink(mac.unixSocket) - os.Exit(mac.proc.ProcessState.ExitCode()) - }() - mac.log.Debugln("Process started, PID", mac.proc.Process.Pid) - - conn, err := mac.unixServer.Accept() - if err != nil { - mac.log.Errorfln("Error accepting unix socket connection: %v", err) - os.Exit(44) - } - mac.log.Debugln("Received unix socket connection") - - ipcProc := ipc.NewCustomProcessor(conn, conn, mac.log, mac.printPayloadContent) - mac.SetIPC(ipcProc) - ipcProc.SetHandler(IncomingLog, mac.handleIncomingLog) - go ipcProc.Loop() - - go mac.pingLoop(ipcProc) - - return mac.APIWithIPC.Start(readyCallback) -} - -const maxTimeouts = 2 - -func (mac *MacNoSIPConnector) pingLoop(ipcProc *ipc.Processor) { - timeouts := 0 - for { - resp, _, err := ipcProc.RequestAsync(ReqPing, nil) - if err != nil { - mac.log.Fatalln("Failed to send ping to Barcelona") - os.Exit(254) - } - timeout := time.After(mac.pingInterval) - select { - case <-mac.stopPinger: - return - case <-timeout: - timeouts++ - if timeouts >= maxTimeouts { - mac.log.Fatalfln("Didn't receive pong from Barcelona within %s", mac.pingInterval) - os.Exit(255) - } else { - mac.log.Warnfln("Didn't receive pong from Barcelona within %s", mac.pingInterval) - continue - } - case rawData := <-resp: - if rawData.Command == "error" { - mac.log.Fatalfln("Barcelona returned error response to pong: %s", rawData.Data) - os.Exit(253) - } - timeouts = 0 - } - select { - case <-timeout: - case <-mac.stopPinger: - return - } - } -} - -type LogLine struct { - Message string `json:"message"` - Level string `json:"level"` - Module string `json:"module"` - Metadata map[string]interface{} `json:"metadata"` -} - -func getLevelFromName(name string) log.Level { - switch strings.ToUpper(name) { - case "DEBUG": - return log.LevelDebug - case "INFO": - return log.LevelInfo - case "WARN": - return log.LevelWarn - case "ERROR": - return log.LevelError - case "FATAL": - return log.LevelFatal - default: - return log.Level{Name: name, Color: -1, Severity: 1} - } -} - -func (mac *MacNoSIPConnector) handleIncomingLog(data json.RawMessage) interface{} { - var message LogLine - err := json.Unmarshal(data, &message) - if err != nil { - mac.log.Warnfln("Failed to parse incoming log line: %v (data: %s)", err, data) - return nil - } - logger := mac.procLog.Subm(message.Module, message.Metadata) - logger.Log(getLevelFromName(message.Level), message.Message) - return nil -} - -func (mac *MacNoSIPConnector) Stop() { - if mac.proc == nil || mac.proc.ProcessState == nil || mac.proc.ProcessState.Exited() { - mac.log.Debugln("Barcelona subprocess not running when Stop was called") - return - } - mac.stopping = true - mac.stopPinger <- true - err := mac.proc.Process.Signal(syscall.SIGTERM) - if err != nil && !errors.Is(err, os.ErrProcessDone) { - mac.log.Warnln("Failed to send SIGTERM to Barcelona process:", err) - } - time.AfterFunc(3*time.Second, func() { - err = mac.proc.Process.Kill() - if err != nil && !errors.Is(err, os.ErrProcessDone) { - mac.log.Warnln("Failed to kill Barcelona process:", err) - } - }) - err = mac.proc.Wait() - if err != nil { - mac.log.Warnln("Error waiting for Barcelona process:", err) - } - _ = mac.unixServer.Close() - _ = syscall.Unlink(mac.unixSocket) -} - -func (mac *MacNoSIPConnector) Capabilities() imessage.ConnectorCapabilities { - return imessage.ConnectorCapabilities{ - MessageSendResponses: true, - SendTapbacks: true, - SendReadReceipts: true, - SendTypingNotifications: true, - SendCaptions: true, - BridgeState: true, - MessageStatusCheckpoints: true, - DeliveredStatus: true, - ContactChatMerging: true, - RichLinks: true, - } -} - -func init() { - imessage.Implementations["mac-nosip"] = NewMacNoSIPConnector -} diff --git a/imessage/mac/attributedstring.go b/imessage/mac/attributedstring.go index 2276f0b9..402f88b2 100644 --- a/imessage/mac/attributedstring.go +++ b/imessage/mac/attributedstring.go @@ -1,3 +1,5 @@ +//go:build darwin + // mautrix-imessage - A Matrix-iMessage puppeting bridge. // Copyright (C) 2022 Tulir Asokan // @@ -29,7 +31,7 @@ import ( "maunium.net/go/maulogger/v2" - "go.mau.fi/mautrix-imessage/imessage" + "github.com/lrhodin/imessage/imessage" ) type AttributeKey string diff --git a/imessage/mac/contacts.go b/imessage/mac/contacts.go index e5a09bd7..8364b7dd 100644 --- a/imessage/mac/contacts.go +++ b/imessage/mac/contacts.go @@ -1,3 +1,5 @@ +//go:build darwin + // mautrix-imessage - A Matrix-iMessage puppeting bridge. // Copyright (C) 2021 Tulir Asokan // @@ -28,7 +30,7 @@ import ( "runtime" "unsafe" - "go.mau.fi/mautrix-imessage/imessage" + "github.com/lrhodin/imessage/imessage" ) type ContactStore struct { @@ -68,9 +70,19 @@ func (cs *ContactStore) RequestContactAccess() error { case C.CNAuthorizationStatusAuthorized: cs.HasContactAccess = true } + // On some macOS versions (e.g. Ventura), the authorization API may report + // denied even when access was granted via System Preferences. Fall back to + // a real query to check if contacts are actually accessible. + if !cs.HasContactAccess { + cs.HasContactAccess = cs.testContactQuery() + } return nil } +func (cs *ContactStore) testContactQuery() bool { + return C.meowTestContactQuery(cs.int) == 1 +} + func gostring(s *C.NSString) string { return C.GoString(C.nsstring2cstring(s)) } func cncontactToContact(ns *C.CNContact, includeAvatar bool) *imessage.Contact { @@ -115,8 +127,6 @@ func cncontactToContact(ns *C.CNContact, includeAvatar bool) *imessage.Contact { func (cs *ContactStore) GetContactInfo(identifier string) (*imessage.Contact, error) { if !cs.HasContactAccess || len(identifier) == 0 { return nil, nil - } else if len(identifier) == 0 { - return nil, fmt.Errorf("can't get contact info of empty identifier") } // Locking the OS thread seems to prevent random SIGSEGV's from the NSAutoreleasePool being drained. @@ -125,11 +135,14 @@ func (cs *ContactStore) GetContactInfo(identifier string) (*imessage.Contact, er // This makes a NSAutoreleasePool, which enables Objective-C's memory management. pool := C.meowMakePool() + cStr := C.CString(identifier) + defer C.free(unsafe.Pointer(cStr)) + var cnContact *C.CNContact if identifier[0] == '+' { - cnContact = C.meowGetContactByPhone(cs.int, C.CString(identifier)) + cnContact = C.meowGetContactByPhone(cs.int, cStr) } else { - cnContact = C.meowGetContactByEmail(cs.int, C.CString(identifier)) + cnContact = C.meowGetContactByEmail(cs.int, cStr) } goContact := cncontactToContact(cnContact, true) diff --git a/imessage/mac/database.go b/imessage/mac/database.go index 1a682205..35f33a08 100644 --- a/imessage/mac/database.go +++ b/imessage/mac/database.go @@ -1,3 +1,5 @@ +//go:build darwin + // mautrix-imessage - A Matrix-iMessage puppeting bridge. // Copyright (C) 2021 Tulir Asokan // @@ -26,7 +28,7 @@ import ( log "maunium.net/go/maulogger/v2" - "go.mau.fi/mautrix-imessage/imessage" + "github.com/lrhodin/imessage/imessage" ) type macOSDatabase struct { @@ -47,6 +49,7 @@ type macOSDatabase struct { chatGUIDQuery *sql.Stmt groupActionQuery *sql.Stmt recentChatsQuery *sql.Stmt + messageGUIDsSinceQuery *sql.Stmt groupMemberQuery *sql.Stmt Messages chan *imessage.Message ReadReceipts chan *imessage.ReadReceipt diff --git a/imessage/mac/debug.go b/imessage/mac/debug.go index 827f2607..6f02a741 100644 --- a/imessage/mac/debug.go +++ b/imessage/mac/debug.go @@ -1,3 +1,5 @@ +//go:build darwin + // mautrix-imessage - A Matrix-iMessage puppeting bridge. // Copyright (C) 2021 Tulir Asokan // @@ -21,7 +23,7 @@ import ( "fmt" "strings" - "go.mau.fi/mautrix-imessage/imessage" + "github.com/lrhodin/imessage/imessage" ) const getAllAccountsJSON = ` diff --git a/imessage/mac/groups.go b/imessage/mac/groups.go index f2a5dcd7..250e12c8 100644 --- a/imessage/mac/groups.go +++ b/imessage/mac/groups.go @@ -1,3 +1,5 @@ +//go:build darwin + // mautrix-imessage - A Matrix-iMessage puppeting bridge. // Copyright (C) 2021 Tulir Asokan // diff --git a/imessage/mac/meowContacts.h b/imessage/mac/meowContacts.h index 5a07cceb..f816b232 100644 --- a/imessage/mac/meowContacts.h +++ b/imessage/mac/meowContacts.h @@ -39,3 +39,4 @@ NSArray*>* meowGetPhoneNumbersFromContact(CNConta NSString* meowGetPhoneArrayItem(NSArray*>* arr, unsigned long i); NSString* meowGetEmailArrayItem(NSArray*>* arr, unsigned long i); unsigned long meowGetArrayLength(NSArray* arr); +int meowTestContactQuery(CNContactStore* store); diff --git a/imessage/mac/meowContacts.m b/imessage/mac/meowContacts.m index 7836a79e..9789b0b6 100644 --- a/imessage/mac/meowContacts.m +++ b/imessage/mac/meowContacts.m @@ -115,3 +115,18 @@ unsigned long meowGetArrayLength(NSArray* arr) { } return arr.count; } + +int meowTestContactQuery(CNContactStore* store) { + NSArray* keysToFetch = @[CNContactGivenNameKey]; + NSString *containerId = store.defaultContainerIdentifier; + if (containerId == nil) { + return 0; + } + NSPredicate *predicate = [CNContact predicateForContactsInContainerWithIdentifier:containerId]; + NSError* error; + NSArray* contacts = [store unifiedContactsMatchingPredicate:predicate keysToFetch:keysToFetch error:&error]; + if (error != nil || contacts == nil) { + return 0; + } + return 1; +} diff --git a/imessage/mac/messages.go b/imessage/mac/messages.go index d14cce16..a8648db4 100644 --- a/imessage/mac/messages.go +++ b/imessage/mac/messages.go @@ -1,3 +1,5 @@ +//go:build darwin + // mautrix-imessage - A Matrix-iMessage puppeting bridge. // Copyright (C) 2022 Tulir Asokan // @@ -30,7 +32,7 @@ import ( "github.com/fsnotify/fsnotify" _ "github.com/mattn/go-sqlite3" - "go.mau.fi/mautrix-imessage/imessage" + "github.com/lrhodin/imessage/imessage" ) const baseMessagesQuery = ` @@ -48,7 +50,7 @@ LEFT JOIN handle target_handle ON message.other_handle = target_handle.ROWID ` const attachmentsQuery = ` -SELECT guid, COALESCE(filename, ''), COALESCE(mime_type, ''), transfer_name FROM attachment +SELECT guid, COALESCE(filename, ''), COALESCE(mime_type, ''), COALESCE(transfer_name, ''), COALESCE(hide_attachment, 0), COALESCE(created_date, 0) FROM attachment JOIN message_attachment_join ON message_attachment_join.attachment_id = attachment.ROWID WHERE message_attachment_join.message_id = $1 ORDER BY ROWID @@ -85,6 +87,17 @@ ORDER BY message.date DESC LIMIT $2 ` +var messageGUIDsSinceQuery = ` +SELECT message.guid FROM message +JOIN chat_message_join ON chat_message_join.message_id = message.ROWID +JOIN chat ON chat_message_join.chat_id = chat.ROWID +WHERE chat.guid=$1 + AND message.item_type = 0 + AND COALESCE(message.associated_message_guid, '') = '' + AND message.date > $2 +ORDER BY message.date ASC +` + const groupActionQuery = ` SELECT COALESCE(attachment.filename, ''), COALESCE(attachment.mime_type, ''), attachment.transfer_name FROM message @@ -112,11 +125,14 @@ LIMIT 1 ` const recentChatsQuery = ` -SELECT DISTINCT chat.guid, chat.group_id FROM message +SELECT chat.guid, chat.group_id FROM message JOIN chat_message_join ON chat_message_join.message_id = message.ROWID JOIN chat ON chat_message_join.chat_id = chat.ROWID WHERE message.date>$1 -ORDER BY message.date DESC + AND message.item_type = 0 + AND COALESCE(message.associated_message_guid, '') = '' +GROUP BY chat.guid, chat.group_id +ORDER BY MAX(message.date) DESC ` const newReceiptsQuery = ` @@ -228,6 +244,10 @@ func (mac *macOSDatabase) prepareMessages() error { if err != nil { return fmt.Errorf("failed to prepare recent chats query: %w", err) } + mac.messageGUIDsSinceQuery, err = mac.chatDB.Prepare(messageGUIDsSinceQuery) + if err != nil { + return fmt.Errorf("failed to prepare message GUIDs since query: %w", err) + } mac.Messages = make(chan *imessage.Message) mac.ReadReceipts = make(chan *imessage.ReadReceipt) @@ -266,7 +286,7 @@ func (mac *macOSDatabase) scanMessages(res *sql.Rows) (messages []*imessage.Mess } for ares.Next() { var attachment imessage.Attachment - err = ares.Scan(&attachment.GUID, &attachment.PathOnDisk, &attachment.MimeType, &attachment.FileName) + err = ares.Scan(&attachment.GUID, &attachment.PathOnDisk, &attachment.MimeType, &attachment.FileName, &attachment.HideAttachment, &attachment.CreatedDate) if err != nil { err = fmt.Errorf("error scanning attachment row for %d: %w", message.RowID, err) return @@ -296,10 +316,15 @@ func (mac *macOSDatabase) scanMessages(res *sql.Rows) (messages []*imessage.Mess message.NewGroupName = newGroupTitle.String } if len(threadOriginatorPart) > 0 { - // The thread_originator_part field seems to have three parts separated by colons. - // The first two parts look like the part index, the third one is something else. - // TODO this might not be reliable - message.ReplyToPart, _ = strconv.Atoi(strings.Split(threadOriginatorPart, ":")[0]) + // The thread_originator_part field has colon-separated parts; the first + // segment is the part index. Log a warning if parsing fails so format + // changes can be diagnosed. + part, err := strconv.Atoi(strings.Split(threadOriginatorPart, ":")[0]) + if err != nil { + mac.log.Warnfln("Failed to parse thread_originator_part %q in message %s: %v", threadOriginatorPart, message.GUID, err) + } else { + message.ReplyToPart = part + } } if message.IsFromMe { message.Sender.LocalID = "" @@ -308,6 +333,7 @@ func (mac *macOSDatabase) scanMessages(res *sql.Rows) (messages []*imessage.Mess message.Tapback, err = tapback.Parse() if err != nil { mac.log.Warnfln("Failed to parse tapback in %s: %v", message.GUID, err) + err = nil // Non-fatal: skip this tapback but continue scanning } } messages = append(messages, &message) @@ -377,11 +403,28 @@ func (mac *macOSDatabase) GetMessage(guid string) (*imessage.Message, error) { return nil, err } if len(msgs) > 0 { - return msgs[1], nil + return msgs[0], nil } return nil, nil } +func (mac *macOSDatabase) GetMessageGUIDsSince(chatID string, minDate time.Time) ([]string, error) { + res, err := mac.messageGUIDsSinceQuery.Query(chatID, minDate.UnixNano()-imessage.AppleEpoch.UnixNano()) + if err != nil { + return nil, fmt.Errorf("error querying message GUIDs since date: %w", err) + } + defer res.Close() + var guids []string + for res.Next() { + var guid string + if err := res.Scan(&guid); err != nil { + return guids, fmt.Errorf("error scanning message GUID row: %w", err) + } + guids = append(guids, guid) + } + return guids, nil +} + func (mac *macOSDatabase) getMessagesSinceRowID(rowID int) ([]*imessage.Message, error) { res, err := mac.newMessagesQuery.Query(rowID) if err != nil { diff --git a/imessage/mac/send.go b/imessage/mac/send.go index 5c634f0d..6ed2bc9e 100644 --- a/imessage/mac/send.go +++ b/imessage/mac/send.go @@ -1,3 +1,5 @@ +//go:build darwin + // mautrix-imessage - A Matrix-iMessage puppeting bridge. // Copyright (C) 2021 Tulir Asokan // @@ -28,7 +30,7 @@ import ( "maunium.net/go/mautrix/id" - "go.mau.fi/mautrix-imessage/imessage" + "github.com/lrhodin/imessage/imessage" ) const sendMessage = ` @@ -191,10 +193,13 @@ func (mac *macOSDatabase) SendFile(chatID, text, filename string, pathOnDisk str func (mac *macOSDatabase) SendFileCleanup(sendFileDir string) { go func() { - // TODO maybe log when the file gets removed // Random sleep to make sure the message has time to get sent time.Sleep(60 * time.Second) - _ = os.RemoveAll(sendFileDir) + if err := os.RemoveAll(sendFileDir); err != nil { + mac.log.Warnfln("Failed to remove send file directory %s: %v", sendFileDir, err) + } else { + mac.log.Debugln("Removed send file directory", sendFileDir) + } }() } diff --git a/imessage/mac/sleepdetect.go b/imessage/mac/sleepdetect.go index ab1a80eb..f6bad1a6 100644 --- a/imessage/mac/sleepdetect.go +++ b/imessage/mac/sleepdetect.go @@ -1,3 +1,5 @@ +//go:build darwin + // mautrix-imessage - A Matrix-iMessage puppeting bridge. // Copyright (C) 2021 Tulir Asokan // diff --git a/imessage/mutation_test.go b/imessage/mutation_test.go new file mode 100644 index 00000000..b4a8e978 --- /dev/null +++ b/imessage/mutation_test.go @@ -0,0 +1,18 @@ +//go:build mutation + +package imessage + +import ( + "testing" + + "github.com/gtramontina/ooze" +) + +func TestMutation(t *testing.T) { + ooze.Release( + t, + ooze.WithTestCommand("go test -count=1 ./imessage/..."), + ooze.WithMinimumThreshold(0.70), + ooze.Parallel(), + ) +} diff --git a/imessage/struct.go b/imessage/struct.go index a26b0e4d..da4c9346 100644 --- a/imessage/struct.go +++ b/imessage/struct.go @@ -129,6 +129,7 @@ type Contact struct { LastName string `json:"last_name,omitempty"` Nickname string `json:"nickname,omitempty"` Avatar []byte `json:"avatar,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` // URL reference for deferred download Phones []string `json:"phones,omitempty"` Emails []string `json:"emails,omitempty"` UserGUID string `json:"user_guid,omitempty"` @@ -163,13 +164,17 @@ func (contact *Contact) Name() string { } type Attachment struct { - GUID string `json:"guid,omitempty"` - PathOnDisk string `json:"path_on_disk"` - FileName string `json:"file_name"` - MimeType string `json:"mime_type,omitempty"` - triedMagic bool + GUID string `json:"guid,omitempty"` + PathOnDisk string `json:"path_on_disk"` + FileName string `json:"file_name"` + MimeType string `json:"mime_type,omitempty"` + HideAttachment bool `json:"hide_attachment,omitempty"` + CreatedDate int64 `json:"created_date,omitempty"` + triedMagic bool } +var userHomeDir = os.UserHomeDir + func (attachment *Attachment) GetMimeType() string { if attachment.MimeType == "" { if attachment.triedMagic { @@ -192,7 +197,7 @@ func (attachment *Attachment) GetFileName() string { func (attachment *Attachment) Read() ([]byte, error) { if strings.HasPrefix(attachment.PathOnDisk, "~/") { - home, err := os.UserHomeDir() + home, err := userHomeDir() if err != nil { return nil, fmt.Errorf("failed to get home directory: %w", err) } @@ -232,10 +237,18 @@ func ParseIdentifier(guid string) Identifier { return Identifier{} } parts := strings.Split(guid, ";") + if len(parts) < 3 { + return Identifier{LocalID: guid} + } + localID := parts[2] + // Detect groups by the separator character ("+") or by LocalID pattern. + // The GUID format is "service;+;localID" for groups and "service;-;localID" for DMs. + // Group LocalIDs can be "chat..." (iMessage), hex UUIDs (SMS/RCS), or other formats. + isGroup := parts[1] == "+" || strings.HasPrefix(localID, "chat") return Identifier{ Service: parts[0], - IsGroup: parts[1] == "+", - LocalID: parts[2], + IsGroup: isGroup, + LocalID: localID, } } diff --git a/imessage/struct_test.go b/imessage/struct_test.go new file mode 100644 index 00000000..d83d5669 --- /dev/null +++ b/imessage/struct_test.go @@ -0,0 +1,526 @@ +package imessage + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/rs/zerolog" + log "maunium.net/go/maulogger/v2" + + "github.com/lrhodin/imessage/ipc" +) + +type testBridge struct { + cfg *PlatformConfig +} + +func (tb *testBridge) GetIPC() *ipc.Processor { return nil } + +func (tb *testBridge) GetLog() log.Logger { return nil } + +func (tb *testBridge) GetZLog() *zerolog.Logger { return nil } + +func (tb *testBridge) GetConnectorConfig() *PlatformConfig { return tb.cfg } + +func (tb *testBridge) PingServer() (start, serverTs, end time.Time) { + return time.Time{}, time.Time{}, time.Time{} +} + +func (tb *testBridge) SendBridgeStatus(state BridgeStatus) {} + +func (tb *testBridge) ReIDPortal(oldGUID, newGUID string, mergeExisting bool) bool { return false } + +func (tb *testBridge) GetMessagesSince(chatGUID string, since time.Time) []string { return nil } + +func (tb *testBridge) SetPushKey(req *PushKeyRequest) {} + +// --------------------------------------------------------------------------- +// Message.SenderText +// --------------------------------------------------------------------------- + +func TestMessage_SenderText_FromMe(t *testing.T) { + msg := &Message{IsFromMe: true, Sender: Identifier{LocalID: "alice"}} + if got := msg.SenderText(); got != "self" { + t.Errorf("SenderText() = %q, want %q", got, "self") + } +} + +func TestMessage_SenderText_FromOther(t *testing.T) { + msg := &Message{IsFromMe: false, Sender: Identifier{LocalID: "+15551234567"}} + if got := msg.SenderText(); got != "+15551234567" { + t.Errorf("SenderText() = %q, want %q", got, "+15551234567") + } +} + +func TestMessage_SenderText_Empty(t *testing.T) { + msg := &Message{IsFromMe: false, Sender: Identifier{}} + if got := msg.SenderText(); got != "" { + t.Errorf("SenderText() = %q, want %q", got, "") + } +} + +// --------------------------------------------------------------------------- +// Contact.HasName +// --------------------------------------------------------------------------- + +func TestContact_HasName(t *testing.T) { + tests := []struct { + name string + contact *Contact + want bool + }{ + {"nil", nil, false}, + {"empty", &Contact{}, false}, + {"first only", &Contact{FirstName: "Alice"}, true}, + {"last only", &Contact{LastName: "Smith"}, true}, + {"nickname only", &Contact{Nickname: "Al"}, true}, + {"full name", &Contact{FirstName: "Alice", LastName: "Smith"}, true}, + {"phones but no name", &Contact{Phones: []string{"+1555"}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.contact.HasName(); got != tt.want { + t.Errorf("HasName() = %v, want %v", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Contact.Name +// --------------------------------------------------------------------------- + +func TestContact_Name(t *testing.T) { + tests := []struct { + name string + contact *Contact + want string + }{ + {"nil", nil, ""}, + {"empty", &Contact{}, ""}, + {"first and last", &Contact{FirstName: "Alice", LastName: "Smith"}, "Alice Smith"}, + {"first only", &Contact{FirstName: "Alice"}, "Alice"}, + {"last only", &Contact{LastName: "Smith"}, "Smith"}, + {"nickname only", &Contact{Nickname: "Al"}, "Al"}, + {"email fallback", &Contact{Emails: []string{"alice@example.com"}}, "alice@example.com"}, + {"phone fallback", &Contact{Phones: []string{"+15551234567"}}, "+15551234567"}, + {"first takes priority over nickname", &Contact{FirstName: "Alice", Nickname: "Al"}, "Alice"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.contact.Name(); got != tt.want { + t.Errorf("Name() = %q, want %q", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Identifier / ParseIdentifier / String +// --------------------------------------------------------------------------- + +func TestParseIdentifier(t *testing.T) { + tests := []struct { + guid string + want Identifier + }{ + {"", Identifier{}}, + {"simple-guid", Identifier{LocalID: "simple-guid"}}, + {"iMessage;-;+15551234567", Identifier{Service: "iMessage", IsGroup: false, LocalID: "+15551234567"}}, + {"iMessage;+;chat123456", Identifier{Service: "iMessage", IsGroup: true, LocalID: "chat123456"}}, + {"SMS;-;+15551234567", Identifier{Service: "SMS", IsGroup: false, LocalID: "+15551234567"}}, + {"SMS;+;hexuuid-1234", Identifier{Service: "SMS", IsGroup: true, LocalID: "hexuuid-1234"}}, + // "+" separator makes it a group even without "chat" prefix + {"iMessage;+;some-uuid", Identifier{Service: "iMessage", IsGroup: true, LocalID: "some-uuid"}}, + // chat prefix makes it a group even with "-" separator + {"iMessage;-;chat999", Identifier{Service: "iMessage", IsGroup: true, LocalID: "chat999"}}, + // only two parts + {"nodots", Identifier{LocalID: "nodots"}}, + } + for _, tt := range tests { + t.Run(tt.guid, func(t *testing.T) { + got := ParseIdentifier(tt.guid) + if got != tt.want { + t.Errorf("ParseIdentifier(%q) = %+v, want %+v", tt.guid, got, tt.want) + } + }) + } +} + +func TestIdentifier_String(t *testing.T) { + tests := []struct { + id Identifier + want string + }{ + {Identifier{}, ""}, + {Identifier{Service: "iMessage", IsGroup: false, LocalID: "+15551234567"}, "iMessage;-;+15551234567"}, + {Identifier{Service: "iMessage", IsGroup: true, LocalID: "chat123"}, "iMessage;+;chat123"}, + {Identifier{Service: "SMS", IsGroup: false, LocalID: "+1555"}, "SMS;-;+1555"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.id.String(); got != tt.want { + t.Errorf("Identifier.String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIdentifier_RoundTrip(t *testing.T) { + original := Identifier{Service: "iMessage", IsGroup: false, LocalID: "+15551234567"} + s := original.String() + parsed := ParseIdentifier(s) + if parsed != original { + t.Errorf("round-trip failed: %+v -> %q -> %+v", original, s, parsed) + } +} + +// --------------------------------------------------------------------------- +// Attachment.GetMimeType +// --------------------------------------------------------------------------- + +func TestAttachment_GetMimeType_Set(t *testing.T) { + a := &Attachment{MimeType: "image/png"} + if got := a.GetMimeType(); got != "image/png" { + t.Errorf("GetMimeType() = %q, want %q", got, "image/png") + } +} + +func TestAttachment_GetMimeType_DetectsFromFile(t *testing.T) { + // Create a real PNG file for mimetype detection + tmp := t.TempDir() + f := filepath.Join(tmp, "test.png") + // Minimal PNG header + pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + os.WriteFile(f, pngHeader, 0600) + + a := &Attachment{PathOnDisk: f} + got := a.GetMimeType() + if got != "image/png" { + t.Errorf("GetMimeType() = %q, want %q", got, "image/png") + } + // Second call should return cached value + got2 := a.GetMimeType() + if got2 != got { + t.Errorf("second call returned %q, want %q", got2, got) + } +} + +func TestAttachment_GetMimeType_NoFile(t *testing.T) { + a := &Attachment{PathOnDisk: "/nonexistent/path"} + got := a.GetMimeType() + if got != "" { + t.Errorf("GetMimeType() = %q, want empty string for missing file", got) + } + // triedMagic should be set, so second call won't retry + if !a.triedMagic { + t.Error("triedMagic should be true after failed detection") + } + // Second call returns early (triedMagic branch) + got2 := a.GetMimeType() + if got2 != "" { + t.Errorf("GetMimeType() second call = %q, want empty", got2) + } +} + +func TestAttachment_GetFileName(t *testing.T) { + a := &Attachment{FileName: "photo.jpg"} + if got := a.GetFileName(); got != "photo.jpg" { + t.Errorf("GetFileName() = %q, want %q", got, "photo.jpg") + } +} + +// --------------------------------------------------------------------------- +// Attachment.Read +// --------------------------------------------------------------------------- + +func TestAttachment_Read(t *testing.T) { + tmp := t.TempDir() + f := filepath.Join(tmp, "testfile.txt") + content := []byte("hello world") + os.WriteFile(f, content, 0600) + + a := &Attachment{PathOnDisk: f} + got, err := a.Read() + if err != nil { + t.Fatalf("Read() error: %v", err) + } + if string(got) != string(content) { + t.Errorf("Read() = %q, want %q", string(got), string(content)) + } +} + +func TestAttachment_Read_TildeExpansion(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("can't get home dir") + } + tmp, err := os.MkdirTemp(home, "imessage-test-*") + if err != nil { + t.Skip("can't create temp dir in home") + } + defer os.RemoveAll(tmp) + + rel, _ := filepath.Rel(home, tmp) + f := filepath.Join(tmp, "testfile.txt") + os.WriteFile(f, []byte("tilde"), 0600) + + a := &Attachment{PathOnDisk: "~/" + filepath.Join(rel, "testfile.txt")} + got, err := a.Read() + if err != nil { + t.Fatalf("Read() with tilde error: %v", err) + } + if string(got) != "tilde" { + t.Errorf("Read() = %q, want %q", string(got), "tilde") + } +} + +func TestAttachment_Read_MissingFile(t *testing.T) { + a := &Attachment{PathOnDisk: "/definitely/not/a/real/file"} + if _, err := a.Read(); err == nil { + t.Fatal("Read() expected error for missing file, got nil") + } +} + +func TestAttachment_Read_TildeMissingFile(t *testing.T) { + a := &Attachment{PathOnDisk: "~/definitely-not-a-real-file-imessage-test"} + if _, err := a.Read(); err == nil { + t.Fatal("Read() expected error for missing ~/ file, got nil") + } +} + +func TestAttachment_Read_HomeDirError(t *testing.T) { + orig := userHomeDir + userHomeDir = func() (string, error) { + return "", errors.New("boom") + } + t.Cleanup(func() { userHomeDir = orig }) + + a := &Attachment{PathOnDisk: "~/any-file"} + if _, err := a.Read(); err == nil { + t.Fatal("Read() expected home directory error, got nil") + } +} + +func TestAttachment_Delete(t *testing.T) { + tmp := t.TempDir() + f := filepath.Join(tmp, "deleteme.txt") + os.WriteFile(f, []byte("bye"), 0600) + + a := &Attachment{PathOnDisk: f} + if err := a.Delete(); err != nil { + t.Fatalf("Delete() error: %v", err) + } + if _, err := os.Stat(f); !os.IsNotExist(err) { + t.Error("file should not exist after Delete()") + } +} + +// --------------------------------------------------------------------------- +// SendFilePrepare +// --------------------------------------------------------------------------- + +func TestSendFilePrepare(t *testing.T) { + data := []byte("test file content") + dir, filePath, err := SendFilePrepare("test.txt", data) + if err != nil { + t.Fatalf("SendFilePrepare() error: %v", err) + } + defer os.RemoveAll(dir) + + // Verify file was written + got, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("can't read written file: %v", err) + } + if string(got) != string(data) { + t.Errorf("file content = %q, want %q", string(got), string(data)) + } + + // Verify the file is inside the directory + if filepath.Dir(filePath) != dir { + t.Errorf("file not inside temp dir: %q not in %q", filePath, dir) + } +} + +func TestSendFilePrepare_WriteError(t *testing.T) { + // Nested path without parent dir inside temp upload dir should fail write. + _, _, err := SendFilePrepare(filepath.Join("no-such-dir", "test.txt"), []byte("x")) + if err == nil { + t.Fatal("SendFilePrepare() expected write error, got nil") + } +} + +func TestSendFilePrepare_TempDirError(t *testing.T) { + // Force TempDir() to fail by pointing TMPDIR to a regular file. + tmpFile, err := os.CreateTemp("", "imessage-upload-file-*") + if err != nil { + t.Fatalf("CreateTemp() error: %v", err) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpPath) + + t.Setenv("TMPDIR", tmpPath) + if _, _, err := SendFilePrepare("test.txt", []byte("x")); err == nil { + t.Fatal("SendFilePrepare() expected temp dir error, got nil") + } +} + +// --------------------------------------------------------------------------- +// GroupActionType / ItemType constants +// --------------------------------------------------------------------------- + +func TestConstants(t *testing.T) { + if GroupActionAddUser != 0 { + t.Errorf("GroupActionAddUser = %d, want 0", GroupActionAddUser) + } + if GroupActionRemoveUser != 1 { + t.Errorf("GroupActionRemoveUser = %d, want 1", GroupActionRemoveUser) + } + if ItemTypeMessage != 0 { + t.Errorf("ItemTypeMessage = %d, want 0", ItemTypeMessage) + } + if ItemTypeMember != 1 { + t.Errorf("ItemTypeMember = %d, want 1", ItemTypeMember) + } + if ItemTypeName != 2 { + t.Errorf("ItemTypeName = %d, want 2", ItemTypeName) + } + if ItemTypeAvatar != 3 { + t.Errorf("ItemTypeAvatar = %d, want 3", ItemTypeAvatar) + } + if ItemTypeError != -100 { + t.Errorf("ItemTypeError = %d, want -100", ItemTypeError) + } +} + +// --------------------------------------------------------------------------- +// PlatformConfig.BridgeName +// --------------------------------------------------------------------------- + +func TestBridgeName(t *testing.T) { + tests := []struct { + platform string + want string + }{ + {"android", "Android SMS Bridge"}, + {"mac", "iMessage Bridge"}, + {"", "iMessage Bridge"}, + } + for _, tt := range tests { + pc := &PlatformConfig{Platform: tt.platform} + if got := pc.BridgeName(); got != tt.want { + t.Errorf("BridgeName(%q) = %q, want %q", tt.platform, got, tt.want) + } + } +} + +// --------------------------------------------------------------------------- +// TempDir +// --------------------------------------------------------------------------- + +func TestTempDir(t *testing.T) { + dir, err := TempDir("test-imessage") + if err != nil { + t.Fatalf("TempDir() error: %v", err) + } + defer os.RemoveAll(dir) + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("Stat(%q) error: %v", dir, err) + } + if !info.IsDir() { + t.Errorf("TempDir() result is not a directory") + } +} + +func TestTempDir_MkdirAllError(t *testing.T) { + // Force os.TempDir() to return a regular file path so MkdirAll fails. + tmpFile, err := os.CreateTemp("", "imessage-tempdir-file-*") + if err != nil { + t.Fatalf("CreateTemp() error: %v", err) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpPath) + + orig := os.Getenv("TMPDIR") + t.Setenv("TMPDIR", tmpPath) + defer func() { + if orig == "" { + os.Unsetenv("TMPDIR") + } else { + os.Setenv("TMPDIR", orig) + } + }() + + if _, err := TempDir("test-imessage"); err == nil { + t.Fatal("TempDir() expected error when TMPDIR points to file, got nil") + } +} + +func TestTempDir_MkdirTempError(t *testing.T) { + origPerm := TempDirPermissions + TempDirPermissions = 0400 + t.Cleanup(func() { TempDirPermissions = origPerm }) + + base := filepath.Join(t.TempDir(), "no-write-temp-root") + t.Setenv("TMPDIR", base) + + if _, err := TempDir("test-imessage"); err == nil { + t.Fatal("TempDir() expected error from MkdirTemp in non-writable TMPDIR, got nil") + } +} + +// --------------------------------------------------------------------------- +// NewAPI (error path only — no real implementations registered in test) +// --------------------------------------------------------------------------- + +func TestNewAPI_UnknownPlatform(t *testing.T) { + bridge := &testBridge{cfg: &PlatformConfig{Platform: "__bogus_platform__"}} + api, err := NewAPI(bridge) + if err == nil { + t.Fatal("NewAPI() expected error for unknown platform, got nil") + } + if api != nil { + t.Fatal("NewAPI() expected nil API on unknown platform") + } +} + +func TestNewAPI_KnownPlatform(t *testing.T) { + const platform = "__unit_test_platform__" + origImpl, hadOrig := Implementations[platform] + t.Cleanup(func() { + if hadOrig { + Implementations[platform] = origImpl + } else { + delete(Implementations, platform) + } + }) + + called := false + Implementations[platform] = func(b Bridge) (API, error) { + called = true + if b == nil { + t.Fatal("implementation received nil bridge") + } + return nil, nil + } + + bridge := &testBridge{cfg: &PlatformConfig{Platform: platform}} + api, err := NewAPI(bridge) + if err != nil { + t.Fatalf("NewAPI() unexpected error: %v", err) + } + if api != nil { + t.Fatal("NewAPI() expected nil API from test implementation") + } + if !called { + t.Fatal("expected implementation to be called") + } +} diff --git a/imessage/tapback_test.go b/imessage/tapback_test.go new file mode 100644 index 00000000..72d9b91c --- /dev/null +++ b/imessage/tapback_test.go @@ -0,0 +1,280 @@ +package imessage + +import ( + "errors" + "testing" +) + +func TestTapbackFromEmoji(t *testing.T) { + tests := []struct { + emoji string + want TapbackType + }{ + {"❤", TapbackLove}, + {"♥", TapbackLove}, + {"💙", TapbackLove}, + {"💚", TapbackLove}, + {"🤎", TapbackLove}, + {"🖤", TapbackLove}, + {"🤍", TapbackLove}, + {"🧡", TapbackLove}, + {"💛", TapbackLove}, + {"💜", TapbackLove}, + {"💖", TapbackLove}, + {"❣", TapbackLove}, + {"💕", TapbackLove}, + {"💟", TapbackLove}, + {"👍", TapbackLike}, + {"👎", TapbackDislike}, + {"😂", TapbackLaugh}, + {"😹", TapbackLaugh}, + {"😆", TapbackLaugh}, + {"🤣", TapbackLaugh}, + {"❕", TapbackEmphasis}, + {"❗", TapbackEmphasis}, + {"‼", TapbackEmphasis}, + {"❓", TapbackQuestion}, + {"❔", TapbackQuestion}, + {"🎉", 0}, // unknown emoji + } + for _, tt := range tests { + t.Run(tt.emoji, func(t *testing.T) { + got := TapbackFromEmoji(tt.emoji) + if got != tt.want { + t.Errorf("TapbackFromEmoji(%q) = %d, want %d", tt.emoji, got, tt.want) + } + }) + } +} + +func TestTapbackFromName(t *testing.T) { + tests := []struct { + name string + want TapbackType + }{ + {"love", TapbackLove}, + {"like", TapbackLike}, + {"dislike", TapbackDislike}, + {"laugh", TapbackLaugh}, + {"emphasize", TapbackEmphasis}, + {"question", TapbackQuestion}, + {"unknown", 0}, + {"", 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TapbackFromName(tt.name) + if got != tt.want { + t.Errorf("TapbackFromName(%q) = %d, want %d", tt.name, got, tt.want) + } + }) + } +} + +func TestTapbackType_Emoji(t *testing.T) { + tests := []struct { + typ TapbackType + want string + }{ + {0, ""}, + {TapbackLove, "\u2764\ufe0f"}, + {TapbackLike, "\U0001f44d\ufe0f"}, + {TapbackDislike, "\U0001f44e\ufe0f"}, + {TapbackLaugh, "\U0001f602"}, + {TapbackEmphasis, "\u203c\ufe0f"}, + {TapbackQuestion, "\u2753\ufe0f"}, + {TapbackType(9999), "\ufffd"}, + } + for _, tt := range tests { + got := tt.typ.Emoji() + if got != tt.want { + t.Errorf("TapbackType(%d).Emoji() = %q, want %q", tt.typ, got, tt.want) + } + } +} + +func TestTapbackType_String(t *testing.T) { + // String() delegates to Emoji() + if TapbackLove.String() != TapbackLove.Emoji() { + t.Errorf("String() != Emoji()") + } +} + +func TestTapbackType_Name(t *testing.T) { + tests := []struct { + typ TapbackType + want string + }{ + {0, ""}, + {TapbackLove, "love"}, + {TapbackLike, "like"}, + {TapbackDislike, "dislike"}, + {TapbackLaugh, "laugh"}, + {TapbackEmphasis, "emphasize"}, + {TapbackQuestion, "question"}, + {TapbackType(9999), ""}, + } + for _, tt := range tests { + got := tt.typ.Name() + if got != tt.want { + t.Errorf("TapbackType(%d).Name() = %q, want %q", tt.typ, got, tt.want) + } + } +} + +func TestTapbackConstants(t *testing.T) { + if TapbackLove != 2000 { + t.Errorf("TapbackLove = %d, want 2000", TapbackLove) + } + if TapbackLike != 2001 { + t.Errorf("TapbackLike = %d, want 2001", TapbackLike) + } + if TapbackDislike != 2002 { + t.Errorf("TapbackDislike = %d, want 2002", TapbackDislike) + } + if TapbackLaugh != 2003 { + t.Errorf("TapbackLaugh = %d, want 2003", TapbackLaugh) + } + if TapbackEmphasis != 2004 { + t.Errorf("TapbackEmphasis = %d, want 2004", TapbackEmphasis) + } + if TapbackQuestion != 2005 { + t.Errorf("TapbackQuestion = %d, want 2005", TapbackQuestion) + } + if TapbackRemoveOffset != 1000 { + t.Errorf("TapbackRemoveOffset = %d, want 1000", TapbackRemoveOffset) + } +} + +func TestTapback_Parse_BPPrefix(t *testing.T) { + tb := &Tapback{ + TargetGUID: "bp:some-guid-1234", + Type: TapbackLove, + } + result, err := tb.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.TargetGUID != "some-guid-1234" { + t.Errorf("TargetGUID = %q, want %q", result.TargetGUID, "some-guid-1234") + } + if result.Remove { + t.Error("Remove should be false") + } + if result.TargetPart != 0 { + t.Errorf("TargetPart = %d, want 0", result.TargetPart) + } +} + +func TestTapback_Parse_PPrefix(t *testing.T) { + tb := &Tapback{ + TargetGUID: "p:3/some-guid-5678", + Type: TapbackLaugh, + } + result, err := tb.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.TargetGUID != "some-guid-5678" { + t.Errorf("TargetGUID = %q, want %q", result.TargetGUID, "some-guid-5678") + } + if result.TargetPart != 3 { + t.Errorf("TargetPart = %d, want 3", result.TargetPart) + } +} + +func TestTapback_Parse_RemoveFlag(t *testing.T) { + // 3001 = TapbackLike + TapbackRemoveOffset + tb := &Tapback{ + TargetGUID: "bp:guid-remove", + Type: TapbackType(3001), + } + result, err := tb.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Remove { + t.Error("Remove should be true for type 3001") + } + if result.Type != TapbackLike { + t.Errorf("Type = %d, want %d (TapbackLike)", result.Type, TapbackLike) + } +} + +func TestTapback_Parse_RemoveBoundary(t *testing.T) { + // Type 3999 should trigger remove + tb := &Tapback{ + TargetGUID: "bp:guid", + Type: TapbackType(3999), + } + result, err := tb.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Remove { + t.Error("Remove should be true for type 3999") + } + + // Type 4000 should NOT trigger remove + tb2 := &Tapback{ + TargetGUID: "bp:guid", + Type: TapbackType(4000), + } + result2, err := tb2.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result2.Remove { + t.Error("Remove should be false for type 4000") + } +} + +func TestTapback_Parse_InvalidPFormat(t *testing.T) { + // p: prefix with wrong number of parts (no slash) + tb := &Tapback{ + TargetGUID: "p:guid-no-slash", + Type: TapbackLove, + } + _, err := tb.Parse() + if !errors.Is(err, ErrUnknownNormalTapbackTarget) { + t.Errorf("expected ErrUnknownNormalTapbackTarget, got: %v", err) + } +} + +func TestTapback_Parse_InvalidPartIndex(t *testing.T) { + // p: prefix with non-numeric part index + tb := &Tapback{ + TargetGUID: "p:abc/guid-1234", + Type: TapbackLove, + } + _, err := tb.Parse() + if !errors.Is(err, ErrInvalidTapbackTargetPart) { + t.Errorf("expected ErrInvalidTapbackTargetPart, got: %v", err) + } +} + +func TestTapback_Parse_UnknownTargetType(t *testing.T) { + // Neither bp: nor p: prefix + tb := &Tapback{ + TargetGUID: "unknown:guid-1234", + Type: TapbackLove, + } + _, err := tb.Parse() + if !errors.Is(err, ErrUnknownTapbackTargetType) { + t.Errorf("expected ErrUnknownTapbackTargetType, got: %v", err) + } +} + +func TestTapbackRoundTrip(t *testing.T) { + names := []string{"love", "like", "dislike", "laugh", "emphasize", "question"} + for _, name := range names { + typ := TapbackFromName(name) + if typ == 0 { + t.Fatalf("TapbackFromName(%q) returned 0", name) + } + gotName := typ.Name() + if gotName != name { + t.Errorf("round-trip failed: TapbackFromName(%q).Name() = %q", name, gotName) + } + } +} diff --git a/ipc/ipc_test.go b/ipc/ipc_test.go deleted file mode 100644 index 6081122e..00000000 --- a/ipc/ipc_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package ipc_test - -import ( - "encoding/json" - "errors" - "testing" - - "go.mau.fi/mautrix-imessage/ipc" -) - -func TestError_Is(t *testing.T) { - var err error = ipc.Error{Code: "not_found"} - if !errors.Is(err, ipc.ErrNotFound) { - t.Error("errors.Is() returned false for an error with the same code") - } - if errors.Is(err, ipc.Error{Code: "test"}) { - t.Error("errors.Is() returned true for an error with a different code") - } - if errors.Is(err, errors.New("not_found")) { - t.Error("errors.Is() returned true for an unrelated error") - } - - var err2 ipc.Error - err = json.Unmarshal([]byte(`{"code": "timeout", "message": "Request timed out"}`), &err2) - if err != nil { - t.Error("Failed to unmarshal error:", err) - } - if !errors.Is(err2, ipc.ErrTimeoutError) { - t.Error("errors.Is() returned false for an error with the same code") - } -} diff --git a/mac-permissions.go b/mac-permissions.go deleted file mode 100644 index 488d992d..00000000 --- a/mac-permissions.go +++ /dev/null @@ -1,48 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build darwin && !ios - -package main - -import ( - "errors" - "fmt" - "os" - - "github.com/mattn/go-sqlite3" - - "go.mau.fi/mautrix-imessage/imessage" - "go.mau.fi/mautrix-imessage/imessage/mac" -) - -func checkMacPermissions() { - err := mac.CheckPermissions() - if err != nil { - fmt.Println(err) - } - if errors.Is(err, imessage.ErrNotLoggedIn) { - os.Exit(41) - } else if sqliteError := (sqlite3.Error{}); errors.As(err, &sqliteError) { - if errors.Is(sqliteError.SystemErrno, os.ErrNotExist) { - os.Exit(42) - } else if errors.Is(sqliteError.SystemErrno, os.ErrPermission) { - os.Exit(43) - } - } else if err != nil { - os.Exit(49) - } -} diff --git a/main.go b/main.go deleted file mode 100644 index 9a0a99ca..00000000 --- a/main.go +++ /dev/null @@ -1,714 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "context" - _ "embed" - "encoding/json" - "errors" - "fmt" - "os" - "strconv" - "sync" - "time" - - "github.com/rs/zerolog" - flag "maunium.net/go/mauflag" - "maunium.net/go/maulogger/v2" - - "maunium.net/go/mautrix/bridge/bridgeconfig" - - "maunium.net/go/mautrix/event" - - "go.mau.fi/util/configupgrade" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/config" - "go.mau.fi/mautrix-imessage/database" - "go.mau.fi/mautrix-imessage/imessage" - _ "go.mau.fi/mautrix-imessage/imessage/bluebubbles" - _ "go.mau.fi/mautrix-imessage/imessage/ios" - _ "go.mau.fi/mautrix-imessage/imessage/mac-nosip" - "go.mau.fi/mautrix-imessage/ipc" -) - -var ( - // These are filled at build time with the -X linker flag - Tag = "unknown" - Commit = "unknown" - BuildTime = "unknown" -) - -//go:embed example-config.yaml -var ExampleConfig string - -var configURL = flag.MakeFull("u", "url", "The URL to download the config file from.", "").String() -var configOutputRedirect = flag.MakeFull("o", "output-redirect", "Whether or not to output the URL of the first redirect when downloading the config file.", "false").Bool() -var checkPermissions = flag.MakeFull("p", "check-permissions", "Check for full disk access permissions and quit.", "false").Bool() - -type IMBridge struct { - bridge.Bridge - Config *config.Config - DB *database.Database - IM imessage.API - IMHandler *iMessageHandler - IPC *ipc.Processor - - WebsocketHandler *WebsocketCommandHandler - - user *User - portalsByMXID map[id.RoomID]*Portal - portalsByGUID map[string]*Portal - portalsLock sync.Mutex - userCache map[id.UserID]*User - puppets map[string]*Puppet - puppetsLock sync.Mutex - latestState *imessage.BridgeStatus - pushKey *imessage.PushKeyRequest - - SendStatusStartTS int64 - sendStatusUpdateInfo bool - wasConnected bool - hackyTestLoopStarted bool - - firstConnectTime time.Time - noPhoneNumbers bool - - pendingHackyTestGUID string - pendingHackyTestRandomID string - hackyTestSuccess bool - - wsOnConnectWait sync.WaitGroup -} - -func (br *IMBridge) GetExampleConfig() string { - return ExampleConfig -} - -func (br *IMBridge) GetConfigPtr() interface{} { - br.Config = &config.Config{ - BaseConfig: &br.Bridge.Config, - } - br.Config.BaseConfig.Bridge = &br.Config.Bridge - return br.Config -} - -func (br *IMBridge) GetIPortal(roomID id.RoomID) bridge.Portal { - portal := br.GetPortalByMXID(roomID) - if portal != nil { - return portal - } - return nil -} - -func (br *IMBridge) GetAllIPortals() (iportals []bridge.Portal) { - portals := br.GetAllPortals() - iportals = make([]bridge.Portal, len(portals)) - for i, portal := range portals { - iportals[i] = portal - } - return iportals -} - -func (br *IMBridge) GetIUser(id id.UserID, create bool) bridge.User { - if id == br.user.MXID { - return br.user - } - cached, ok := br.userCache[id] - if !ok { - if !create { - return nil - } - cached = &User{ - User: &database.User{MXID: id}, - bridge: br, - log: br.Log.Sub("ExtUser").Sub(id.String()), - } - br.userCache[id] = cached - } - return cached -} - -func (br *IMBridge) IsGhost(userID id.UserID) bool { - _, isPuppet := br.ParsePuppetMXID(userID) - return isPuppet -} - -func (br *IMBridge) GetIGhost(userID id.UserID) bridge.Ghost { - puppet := br.GetPuppetByMXID(userID) - if puppet != nil { - return puppet - } - return nil -} - -func (br *IMBridge) CreatePrivatePortal(roomID id.RoomID, user bridge.User, ghost bridge.Ghost) { - // TODO implement -} - -func (br *IMBridge) ensureConnection() { - for { - resp, err := br.Bot.Whoami() - if err != nil { - if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.RespError != nil && httpErr.RespError.ErrCode == "M_UNKNOWN_ACCESS_TOKEN" { - br.Log.Fatalln("Access token invalid. Is the registration installed in your homeserver correctly?") - os.Exit(16) - } - br.Log.Errorfln("Failed to connect to homeserver: %v. Retrying in 10 seconds...", err) - time.Sleep(10 * time.Second) - } else if resp.UserID != br.Bot.UserID { - br.Log.Fatalln("Unexpected user ID in whoami call: got %s, expected %s", resp.UserID, br.Bot.UserID) - os.Exit(17) - } else { - break - } - } -} - -func (br *IMBridge) Init() { - br.CommandProcessor = commands.NewProcessor(&br.Bridge) - br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database")) - - br.initSegment() - - br.IPC = ipc.NewStdioProcessor(br.Log, br.Config.IMessage.LogIPCPayloads) - br.IPC.SetHandler("reset-encryption", br.ipcResetEncryption) - br.IPC.SetHandler("ping", br.ipcPing) - br.IPC.SetHandler("ping-server", br.ipcPingServer) - br.IPC.SetHandler("stop", br.ipcStop) - br.IPC.SetHandler("merge-rooms", br.ipcMergeRooms) - br.IPC.SetHandler("split-rooms", br.ipcSplitRooms) - br.IPC.SetHandler("do-auto-merge", br.ipcDoAutoMerge) - br.IPC.SetHandler("backfill-status", br.ipcBackfillStatus) - - br.Log.Debugln("Initializing iMessage connector") - var err error - br.IM, err = imessage.NewAPI(br) - if err != nil { - br.Log.Fatalln("Failed to initialize iMessage connector:", err) - os.Exit(14) - } - - if br.Config.IMessage.Platform == "android" { - br.EventProcessor.PrependHandler(event.EventEncrypted, func(evt *event.Event) { - go br.IM.NotifyUpcomingMessage(evt.ID) - }) - br.Bridge.BeeperNetworkName = "androidsms" - br.Bridge.BeeperServiceName = "androidsms" - } else if br.Config.IMessage.Platform == "mac-nosip" || br.Config.Bridge.Backfill.OnlyBackfill { - br.Bridge.BeeperNetworkName = "imessage" - br.Bridge.BeeperServiceName = "imessagecloud" - } else { - br.Bridge.BeeperNetworkName = "imessage" - br.Bridge.BeeperServiceName = "imessage" - } - - if br.Config.Bridge.Backfill.OnlyBackfill { - br.ProtocolName = "iMessage (Backfill)" - } - - br.IMHandler = NewiMessageHandler(br) - br.WebsocketHandler = NewWebsocketCommandHandler(br) - br.wsOnConnectWait.Add(1) - - br.CommandProcessor = commands.NewProcessor(&br.Bridge) - br.RegisterCommands() -} - -type PingResponse struct { - OK bool `json:"ok"` -} - -func (br *IMBridge) GetIPC() *ipc.Processor { - return br.IPC -} - -func (br *IMBridge) GetLog() maulogger.Logger { - return br.Log -} - -func (br *IMBridge) GetZLog() *zerolog.Logger { - return br.ZLog -} - -func (br *IMBridge) GetConnectorConfig() *imessage.PlatformConfig { - return &br.Config.IMessage -} - -func (br *IMBridge) ipcResetEncryption(_ json.RawMessage) interface{} { - br.Crypto.Reset(true) - return PingResponse{true} -} - -func (br *IMBridge) ipcPing(_ json.RawMessage) interface{} { - return PingResponse{true} -} - -type PingServerResponse struct { - Start int64 `json:"start_ts"` - Server int64 `json:"server_ts"` - End int64 `json:"end_ts"` -} - -func (br *IMBridge) ipcPingServer(_ json.RawMessage) interface{} { - start, server, end := br.PingServer() - return &PingServerResponse{ - Start: start.UnixNano(), - Server: server.UnixNano(), - End: end.UnixNano(), - } -} - -type ipcMergeRequest struct { - GUIDs []string `json:"guids"` -} - -type ipcMergeResponse struct { - MXID id.RoomID `json:"mxid"` -} - -func (br *IMBridge) ipcMergeRooms(rawReq json.RawMessage) interface{} { - var req ipcMergeRequest - err := json.Unmarshal(rawReq, &req) - if err != nil { - return err - } - var portals []*Portal - for _, guid := range req.GUIDs { - portals = append(portals, br.GetPortalByGUID(guid)) - } - if len(portals) < 2 { - return fmt.Errorf("must pass at least 2 portals to merge") - } - portals[0].Merge(portals[1:]) - return ipcMergeResponse{MXID: portals[0].MXID} -} - -type ipcSplitRequest struct { - GUID string `json:"guid"` - Parts map[string][]string `json:"parts"` -} - -type ipcSplitResponse struct{} - -func (br *IMBridge) ipcSplitRooms(rawReq json.RawMessage) interface{} { - var req ipcSplitRequest - err := json.Unmarshal(rawReq, &req) - if err != nil { - return err - } - sourcePortal := br.GetPortalByGUID(req.GUID) - sourcePortal.Split(req.Parts) - return ipcSplitResponse{} -} - -func (br *IMBridge) ipcDoAutoMerge(_ json.RawMessage) any { - contacts, err := br.IM.GetContactList() - if err != nil { - return fmt.Errorf("failed to get contact list: %w", err) - } - br.UpdateMerges(contacts) - return struct{}{} -} - -func (br *IMBridge) ipcBackfillStatus(_ json.RawMessage) any { - return br.user.GetBackfillInfo() -} - -type StartSyncRequest struct { - AccessToken string `json:"access_token"` - DeviceID id.DeviceID `json:"device_id"` - UserID id.UserID `json:"user_id"` -} - -const BridgeStatusConnected = "CONNECTED" - -func (br *IMBridge) SendBridgeStatus(state imessage.BridgeStatus) { - br.Log.Debugfln("Sending bridge status to server: %+v", state) - if state.Timestamp == 0 { - state.Timestamp = time.Now().Unix() - } - if state.TTL == 0 { - state.TTL = 600 - } - if len(state.Source) == 0 { - state.Source = "bridge" - } - if len(state.UserID) == 0 { - state.UserID = br.user.MXID - } - if br.IM.Capabilities().BridgeState { - br.latestState = &state - } - activeNumberCountVal, ok := state.Info["active_phone_number_count"] - if ok { - br.noPhoneNumbers = int(activeNumberCountVal.(float64)) == 0 - } - wasConnected := br.wasConnected - if state.StateEvent == BridgeStatusConnected && !wasConnected && br.firstConnectTime.IsZero() { - br.wasConnected = true - br.firstConnectTime = time.Now().UTC() - br.DB.KV.Set(database.KVBridgeFirstConnect, br.firstConnectTime.Format(time.RFC3339)) - } - if !br.firstConnectTime.IsZero() { - if state.Info == nil { - state.Info = make(map[string]any) - } - state.Info["first_connected_time"] = br.firstConnectTime.Format(time.RFC3339) - if br.Config.IMessage.Platform == "mac-nosip" { - state.Info["warming_up"] = br.isWarmingUp() - } - } - err := br.AS.SendWebsocket(&appservice.WebsocketRequest{ - Command: "bridge_status", - Data: &state, - }) - if err != nil { - br.Log.Warnln("Error sending bridge status:", err) - } - if br.Config.HackyStartupTest.Identifier != "" && state.StateEvent == BridgeStatusConnected && !br.Config.HackyStartupTest.EchoMode { - if !wasConnected { - go br.hackyStartupTests(true, false) - } - if !br.hackyTestLoopStarted && br.Config.HackyStartupTest.PeriodicResolve > 0 { - br.hackyTestLoopStarted = true - go br.hackyTestLoop() - } - } -} - -func (br *IMBridge) sendPushKey() { - if br.pushKey == nil { - return - } - err := br.AS.RequestWebsocket(context.Background(), &appservice.WebsocketRequest{ - Command: "push_key", - Data: br.pushKey, - }, nil) - if err != nil { - // Don't care about websocket not connected errors, we'll retry automatically when reconnecting - if !errors.Is(err, appservice.ErrWebsocketNotConnected) { - br.Log.Warnln("Error sending push key to asmux:", err) - } - } else { - br.Log.Infoln("Successfully sent push key to asmux") - } -} - -func (br *IMBridge) SetPushKey(req *imessage.PushKeyRequest) { - if req.PushKeyTS == 0 { - req.PushKeyTS = time.Now().Unix() - } - br.pushKey = req - go br.sendPushKey() -} - -func (br *IMBridge) RequestStartSync() { - if !br.Config.Bridge.Encryption.Appservice || - br.Config.Homeserver.Software == bridgeconfig.SoftwareHungry || - br.Crypto == nil || - !br.AS.HasWebsocket() { - return - } - resp := map[string]interface{}{} - br.Log.Debugln("Sending /sync start request through websocket") - cryptoClient := br.Crypto.Client() - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) - defer cancel() - err := br.AS.RequestWebsocket(ctx, &appservice.WebsocketRequest{ - Command: "start_sync", - Deadline: 30 * time.Second, - Data: &StartSyncRequest{ - AccessToken: cryptoClient.AccessToken, - DeviceID: cryptoClient.DeviceID, - UserID: cryptoClient.UserID, - }, - }, &resp) - if err != nil { - go br.WebsocketHandler.HandleSyncProxyError(nil, err) - } else { - br.Log.Debugln("Started receiving encryption data with sync proxy:", resp) - } -} - -func (br *IMBridge) OnWebsocketConnect() { - br.wsOnConnectWait.Wait() - if br.latestState != nil { - go br.SendBridgeStatus(*br.latestState) - } else if !br.IM.Capabilities().BridgeState { - go br.SendBridgeStatus(imessage.BridgeStatus{ - StateEvent: BridgeStatusConnected, - RemoteID: "unknown", - }) - } - go br.sendPushKey() - br.RequestStartSync() -} - -func (br *IMBridge) connectToiMessage(wg *sync.WaitGroup) { - err := br.IM.Start(wg.Done) - if err != nil { - br.Log.Fatalln("Error in iMessage connection:", err) - os.Exit(40) - } -} - -const warmupPeriod = 2 * 24 * time.Hour - -func (br *IMBridge) isWarmingUp() bool { - return br.Config.IMessage.Platform == "mac-nosip" && br.noPhoneNumbers && !br.firstConnectTime.IsZero() && time.Since(br.firstConnectTime) < warmupPeriod -} - -func (br *IMBridge) Start() { - br.ZLog.Debug().Msg("Finding bridge user") - br.user = br.loadDBUser() - br.user.initDoublePuppet() - - // If this bridge is in OnlyBackfill mode, then only run the backfill - // queue and the IPC listener, and not the new message listeners. - if br.Config.Bridge.Backfill.OnlyBackfill { - br.ZLog.Debug().Msg("Starting IPC loop") - go br.IPC.Loop() - br.user.runOnlyBackfillMode() - return - } - - if br.Config.Bridge.MessageStatusEvents { - sendStatusStart := br.DB.KV.Get(database.KVSendStatusStart) - if len(sendStatusStart) > 0 { - br.SendStatusStartTS, _ = strconv.ParseInt(sendStatusStart, 10, 64) - } - if br.SendStatusStartTS == 0 { - br.SendStatusStartTS = time.Now().UnixMilli() - br.DB.KV.Set(database.KVSendStatusStart, strconv.FormatInt(br.SendStatusStartTS, 10)) - br.sendStatusUpdateInfo = true - } - } - br.wasConnected = br.DB.KV.Get(database.KVBridgeWasConnected) == "true" - if firstConnectTime := br.DB.KV.Get(database.KVBridgeFirstConnect); firstConnectTime != "" { - var err error - br.firstConnectTime, err = time.Parse(time.RFC3339, firstConnectTime) - if err != nil { - br.ZLog.Warn().Err(err).Msg("Failed to parse first connect time from database") - } - } - - needsPortalFinding := br.Config.Bridge.FindPortalsIfEmpty && br.DB.Portal.Count() == 0 && - br.DB.KV.Get(database.KVLookedForPortals) != "true" - - var startupGroup sync.WaitGroup - startupGroup.Add(1) - br.Log.Debugln("Connecting to iMessage") - go br.connectToiMessage(&startupGroup) - - if needsPortalFinding { - br.Log.Infoln("Portal database is empty, finding portals from Matrix room state") - err := br.FindPortalsFromMatrix() - if err != nil { - br.Log.Fatalln("Error finding portals:", err) - os.Exit(30) - } - br.DB.KV.Set(database.KVLookedForPortals, "true") - // The database was probably reset, so log out of all bridge bot devices to keep the list clean - // TODO this may be unsafe with appservice encryption, it would be better to just log out other devices - if br.Crypto != nil && br.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { - br.Crypto.Reset(true) - } - } - - br.Log.Debugln("Starting iMessage handler") - go br.IMHandler.Start() - br.wsOnConnectWait.Done() - startupGroup.Wait() - br.WaitWebsocketConnected() - br.Log.Debugln("Starting IPC loop") - go br.IPC.Loop() - - go br.StartupSync() - br.ZLog.Info().Msg("Initialization complete") - go br.PeriodicSync() -} - -func (br *IMBridge) StartupSync() { - resp, err := br.IM.PreStartupSyncHook() - if err != nil { - br.Log.Errorln("iMessage connector returned error in startup sync hook:", err) - } else if resp.SkipSync { - br.Log.Debugln("Skipping startup sync") - return - } - - forceUpdateBridgeInfo := br.sendStatusUpdateInfo || - br.DB.KV.Get(database.KVBridgeInfoVersion) != database.ExpectedBridgeInfoVersion - alreadySynced := make(map[string]bool) - for _, portal := range br.GetAllPortals() { - removed := portal.CleanupIfEmpty(true) - if !removed && len(portal.MXID) > 0 { - if br.Config.Bridge.DisableSMSPortals && portal.Identifier.Service == "SMS" && !portal.Identifier.IsGroup { - imIdentifier := portal.Identifier - imIdentifier.Service = "iMessage" - if !portal.reIDInto(imIdentifier.String(), nil, true, true) { - // Portal was dropped/merged, don't sync it - continue - } // else: portal was re-id'd, sync it as usual - } else if !br.Config.Bridge.DisableSMSPortals && portal.Identifier.Service == "iMessage" && !portal.Identifier.IsGroup && portal.LastSeenHandle != "" { - lastSeenHandle := imessage.ParseIdentifier(portal.LastSeenHandle) - if lastSeenHandle.Service == "SMS" && lastSeenHandle.LocalID == portal.Identifier.LocalID { - if !portal.reIDInto(portal.LastSeenHandle, nil, true, true) { - continue - } - } - } - portal.Sync(true) - alreadySynced[portal.GUID] = true - if forceUpdateBridgeInfo { - portal.UpdateBridgeInfo() - } - } - } - if forceUpdateBridgeInfo { - br.DB.KV.Set(database.KVBridgeInfoVersion, database.ExpectedBridgeInfoVersion) - } - syncChatMaxAge := time.Duration(br.Config.Bridge.Backfill.InitialSyncMaxAge*24*60) * time.Minute - chats, err := br.IM.GetChatsWithMessagesAfter(time.Now().Add(-syncChatMaxAge)) - if err != nil { - br.ZLog.Error().Err(err).Msg("Failed to get chat list to backfill") - return - } - for _, chat := range chats { - if !alreadySynced[chat.ChatGUID] { - alreadySynced[chat.ChatGUID] = true - portal := br.GetPortalByGUID(chat.ChatGUID) - if portal.ThreadID == "" { - portal.ThreadID = chat.ThreadID - } - portal.log.Infoln("Syncing portal (startup sync, new portal)") - portal.Sync(true) - } - } - br.ZLog.Info().Msg("Startup sync complete") - br.IM.PostStartupSyncHook() -} - -func (br *IMBridge) PeriodicSync() { - if !br.Config.Bridge.PeriodicSync { - br.Log.Debugln("Periodic sync is disabled") - return - } - br.Log.Debugln("Periodic sync is enabled") - for { - time.Sleep(time.Hour) - br.Log.Infoln("Executing periodic chat/contact info sync") - for _, portal := range br.GetAllPortals() { - if len(portal.MXID) > 0 { - portal.log.Infoln("Syncing portal (periodic sync, existing portal)") - portal.Sync(false) - } - } - } -} - -func (br *IMBridge) UpdateBotProfile() { - br.Log.Debugln("Updating bot profile") - botConfig := br.Config.AppService.Bot - - var err error - if botConfig.Avatar == "remove" { - err = br.Bot.SetAvatarURL(id.ContentURI{}) - } else if len(botConfig.Avatar) > 0 && !botConfig.ParsedAvatar.IsEmpty() { - err = br.Bot.SetAvatarURL(botConfig.ParsedAvatar) - } - if err != nil { - br.Log.Warnln("Failed to update bot avatar:", err) - } - - if botConfig.Displayname == "remove" { - err = br.Bot.SetDisplayName("") - } else if len(botConfig.Avatar) > 0 { - err = br.Bot.SetDisplayName(botConfig.Displayname) - } - if err != nil { - br.Log.Warnln("Failed to update bot displayname:", err) - } -} - -func (br *IMBridge) ipcStop(_ json.RawMessage) interface{} { - br.Stop() - return nil -} - -func (br *IMBridge) Stop() { - br.ZLog.Debug().Msg("Stopping iMessage connector") - if br.Config.Bridge.Backfill.OnlyBackfill { - return - } - br.IM.Stop() - br.IMHandler.Stop() -} - -func (br *IMBridge) HandleFlags() bool { - if *checkPermissions { - checkMacPermissions() - return true - } - if len(*configURL) > 0 { - err := config.Download(*configURL, br.ConfigPath, *configOutputRedirect) - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Failed to download config: %v\n", err) - os.Exit(2) - } - } - return false -} - -func main() { - br := &IMBridge{ - portalsByMXID: make(map[id.RoomID]*Portal), - portalsByGUID: make(map[string]*Portal), - puppets: make(map[string]*Puppet), - userCache: make(map[id.UserID]*User), - } - br.Bridge = bridge.Bridge{ - Name: "mautrix-imessage", - - URL: "https://github.com/mautrix/imessage", - Description: "A Matrix-iMessage puppeting bridge.", - Version: "0.1.0", - ProtocolName: "iMessage", - - AdditionalShortFlags: "po", - AdditionalLongFlags: " [-u ]", - - CryptoPickleKey: "go.mau.fi/mautrix-imessage", - - ConfigUpgrader: &configupgrade.StructUpgrader{ - SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade), - Blocks: config.SpacedBlocks, - Base: ExampleConfig, - }, - - Child: br, - } - br.InitVersion(Tag, Commit, BuildTime) - - br.Main() -} diff --git a/matrix.go b/matrix.go deleted file mode 100644 index 09549dcb..00000000 --- a/matrix.go +++ /dev/null @@ -1,455 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "sync/atomic" - "time" - - "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/imessage" -) - -const DefaultSyncProxyBackoff = 1 * time.Second -const MaxSyncProxyBackoff = 60 * time.Second - -type WebsocketCommandHandler struct { - bridge *IMBridge - log maulogger.Logger - errorTxnIDC *appservice.TransactionIDCache - - lastSyncProxyError time.Time - syncProxyBackoff time.Duration - syncProxyWaiting int64 - - createRoomsForBackfillError error -} - -func NewWebsocketCommandHandler(br *IMBridge) *WebsocketCommandHandler { - handler := &WebsocketCommandHandler{ - bridge: br, - log: br.Log.Sub("MatrixWebsocket"), - errorTxnIDC: appservice.NewTransactionIDCache(8), - syncProxyBackoff: DefaultSyncProxyBackoff, - } - br.AS.PrepareWebsocket() - br.AS.SetWebsocketCommandHandler("ping", handler.handleWSPing) - br.AS.SetWebsocketCommandHandler("syncproxy_error", handler.handleWSSyncProxyError) - br.AS.SetWebsocketCommandHandler("create_group", handler.handleWSCreateGroup) - br.AS.SetWebsocketCommandHandler("start_dm", handler.handleWSStartDM) - br.AS.SetWebsocketCommandHandler("resolve_identifier", handler.handleWSStartDM) - br.AS.SetWebsocketCommandHandler("list_contacts", handler.handleWSGetContacts) - br.AS.SetWebsocketCommandHandler("upload_contacts", handler.handleWSUploadContacts) - br.AS.SetWebsocketCommandHandler("edit_ghost", handler.handleWSEditGhost) - br.AS.SetWebsocketCommandHandler("do_hacky_test", handler.handleWSHackyTest) - br.AS.SetWebsocketCommandHandler("create_rooms_for_backfill", handler.handleCreateRoomsForBackfill) - br.AS.SetWebsocketCommandHandler("get_room_info_for_backfill", handler.handleGetRoomInfoForBackfill) - return handler -} - -func (mx *WebsocketCommandHandler) handleWSHackyTest(cmd appservice.WebsocketCommand) (bool, any) { - mx.log.Debugfln("Starting hacky test due to manual request") - mx.bridge.hackyStartupTests(false, true) - return true, nil -} - -func (mx *WebsocketCommandHandler) handleWSPing(cmd appservice.WebsocketCommand) (bool, interface{}) { - var status imessage.BridgeStatus - - if mx.bridge.latestState != nil { - status = *mx.bridge.latestState - } else { - status = imessage.BridgeStatus{ - StateEvent: BridgeStatusConnected, - Timestamp: time.Now().Unix(), - TTL: 600, - Source: "bridge", - } - } - - return true, &status -} - -func (mx *WebsocketCommandHandler) handleWSSyncProxyError(cmd appservice.WebsocketCommand) (bool, interface{}) { - var data mautrix.RespError - err := json.Unmarshal(cmd.Data, &data) - - if err != nil { - mx.log.Warnln("Failed to unmarshal syncproxy_error data:", err) - } else if txnID, ok := data.ExtraData["txn_id"].(string); !ok { - mx.log.Warnln("Got syncproxy_error data with no transaction ID") - } else if mx.errorTxnIDC.IsProcessed(txnID) { - mx.log.Debugln("Ignoring syncproxy_error with duplicate transaction ID", txnID) - } else { - go mx.HandleSyncProxyError(&data, nil) - mx.errorTxnIDC.MarkProcessed(txnID) - } - - return true, &data -} - -type ProfileOverride struct { - Displayname string `json:"displayname,omitempty"` - PhotoURL string `json:"photo_url,omitempty"` -} - -type EditGhostRequest struct { - UserID id.UserID `json:"user_id"` - RoomID id.RoomID `json:"room_id"` - Reset bool `json:"reset"` - ProfileOverride -} - -func (mx *WebsocketCommandHandler) handleWSEditGhost(cmd appservice.WebsocketCommand) (bool, interface{}) { - var req EditGhostRequest - if err := json.Unmarshal(cmd.Data, &req); err != nil { - return false, fmt.Errorf("failed to parse request: %w", err) - } - var puppet *Puppet - if req.UserID != "" { - puppet = mx.bridge.GetPuppetByMXID(req.UserID) - if puppet == nil { - return false, fmt.Errorf("user is not a bridge ghost") - } - } else if req.RoomID != "" { - portal := mx.bridge.GetPortalByMXID(req.RoomID) - if portal == nil { - return false, fmt.Errorf("unknown room ID provided") - } else if !portal.IsPrivateChat() { - return false, fmt.Errorf("provided room is not a direct chat") - } else if puppet = portal.GetDMPuppet(); puppet == nil { - return false, fmt.Errorf("unexpected error: private chat portal doesn't have ghost") - } - } else { - return false, fmt.Errorf("neither room nor user ID were provided") - } - if req.Reset { - puppet.log.Debugfln("Marking name as not overridden and resyncing profile") - puppet.NameOverridden = false - puppet.Update() - puppet.Sync() - } else { - puppet.log.Debugfln("Updating profile with %+v", req.ProfileOverride) - puppet.SyncWithProfileOverride(req.ProfileOverride) - } - return true, struct{}{} -} - -type StartDMRequest struct { - Identifier string `json:"identifier"` - Force bool `json:"force"` - ProfileOverride - - ActuallyStart bool `json:"-"` -} - -type CreateGroupRequest struct { - Users []string `json:"users"` - - AllowSMS bool `json:"allow_sms"` -} - -type StartDMResponse struct { - RoomID id.RoomID `json:"room_id,omitempty"` - GUID string `json:"guid"` - JustCreated bool `json:"just_created"` -} - -func (mx *WebsocketCommandHandler) handleWSStartDM(cmd appservice.WebsocketCommand) (bool, interface{}) { - var req StartDMRequest - if err := json.Unmarshal(cmd.Data, &req); err != nil { - return false, fmt.Errorf("failed to parse request: %w", err) - } - req.ActuallyStart = cmd.Command == "start_dm" - resp, err := mx.StartChat(req) - if err != nil { - mx.log.Errorfln("Error in %s handler: %v", cmd.Command, err) - return false, err - } else { - return true, resp - } -} - -func (mx *WebsocketCommandHandler) handleWSCreateGroup(cmd appservice.WebsocketCommand) (bool, interface{}) { - var req CreateGroupRequest - err := json.Unmarshal(cmd.Data, &req) - if err != nil { - return false, fmt.Errorf("failed to parse request: %w", err) - } - guids := make([]string, len(req.Users)) - for i, identifier := range req.Users { - if strings.HasPrefix(identifier, "iMessage;-;") || strings.HasPrefix(identifier, "SMS;-;") { - guids[i] = identifier - } else { - guids[i], err = mx.bridge.IM.ResolveIdentifier(identifier) - if err != nil { - return false, fmt.Errorf("failed to resolve identifier %s: %w", identifier, err) - } - } - if strings.HasPrefix(guids[i], "SMS;-;") && !req.AllowSMS { - return false, fmt.Errorf("%s is only available on SMS", identifier) - } - } - mx.log.Debugfln("Creating group with guids %+v (resolved from identifiers %+v)", guids, req.Users) - createResp, err := mx.bridge.IM.CreateGroup(guids) - if err != nil { - return false, fmt.Errorf("failed to create group: %w", err) - } - mx.log.Infofln("Created group %s (%s)", createResp.GUID, createResp.ThreadID) - portal := mx.bridge.GetPortalByGUID(createResp.GUID) - portal.ThreadID = createResp.ThreadID - resp := StartDMResponse{ - RoomID: portal.MXID, - GUID: createResp.GUID, - JustCreated: len(portal.MXID) == 0, - } - err = portal.CreateMatrixRoom(nil, nil) - if err != nil { - return false, fmt.Errorf("failed to create Matrix room: %w", err) - } - resp.RoomID = portal.MXID - return true, &resp -} - -func (mx *WebsocketCommandHandler) handleWSGetContacts(_ appservice.WebsocketCommand) (bool, interface{}) { - contacts, err := mx.bridge.IM.GetContactList() - if err != nil { - return false, err - } - return true, contacts -} - -type UploadContactsRequest struct { - Contacts []*imessage.Contact `json:"contacts"` -} - -func (mx *WebsocketCommandHandler) handleWSUploadContacts(cmd appservice.WebsocketCommand) (bool, any) { - var req UploadContactsRequest - if err := json.Unmarshal(cmd.Data, &req); err != nil { - return false, fmt.Errorf("failed to parse request: %w", err) - } - mx.bridge.UpdateMerges(req.Contacts) - return true, nil -} - -func isNumber(number string) bool { - for _, char := range number { - if (char < '0' || char > '9') && char != '+' { - return false - } - } - return true -} - -func (mx *WebsocketCommandHandler) trackResolveIdentifier(actuallyTrack bool, identifier, status string) { - if actuallyTrack { - identifierType := "unknown" - if isNumber(identifierType) { - identifierType = "phone" - } else if strings.ContainsRune(identifier, '@') { - identifierType = "email" - } - Segment.Track("iMC resolve identifier", map[string]any{ - "status": status, - "is_startup_target": strconv.FormatBool(identifier == mx.bridge.Config.HackyStartupTest.Identifier), - "identifier_type": identifierType, - "tmp_identifier": identifier, - }) - } -} - -func (mx *WebsocketCommandHandler) StartChat(req StartDMRequest) (*StartDMResponse, error) { - var resp StartDMResponse - var err error - var forced bool - - if resp.GUID, err = mx.bridge.IM.ResolveIdentifier(req.Identifier); err != nil { - if req.Force && req.ActuallyStart { - mx.log.Debugfln("Failed to resolve identifier %s (%v), but forcing creation anyway", req.Identifier, err) - resp.GUID = req.Identifier - forced = true - } else { - mx.trackResolveIdentifier(!req.ActuallyStart, req.Identifier, "fail") - return nil, fmt.Errorf("failed to resolve identifier: %w", err) - } - } - if parsed := imessage.ParseIdentifier(resp.GUID); parsed.Service == "SMS" && !isNumber(parsed.LocalID) { - mx.trackResolveIdentifier(!req.ActuallyStart, req.Identifier, "fail") - return nil, fmt.Errorf("can't start SMS with non-numeric identifier") - } else if portal := mx.bridge.GetPortalByGUID(resp.GUID); len(portal.MXID) > 0 || !req.ActuallyStart { - status := "success" - if parsed.Service == "SMS" { - status = "sms" - } - if !forced { - mx.trackResolveIdentifier(!req.ActuallyStart, req.Identifier, status) - } - resp.RoomID = portal.MXID - return &resp, nil - } else if err = mx.bridge.IM.PrepareDM(resp.GUID); err != nil { - return nil, fmt.Errorf("failed to prepare DM: %w", err) - } else if err = portal.CreateMatrixRoom(nil, &req.ProfileOverride); err != nil { - return nil, fmt.Errorf("failed to create Matrix room: %w", err) - } else { - resp.JustCreated = true - resp.RoomID = portal.MXID - return &resp, nil - } -} - -func (mx *WebsocketCommandHandler) HandleSyncProxyError(syncErr *mautrix.RespError, startErr error) { - if !atomic.CompareAndSwapInt64(&mx.syncProxyWaiting, 0, 1) { - var err interface{} = startErr - if err == nil { - err = syncErr.Err - } - mx.log.Debugfln("Got sync proxy error (%v), but there's already another thread waiting to restart sync proxy", err) - return - } - if time.Now().Sub(mx.lastSyncProxyError) < MaxSyncProxyBackoff { - mx.syncProxyBackoff *= 2 - if mx.syncProxyBackoff > MaxSyncProxyBackoff { - mx.syncProxyBackoff = MaxSyncProxyBackoff - } - } else { - mx.syncProxyBackoff = DefaultSyncProxyBackoff - } - mx.lastSyncProxyError = time.Now() - if syncErr != nil { - mx.log.Errorfln("Syncproxy told us that syncing failed: %s - Requesting a restart in %s", syncErr.Err, mx.syncProxyBackoff) - } else if startErr != nil { - mx.log.Errorfln("Failed to request sync proxy to start syncing: %v - Requesting a restart in %s", startErr, mx.syncProxyBackoff) - } - time.Sleep(mx.syncProxyBackoff) - atomic.StoreInt64(&mx.syncProxyWaiting, 0) - mx.bridge.RequestStartSync() -} - -type NewRoomBackfillRequest struct { - Chats []*imessage.ChatInfo `json:"chats"` -} - -func (mx *WebsocketCommandHandler) handleCreateRoomsForBackfill(cmd appservice.WebsocketCommand) (bool, any) { - var req NewRoomBackfillRequest - if err := json.Unmarshal(cmd.Data, &req); err != nil { - return false, fmt.Errorf("failed to parse request: %w", err) - } - - mx.log.Debugfln("Got request to create %d rooms for backfill", len(req.Chats)) - go func() { - mx.createRoomsForBackfillError = nil - for _, info := range req.Chats { - info.Identifier = imessage.ParseIdentifier(info.JSONChatGUID) - portals := mx.bridge.FindPortalsByThreadID(info.ThreadID) - var portal *Portal - if len(portals) > 1 { - mx.log.Warnfln("Found multiple portals with thread ID %s (message chat guid: %s)", info.ThreadID, info.Identifier.String()) - continue - } else if len(portals) == 1 { - portal = portals[0] - } else { - // This will create the new portal - portal = mx.bridge.GetPortalByGUID(info.Identifier.String()) - } - - if len(portal.MXID) == 0 { - portal.zlog.Info().Msg("Creating Matrix room with latest chat info") - err := portal.CreateMatrixRoom(info, nil) - if err != nil { - mx.createRoomsForBackfillError = err - return - } - } else { - portal.zlog.Info().Msg("Syncing Matrix room with latest chat info") - portal.SyncWithInfo(info) - } - - mx.log.Debugfln("Room %s created for backfilling %s", portal.MXID, info.JSONChatGUID) - } - }() - return true, struct{}{} -} - -type RoomInfoForBackfillRequest struct { - ChatGUIDs []string `json:"chats_guids"` -} - -type RoomInfoForBackfill struct { - RoomID id.RoomID `json:"room_id"` - EarliestBridgedTimestamp int64 `json:"earliest_bridged_timestamp"` -} - -type RoomCreationForBackfillStatus string - -const ( - RoomCreationForBackfillStatusInProgress RoomCreationForBackfillStatus = "in-progress" - RoomCreationForBackfillStatusDone RoomCreationForBackfillStatus = "done" - RoomCreationForBackfillStatusError RoomCreationForBackfillStatus = "error" -) - -type RoomInfoForBackfillResponse struct { - Status RoomCreationForBackfillStatus `json:"status"` - Error string `json:"error,omitempty"` - Rooms map[string]RoomInfoForBackfill `json:"rooms,omitempty"` -} - -func (mx *WebsocketCommandHandler) handleGetRoomInfoForBackfill(cmd appservice.WebsocketCommand) (bool, any) { - if mx.createRoomsForBackfillError != nil { - return true, RoomInfoForBackfillResponse{ - Status: RoomCreationForBackfillStatusError, - Error: mx.createRoomsForBackfillError.Error(), - } - } - - var req RoomInfoForBackfillRequest - if err := json.Unmarshal(cmd.Data, &req); err != nil { - return false, fmt.Errorf("failed to parse request: %w", err) - } - - resp := RoomInfoForBackfillResponse{ - Status: RoomCreationForBackfillStatusDone, - Rooms: map[string]RoomInfoForBackfill{}, - } - now := time.Now().UnixMilli() - mx.log.Debugfln("Got request to get room info for backfills") - for _, chatGUID := range req.ChatGUIDs { - portal := mx.bridge.GetPortalByGUID(chatGUID) - - if len(portal.MXID) == 0 { - return true, RoomInfoForBackfillResponse{Status: RoomCreationForBackfillStatusInProgress} - } - - timestamp, err := mx.bridge.DB.Message.GetEarliestTimestampInChat(chatGUID) - if err != nil || timestamp < 0 { - timestamp = now - } - resp.Rooms[portal.GUID] = RoomInfoForBackfill{ - RoomID: portal.MXID, - EarliestBridgedTimestamp: timestamp, - } - } - - return true, resp -} diff --git a/mediaviewer.go b/mediaviewer.go deleted file mode 100644 index dff671c8..00000000 --- a/mediaviewer.go +++ /dev/null @@ -1,128 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha512" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "path" - - "golang.org/x/crypto/hkdf" - - "maunium.net/go/mautrix/event" -) - -type mediaViewerCreateRequest struct { - Ciphertext string `json:"ciphertext"` - AuthToken string `json:"auth_token"` - Homeserver string `json:"homeserver"` -} - -type mediaViewerCreateResponse struct { - Error string `json:"message"` - FileID string `json:"file_id"` -} - -func extractKeys(mediaKey []byte) (encryption, iv, auth []byte, err error) { - prk := hkdf.Extract(sha512.New, mediaKey, nil) - encryption = make([]byte, 32) - iv = make([]byte, 12) - auth = make([]byte, 32) - - if _, err = io.ReadFull(hkdf.Expand(sha512.New, prk, []byte("encryption")), encryption); err != nil { - err = fmt.Errorf("encryption hkdf failed: %w", err) - } else if _, err = io.ReadFull(hkdf.Expand(sha512.New, prk, []byte("initialization")), iv); err != nil { - err = fmt.Errorf("iv hkdf failed: %w", err) - } else if _, err = io.ReadFull(hkdf.Expand(sha512.New, prk, []byte("authentication")), auth); err != nil { - err = fmt.Errorf("authentication hkdf failed: %w", err) - } - return -} - -func (br *IMBridge) createMediaViewerURL(content *event.Content) (string, error) { - msg := content.AsMessage() - if msg.File == nil { - if len(msg.URL) > 0 { - parsedMXC, err := msg.URL.Parse() - return br.Bot.GetDownloadURL(parsedMXC), err - } - return "", fmt.Errorf("no URL in message") - } - - parsedURL, err := url.Parse(br.Config.Bridge.MediaViewer.URL) - if err != nil { - return "", fmt.Errorf("invalid media viewer URL in config: %w", err) - } - origPath := parsedURL.Path - parsedURL.Path = path.Join(origPath, "create") - createURL := parsedURL.String() - - mediaKey := make([]byte, 16) - var encryptionKey, iv, authToken []byte - var ciphertext []byte - if _, err = rand.Read(mediaKey); err != nil { - return "", fmt.Errorf("failed to generate media key: %w", err) - } else if encryptionKey, iv, authToken, err = extractKeys(mediaKey); err != nil { - return "", err - } else if block, err := aes.NewCipher(encryptionKey); err != nil { - return "", fmt.Errorf("failed to prepare AES cipher: %w", err) - } else if gcm, err := cipher.NewGCM(block); err != nil { - return "", fmt.Errorf("failed to prepare GCM cipher: %w", err) - } else { - ciphertext = gcm.Seal(nil, iv, content.VeryRaw, nil) - } - - var reqDataBytes bytes.Buffer - mediaHomeserver := br.Config.Bridge.MediaViewer.Homeserver - if mediaHomeserver == "" { - mediaHomeserver = br.Config.Homeserver.Domain - } - reqData := mediaViewerCreateRequest{ - Ciphertext: base64.RawStdEncoding.EncodeToString(ciphertext), - AuthToken: base64.RawStdEncoding.EncodeToString(authToken), - Homeserver: mediaHomeserver, - } - var respData mediaViewerCreateResponse - - if err = json.NewEncoder(&reqDataBytes).Encode(&reqData); err != nil { - return "", fmt.Errorf("failed to marshal create request: %w", err) - } else if req, err := http.NewRequest(http.MethodPost, createURL, &reqDataBytes); err != nil { - return "", fmt.Errorf("failed to prepare create request: %w", err) - } else if resp, err := http.DefaultClient.Do(req); err != nil { - return "", fmt.Errorf("failed to send create request: %w", err) - } else if err = json.NewDecoder(resp.Body).Decode(&respData); err != nil { - if resp.StatusCode >= 400 { - return "", fmt.Errorf("server returned non-JSON error with status code %d", resp.StatusCode) - } - return "", fmt.Errorf("failed to decode response: %w", err) - } else if resp.StatusCode >= 400 { - return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, respData.Error) - } else { - parsedURL.Path = path.Join(origPath, respData.FileID) - parsedURL.Fragment = base64.RawURLEncoding.EncodeToString(mediaKey) - return parsedURL.String(), nil - } -} diff --git a/nac-validation/Cargo.lock b/nac-validation/Cargo.lock new file mode 100644 index 00000000..24f82d8d --- /dev/null +++ b/nac-validation/Cargo.lock @@ -0,0 +1,95 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "nac-validation" +version = "0.1.0" +dependencies = [ + "cc", + "libc", + "thiserror", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" diff --git a/nac-validation/Cargo.toml b/nac-validation/Cargo.toml new file mode 100644 index 00000000..b7e6400d --- /dev/null +++ b/nac-validation/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nac-validation" +version = "0.1.0" +edition = "2021" +description = "Generate Apple APNs validation data locally on macOS 13+ (Ventura or later)" + +[dependencies] +libc = "0.2" +thiserror = "2" + +[build-dependencies] +cc = "1" diff --git a/nac-validation/build.rs b/nac-validation/build.rs new file mode 100644 index 00000000..75bd46ed --- /dev/null +++ b/nac-validation/build.rs @@ -0,0 +1,15 @@ +fn main() { + println!("cargo:rerun-if-changed=src/validation_data.m"); + println!("cargo:rerun-if-changed=src/validation_data.h"); + + // Compile the Objective-C file + cc::Build::new() + .file("src/validation_data.m") + .flag("-fobjc-arc") + .flag("-fmodules") // for @import if needed + .define("NAC_NO_MAIN", None) // exclude main() when building as a library + .compile("validation_data"); + + // Link with Foundation framework + println!("cargo:rustc-link-lib=framework=Foundation"); +} diff --git a/nac-validation/src/lib.rs b/nac-validation/src/lib.rs new file mode 100644 index 00000000..bb92b85c --- /dev/null +++ b/nac-validation/src/lib.rs @@ -0,0 +1,249 @@ +//! Apple APNs validation data generation for macOS 13+ (Ventura or later) +//! +//! This crate provides `generate_validation_data()` which calls the NAC +//! (Network Attestation Credential) functions via Apple's private +//! `AppleAccount.framework` (class `AAAbsintheContext`) to produce the +//! opaque validation data blob required for IDS registration. +//! +//! # Requirements +//! - macOS 13+ (Ventura or later) +//! - SIP can remain enabled +//! - No jailbreak or code injection required +//! - Network access to Apple's servers +//! +//! # Build +//! The `validation_data.m` Objective-C file must be compiled and linked. +//! Use the provided `build.rs` or compile manually: +//! ```sh +//! cc -c validation_data.m -framework Foundation -fobjc-arc -o validation_data.o +//! ``` + +use std::ffi::CStr; +use std::os::raw::{c_char, c_void}; +use std::ptr; + +// C FFI surface — kept in sync with `src/validation_data.h`. +extern "C" { + fn nac_generate_validation_data( + out_buf: *mut *mut u8, + out_len: *mut usize, + out_err_buf: *mut *mut c_char, + ) -> i32; + + fn nac_ctx_init( + cert_buf: *const u8, + cert_len: usize, + out_handle: *mut *mut c_void, + out_request_buf: *mut *mut u8, + out_request_len: *mut usize, + out_err_buf: *mut *mut c_char, + ) -> i32; + + fn nac_ctx_key_establishment( + handle: *mut c_void, + session_info_buf: *const u8, + session_info_len: usize, + out_err_buf: *mut *mut c_char, + ) -> i32; + + fn nac_ctx_sign( + handle: *mut c_void, + out_buf: *mut *mut u8, + out_len: *mut usize, + out_err_buf: *mut *mut c_char, + ) -> i32; + + fn nac_ctx_free(handle: *mut c_void); +} + +/// Error type for validation data generation. +#[derive(Debug, thiserror::Error)] +pub enum NacError { + #[error("NAC error (code {code}): {message}")] + NacFailed { code: i32, message: String }, +} + +/// Generate APNs validation data for IDS registration. +/// +/// This handles the full NAC protocol: +/// 1. Fetches validation certificate from Apple +/// 2. NACInit with the certificate +/// 3. Sends session info request to Apple's servers +/// 4. NACKeyEstablishment with the response +/// 5. NACSign to produce the final validation data +/// +/// Hardware identifiers are read automatically from IOKit. +/// +/// Returns the raw validation data bytes on success. +pub fn generate_validation_data() -> Result, NacError> { + let mut buf: *mut u8 = ptr::null_mut(); + let mut len: usize = 0; + let mut err_buf: *mut std::os::raw::c_char = ptr::null_mut(); + + let result = unsafe { nac_generate_validation_data(&mut buf, &mut len, &mut err_buf) }; + + if result == 0 && !buf.is_null() { + let data = unsafe { std::slice::from_raw_parts(buf, len) }.to_vec(); + unsafe { libc::free(buf as *mut _) }; + Ok(data) + } else { + let message = if !err_buf.is_null() { + let msg = unsafe { CStr::from_ptr(err_buf) } + .to_string_lossy() + .into_owned(); + unsafe { libc::free(err_buf as *mut _) }; + msg + } else { + format!("Unknown NAC error (code {})", result) + }; + + Err(NacError::NacFailed { + code: result, + message, + }) + } +} + +/// An initialized `AAAbsintheContext` that exposes the three NAC steps +/// (`NACInit` → `NACKeyEstablishment` → `NACSign`) as separate Rust methods. +/// +/// This lets callers that already own the `id-initialize-validation` POST +/// (such as `rustpush`'s `MacOSConfig::generate_validation_data`, via +/// `open-absinthe`'s `ValidationCtx`) drive the full protocol one step at a +/// time while delegating each call to Apple's native framework. The result +/// is byte-identical to [`generate_validation_data`] but integrates cleanly +/// with an HTTP flow that the caller controls — no double POST to Apple, +/// no stub request bytes, no modification of upstream rustpush. +pub struct NacContext { + handle: *mut c_void, +} + +// The underlying AAAbsintheContext is not Send/Sync by default; upstream +// rustpush uses it from a single async task so we mirror that pattern. +unsafe impl Send for NacContext {} + +impl NacContext { + /// Step 1: `NACInit`. + /// + /// Creates a fresh `AAAbsintheContext`, calls `NACInit(cert)`, and + /// returns the initialized context together with the session-info + /// request bytes the caller should POST to + /// `id-initialize-validation`. + pub fn init(cert: &[u8]) -> Result<(Self, Vec), NacError> { + let mut handle: *mut c_void = ptr::null_mut(); + let mut req_buf: *mut u8 = ptr::null_mut(); + let mut req_len: usize = 0; + let mut err_buf: *mut c_char = ptr::null_mut(); + + let result = unsafe { + nac_ctx_init( + cert.as_ptr(), + cert.len(), + &mut handle, + &mut req_buf, + &mut req_len, + &mut err_buf, + ) + }; + + if result == 0 && !handle.is_null() && !req_buf.is_null() { + let request_bytes = + unsafe { std::slice::from_raw_parts(req_buf, req_len) }.to_vec(); + unsafe { libc::free(req_buf as *mut _) }; + Ok((Self { handle }, request_bytes)) + } else { + // Best-effort cleanup if the C side partially succeeded. + if !handle.is_null() { + unsafe { nac_ctx_free(handle) }; + } + if !req_buf.is_null() { + unsafe { libc::free(req_buf as *mut _) }; + } + Err(take_err(result, err_buf)) + } + } + + /// Step 2: `NACKeyEstablishment`. + /// + /// Feeds Apple's session-info response bytes back into the context. + pub fn key_establishment(&mut self, session_info: &[u8]) -> Result<(), NacError> { + let mut err_buf: *mut c_char = ptr::null_mut(); + let result = unsafe { + nac_ctx_key_establishment( + self.handle, + session_info.as_ptr(), + session_info.len(), + &mut err_buf, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(take_err(result, err_buf)) + } + } + + /// Step 3: `NACSign`. + /// + /// Produces the final validation data bytes. + pub fn sign(&mut self) -> Result, NacError> { + let mut buf: *mut u8 = ptr::null_mut(); + let mut len: usize = 0; + let mut err_buf: *mut c_char = ptr::null_mut(); + let result = + unsafe { nac_ctx_sign(self.handle, &mut buf, &mut len, &mut err_buf) }; + + if result == 0 && !buf.is_null() { + let data = unsafe { std::slice::from_raw_parts(buf, len) }.to_vec(); + unsafe { libc::free(buf as *mut _) }; + Ok(data) + } else { + if !buf.is_null() { + unsafe { libc::free(buf as *mut _) }; + } + Err(take_err(result, err_buf)) + } + } +} + +impl Drop for NacContext { + fn drop(&mut self) { + if !self.handle.is_null() { + unsafe { nac_ctx_free(self.handle) }; + self.handle = ptr::null_mut(); + } + } +} + +fn take_err(code: i32, err_buf: *mut c_char) -> NacError { + let message = if !err_buf.is_null() { + let msg = unsafe { CStr::from_ptr(err_buf) } + .to_string_lossy() + .into_owned(); + unsafe { libc::free(err_buf as *mut _) }; + msg + } else { + format!("Unknown NAC error (code {})", code) + }; + NacError::NacFailed { code, message } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_validation_data() { + let data = generate_validation_data().expect("Failed to generate validation data"); + assert!(!data.is_empty(), "Validation data should not be empty"); + assert!( + data.len() > 100, + "Validation data should be substantial (got {} bytes)", + data.len() + ); + eprintln!( + "Generated {} bytes of validation data", + data.len() + ); + } +} diff --git a/nac-validation/src/validation_data.h b/nac-validation/src/validation_data.h new file mode 100644 index 00000000..30b91cc8 --- /dev/null +++ b/nac-validation/src/validation_data.h @@ -0,0 +1,109 @@ +/** + * validation_data.h — C FFI interface for generating Apple APNs validation data + * + * Link with: -framework Foundation -fobjc-arc + */ + +#ifndef VALIDATION_DATA_H +#define VALIDATION_DATA_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Generate APNs validation data for IDS registration. + * + * This function handles the entire NAC protocol: + * 1. Fetches the validation certificate from Apple + * 2. Initializes a NAC context (NACInit) + * 3. Sends session info request to Apple (HTTP POST) + * 4. Performs key establishment (NACKeyEstablishment) + * 5. Signs and produces validation data (NACSign) + * + * The hardware identifiers are read automatically from IOKit. + * + * @param out_buf On success, receives a malloc'd buffer with validation data. + * Caller must free() this buffer. + * @param out_len On success, receives the length of the validation data. + * @param out_err_buf On failure, receives a malloc'd error message string. + * Caller must free() this buffer. May be NULL. + * @return 0 on success, non-zero error code on failure. + * + * Error codes: + * 1 = Failed to load AppleAccount.framework + * 2 = Failed to fetch validation certificate + * 3 = Invalid certificate plist format + * 4 = AAAbsintheContext class not found + * 5 = NACInit failed + * 6 = HTTP request to initializeValidation failed + * 7 = Invalid response plist + * 8 = Server returned non-zero status + * 9 = No session-info in response + * 10 = NACKeyEstablishment failed + * 11 = NACSign failed + */ +int nac_generate_validation_data(uint8_t **out_buf, size_t *out_len, char **out_err_buf); + +/* ------------------------------------------------------------------------- */ +/* 3-step NAC API. */ +/* */ +/* Exposes NACInit / NACKeyEstablishment / NACSign as individual entry */ +/* points so a caller that already owns the id-initialize-validation POST */ +/* (e.g. rustpush's `MacOSConfig::generate_validation_data`) can drive each */ +/* step of the AAAbsintheContext protocol directly. This is how */ +/* open-absinthe's `ValidationCtx` Native mode delegates macOS Local NAC */ +/* without requiring any modifications to upstream rustpush. */ +/* ------------------------------------------------------------------------- */ + +/** + * Step 1: NACInit. + * + * Loads AppleAccount.framework, creates an AAAbsintheContext, discovers the + * NAC selectors, and calls NACInit(cert) to produce session-info request + * bytes that the caller should POST to id-initialize-validation. + * + * On success the opaque handle must be released with nac_ctx_free. + */ +int nac_ctx_init(const uint8_t *cert_buf, + size_t cert_len, + void **out_handle, + uint8_t **out_request_buf, + size_t *out_request_len, + char **out_err_buf); + +/** + * Step 2: NACKeyEstablishment. + * + * Feeds Apple's session-info response into the context created by + * nac_ctx_init. + */ +int nac_ctx_key_establishment(void *handle, + const uint8_t *session_info_buf, + size_t session_info_len, + char **out_err_buf); + +/** + * Step 3: NACSign. + * + * Produces the final validation data from the established context. + * Caller must free() out_buf on success. + */ +int nac_ctx_sign(void *handle, + uint8_t **out_buf, + size_t *out_len, + char **out_err_buf); + +/** + * Release the context handle. Safe to call with NULL. + */ +void nac_ctx_free(void *handle); + +#ifdef __cplusplus +} +#endif + +#endif /* VALIDATION_DATA_H */ diff --git a/nac-validation/src/validation_data.m b/nac-validation/src/validation_data.m new file mode 100644 index 00000000..54e5e3fe --- /dev/null +++ b/nac-validation/src/validation_data.m @@ -0,0 +1,674 @@ +/** + * validation_data.m — Generate Apple APNs validation data on macOS 13+ (Ventura or later) + * + * Uses the private AAAbsintheContext class from AppleAccount.framework to call + * the underlying NAC (Network Attestation Credential) functions. No SIP modification, + * no code injection, no jailbreak required. + * + * Protocol: + * 1. Fetch validation cert from Apple (DER cert in a plist) + * 2. NACInit: Pass cert to context → get session info request bytes + * 3. Send request bytes to Apple's initializeValidation endpoint → get session info + * 4. NACKeyEstablishment: Pass session info to context + * 5. NACSign: Get final validation data bytes + * + * Build: + * cc -o validation_data validation_data.m -framework Foundation -fobjc-arc + * + * The output is the raw validation data bytes written to stdout (or a file), + * suitable for use with rustpush's OSConfig::generate_validation_data(). + */ + +#import +#import +#import +#import + +// ---- Configuration ---- + +static NSString *const kIDSBagURL = @"https://init.ess.apple.com/WebObjects/VCInit.woa/wa/getBag?ix=3"; + +// ---- Synchronous HTTP helper ---- + +static NSData *httpGet(NSString *urlStr, NSError **outError) { + NSURL *url = [NSURL URLWithString:urlStr]; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSData *result = nil; + __block NSError *blockError = nil; + + NSURLSession *session = [NSURLSession sharedSession]; + [[session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) { + result = data; + blockError = err; + dispatch_semaphore_signal(sem); + }] resume]; + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC)); + + if (blockError && outError) *outError = blockError; + return result; +} + +static NSData *httpPost(NSString *urlStr, NSData *body, NSString *contentType, NSInteger *outStatus, NSError **outError) { + NSURL *url = [NSURL URLWithString:urlStr]; + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:url]; + [req setHTTPMethod:@"POST"]; + [req setHTTPBody:body]; + [req setValue:contentType forHTTPHeaderField:@"Content-Type"]; + [req setTimeoutInterval:30]; + + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSData *result = nil; + __block NSError *blockError = nil; + __block NSInteger status = 0; + + NSURLSession *session = [NSURLSession sharedSession]; + [[session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) { + result = data; + blockError = err; + if ([resp isKindOfClass:[NSHTTPURLResponse class]]) + status = [(NSHTTPURLResponse *)resp statusCode]; + dispatch_semaphore_signal(sem); + }] resume]; + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC)); + + if (outStatus) *outStatus = status; + if (blockError && outError) *outError = blockError; + return result; +} + +// ---- IDS bag URL resolution ---- + +/** + * Fetch and parse the IDS bag, returning the inner dictionary. + * The bag endpoint returns a plist with a "bag" key containing a nested plist dictionary. + */ +static NSDictionary *fetchIDSBag(NSString *bagURL, NSError **outError) { + NSError *fetchErr = nil; + NSData *bagData = httpGet(bagURL, &fetchErr); + if (!bagData) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:30 + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Failed to fetch IDS bag: %@", fetchErr]}]; + return nil; + } + + id outerPlist = [NSPropertyListSerialization propertyListWithData:bagData options:0 format:NULL error:&fetchErr]; + if (![outerPlist isKindOfClass:[NSDictionary class]] || !outerPlist[@"bag"]) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:31 + userInfo:@{NSLocalizedDescriptionKey: @"IDS bag response missing 'bag' key"}]; + return nil; + } + + NSData *innerData = outerPlist[@"bag"]; + if (![innerData isKindOfClass:[NSData class]]) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:32 + userInfo:@{NSLocalizedDescriptionKey: @"IDS bag 'bag' value is not data"}]; + return nil; + } + + id innerPlist = [NSPropertyListSerialization propertyListWithData:innerData options:0 format:NULL error:&fetchErr]; + if (![innerPlist isKindOfClass:[NSDictionary class]]) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:33 + userInfo:@{NSLocalizedDescriptionKey: @"IDS bag inner plist is not a dictionary"}]; + return nil; + } + + return innerPlist; +} + +// ---- NAC selector discovery ---- + +/** + * Holds the three dynamically-discovered NAC selectors. + */ +typedef struct { + SEL initSel; // NACInit: cert → request bytes (returns @) + SEL keyEstabSel; // NACKeyEstablishment: sessionInfo → BOOL (returns B) + SEL signSel; // NACSign: nil → validation data (returns @) +} NACSelectors; + +/** + * Discover NAC selectors on AAAbsintheContext by type signature matching. + * + * Enumerates all instance methods, filters for the *:error: two-arg pattern, + * and classifies by return type: + * - B or c (BOOL) return → NACKeyEstablishment (unique) + * ('B' = _Bool on ARM64, 'c' = signed char on x86_64) + * - @ (object) return → NACInit or NACSign (disambiguated by trial call) + * + * To distinguish init from sign: creates a temporary context and tries each + * @-returning candidate with the cert data. NACInit returns non-nil request + * bytes; NACSign on an uninitialized context returns nil. + * + * @param cls The AAAbsintheContext class + * @param certData Certificate data (used for init/sign disambiguation) + * @param out Receives the discovered selectors + * @param outError Receives error info on failure + * @return 0 on success, non-zero on failure + */ +static int discover_nac_selectors(Class cls, NSData *certData, NACSelectors *out, NSError **outError) { + unsigned int methodCount = 0; + Method *methods = class_copyMethodList(cls, &methodCount); + if (!methods) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:20 + userInfo:@{NSLocalizedDescriptionKey: @"class_copyMethodList returned NULL"}]; + return 20; + } + + SEL boolSel = NULL; + SEL objSels[2] = {NULL, NULL}; + int objCount = 0; + + for (unsigned int i = 0; i < methodCount; i++) { + SEL sel = method_getName(methods[i]); + const char *name = sel_getName(sel); + const char *typeEnc = method_getTypeEncoding(methods[i]); + + if (!name || !typeEnc) continue; + + // Must end with ":error:" and have exactly 2 colons + size_t len = strlen(name); + if (len < 7) continue; + if (strcmp(name + len - 7, ":error:") != 0) continue; + + int colons = 0; + for (const char *p = name; *p; p++) { + if (*p == ':') colons++; + } + if (colons != 2) continue; + + // Classify by return type (first char of type encoding) + // BOOL is '_Bool' (encoding 'B') on ARM64, but 'signed char' (encoding 'c') on x86_64 + if (typeEnc[0] == 'B' || typeEnc[0] == 'c') { + boolSel = sel; + } else if (typeEnc[0] == '@') { + if (objCount < 2) { + objSels[objCount++] = sel; + } + } + } + free(methods); + + if (!boolSel) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:21 + userInfo:@{NSLocalizedDescriptionKey: @"No BOOL-returning (B or c) *:error: method found (NACKeyEstablishment)"}]; + return 21; + } + if (objCount != 2) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:22 + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Expected 2 object-returning *:error: methods, found %d", objCount]}]; + return 22; + } + + out->keyEstabSel = boolSel; + + // Disambiguate init vs sign: try each candidate on a throwaway context. + // NACInit(cert) returns non-nil request bytes on a fresh context. + // NACSign on an uninitialized context returns nil. + id tempCtx = [[cls alloc] init]; + NSError *tempErr = nil; + NSData *tryResult = ((id(*)(id, SEL, id, NSError **))objc_msgSend)( + tempCtx, objSels[0], certData, &tempErr); + + if (tryResult != nil) { + out->initSel = objSels[0]; + out->signSel = objSels[1]; + } else { + out->initSel = objSels[1]; + out->signSel = objSels[0]; + } + + return 0; +} + +// ---- Main validation data generation ---- + +/** + * Generate APNs validation data. + * + * @param outData On success, receives the validation data bytes (caller must free/release) + * @param outError On failure, receives an error description + * @return 0 on success, non-zero on failure + */ +int generate_validation_data(NSData **outData, NSError **outError) { + // Load the AppleAccount framework (contains AAAbsintheContext) + void *handle = dlopen("/System/Library/PrivateFrameworks/AppleAccount.framework/AppleAccount", RTLD_NOW); + if (!handle) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:1 + userInfo:@{NSLocalizedDescriptionKey: @"Failed to load AppleAccount.framework"}]; + return 1; + } + + // --- Step 0: Resolve URLs from IDS bag --- + NSError *fetchErr = nil; + NSDictionary *bag = fetchIDSBag(kIDSBagURL, &fetchErr); + if (!bag) { + if (outError && !*outError) *outError = fetchErr; + return 30; + } + NSString *certURL = bag[@"id-validation-cert"]; + NSString *initValidationURL = bag[@"id-initialize-validation"]; + if (!certURL || !initValidationURL) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:34 + userInfo:@{NSLocalizedDescriptionKey: @"IDS bag missing cert or validation URL"}]; + return 34; + } + + // --- Step 1: Fetch validation certificate --- + NSData *certPlistData = httpGet(certURL, &fetchErr); + if (!certPlistData) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:2 + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to fetch cert: %@", fetchErr]}]; + return 2; + } + + id certPlist = [NSPropertyListSerialization propertyListWithData:certPlistData options:0 format:NULL error:&fetchErr]; + if (![certPlist isKindOfClass:[NSDictionary class]] || !certPlist[@"cert"]) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:3 + userInfo:@{NSLocalizedDescriptionKey: @"Invalid cert plist format"}]; + return 3; + } + NSData *certData = certPlist[@"cert"]; + + // --- Step 2: NACInit — create context and get session info request --- + Class ctxClass = NSClassFromString(@"AAAbsintheContext"); + if (!ctxClass) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:4 + userInfo:@{NSLocalizedDescriptionKey: @"AAAbsintheContext class not found"}]; + return 4; + } + + // Discover NAC selectors by type signature (no hardcoded method names) + NACSelectors sels = {0}; + int discoverResult = discover_nac_selectors(ctxClass, certData, &sels, outError); + if (discoverResult != 0) return discoverResult; + + id ctx = [[ctxClass alloc] init]; + NSError *nacError = nil; + + // NACInit: cert → requestBytes (selector discovered at runtime) + NSData *requestBytes = ((id(*)(id, SEL, id, NSError **))objc_msgSend)( + ctx, sels.initSel, certData, &nacError); + + if (!requestBytes) { + if (outError) *outError = nacError ?: [NSError errorWithDomain:@"NAC" code:5 + userInfo:@{NSLocalizedDescriptionKey: @"NACInit returned nil"}]; + return 5; + } + + // --- Step 3: Send session info request to Apple --- + NSDictionary *requestDict = @{@"session-info-request": requestBytes}; + NSData *requestPlist = [NSPropertyListSerialization dataWithPropertyList:requestDict + format:NSPropertyListXMLFormat_v1_0 options:0 error:&nacError]; + + NSInteger httpStatus = 0; + NSData *responseData = httpPost(initValidationURL, requestPlist, + @"application/x-apple-plist", &httpStatus, &nacError); + + if (httpStatus != 200 || !responseData) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:6 + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"initializeValidation failed: HTTP %ld, %@", (long)httpStatus, nacError]}]; + return 6; + } + + id responsePlist = [NSPropertyListSerialization propertyListWithData:responseData + options:0 format:NULL error:&nacError]; + if (![responsePlist isKindOfClass:[NSDictionary class]]) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:7 + userInfo:@{NSLocalizedDescriptionKey: @"Invalid response plist"}]; + return 7; + } + + NSNumber *status = responsePlist[@"status"]; + if (status && [status integerValue] != 0) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:8 + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Server returned status %@", status]}]; + return 8; + } + + NSData *sessionInfo = responsePlist[@"session-info"]; + if (!sessionInfo) { + if (outError) *outError = [NSError errorWithDomain:@"NAC" code:9 + userInfo:@{NSLocalizedDescriptionKey: @"No session-info in response"}]; + return 9; + } + + // --- Step 4: NACKeyEstablishment — feed session info into context --- + nacError = nil; + // NACKeyEstablishment: sessionInfo → BOOL (selector discovered at runtime) + BOOL keyResult = ((BOOL(*)(id, SEL, id, NSError **))objc_msgSend)( + ctx, sels.keyEstabSel, sessionInfo, &nacError); + + if (!keyResult) { + if (outError) *outError = nacError ?: [NSError errorWithDomain:@"NAC" code:10 + userInfo:@{NSLocalizedDescriptionKey: @"NACKeyEstablishment failed"}]; + return 10; + } + + // --- Step 5: NACSign — get final validation data --- + nacError = nil; + // NACSign: nil → validationData (selector discovered at runtime) + NSData *validationData = ((id(*)(id, SEL, id, NSError **))objc_msgSend)( + ctx, sels.signSel, nil, &nacError); + + if (!validationData || ![validationData isKindOfClass:[NSData class]]) { + if (outError) *outError = nacError ?: [NSError errorWithDomain:@"NAC" code:11 + userInfo:@{NSLocalizedDescriptionKey: @"NACSign failed or returned non-data"}]; + return 11; + } + + *outData = validationData; + return 0; +} + +// ---- C FFI interface ---- + +/** + * C-callable FFI function for generating validation data. + * + * @param out_buf Receives a pointer to the validation data bytes (caller must free with free()) + * @param out_len Receives the length of the validation data + * @param out_err_buf On error, receives a pointer to error message (caller must free with free()) + * @return 0 on success, non-zero on failure + */ +int nac_generate_validation_data(uint8_t **out_buf, size_t *out_len, char **out_err_buf) { + @autoreleasepool { + NSData *data = nil; + NSError *error = nil; + + int result = generate_validation_data(&data, &error); + + if (result == 0 && data) { + *out_len = [data length]; + *out_buf = (uint8_t *)malloc(*out_len); + memcpy(*out_buf, [data bytes], *out_len); + if (out_err_buf) *out_err_buf = NULL; + return 0; + } else { + *out_buf = NULL; + *out_len = 0; + if (out_err_buf && error) { + const char *msg = [[error localizedDescription] UTF8String]; + *out_err_buf = strdup(msg ? msg : "Unknown error"); + } + return result; + } + } +} + +// ============================================================================ +// 3-step NAC API — exposes NACInit/NACKeyEstablishment/NACSign as individual +// C functions so callers can drive the HTTP session-info roundtrip themselves. +// +// This is what `rustpush::macos::MacOSConfig::generate_validation_data` needs: +// it owns the cert fetch and the id-initialize-validation POST, and expects +// a ValidationCtx that exposes new(cert) → request bytes, key_establishment, +// and sign() as distinct steps. The 3-step API lets `open-absinthe`'s +// ValidationCtx delegate each step directly to `AAAbsintheContext`, producing +// Local NAC validation data through upstream's existing HTTP flow — no +// patching of rustpush, no double-POST to Apple, no stub request bytes. +// ============================================================================ + +/** + * Opaque handle that keeps an initialized `AAAbsintheContext` alive across + * the three NAC steps. Stored as CFBridgingRetain to survive outside ARC. + */ +typedef struct { + void *ctx; // __bridge_retained AAAbsintheContext * + SEL initSel; + SEL keyEstabSel; + SEL signSel; +} NacContext; + +/** + * Step 1: NACInit. + * Loads AppleAccount.framework, creates AAAbsintheContext, discovers the + * three NAC selectors, and calls NACInit(cert) to produce the session-info + * request bytes. The caller POSTs those bytes to Apple's + * id-initialize-validation endpoint. + * + * @param cert_buf cert bytes (from id-validation-cert) + * @param cert_len length of cert_buf + * @param out_handle receives opaque context handle (free via nac_ctx_free) + * @param out_request_buf receives request bytes (caller must free()) + * @param out_request_len receives length of request bytes + * @param out_err_buf receives error message on failure (caller must free()) + * @return 0 on success, non-zero on failure + */ +int nac_ctx_init( + const uint8_t *cert_buf, + size_t cert_len, + void **out_handle, + uint8_t **out_request_buf, + size_t *out_request_len, + char **out_err_buf +) { + @autoreleasepool { + if (out_handle) *out_handle = NULL; + if (out_request_buf) *out_request_buf = NULL; + if (out_request_len) *out_request_len = 0; + if (out_err_buf) *out_err_buf = NULL; + + void *handle = dlopen("/System/Library/PrivateFrameworks/AppleAccount.framework/AppleAccount", RTLD_NOW); + if (!handle) { + if (out_err_buf) *out_err_buf = strdup("Failed to load AppleAccount.framework"); + return 1; + } + + Class ctxClass = NSClassFromString(@"AAAbsintheContext"); + if (!ctxClass) { + if (out_err_buf) *out_err_buf = strdup("AAAbsintheContext class not found"); + return 4; + } + + NSData *certData = [NSData dataWithBytes:cert_buf length:cert_len]; + + NACSelectors sels = {0}; + NSError *selErr = nil; + int discoverResult = discover_nac_selectors(ctxClass, certData, &sels, &selErr); + if (discoverResult != 0) { + if (out_err_buf) { + const char *msg = selErr ? [[selErr localizedDescription] UTF8String] : "selector discovery failed"; + *out_err_buf = strdup(msg ? msg : "selector discovery failed"); + } + return discoverResult; + } + + id ctx = [[ctxClass alloc] init]; + NSError *nacError = nil; + NSData *requestBytes = ((id(*)(id, SEL, id, NSError **))objc_msgSend)( + ctx, sels.initSel, certData, &nacError); + + if (!requestBytes) { + if (out_err_buf) { + const char *msg = nacError ? [[nacError localizedDescription] UTF8String] : "NACInit returned nil"; + *out_err_buf = strdup(msg ? msg : "NACInit returned nil"); + } + return 5; + } + + NacContext *nacCtx = (NacContext *)malloc(sizeof(NacContext)); + if (!nacCtx) { + if (out_err_buf) *out_err_buf = strdup("Failed to allocate NacContext"); + return 40; + } + // Transfer ownership of ctx out of ARC so the context survives until nac_ctx_free. + nacCtx->ctx = (void *)CFBridgingRetain(ctx); + nacCtx->initSel = sels.initSel; + nacCtx->keyEstabSel = sels.keyEstabSel; + nacCtx->signSel = sels.signSel; + + NSUInteger reqLen = [requestBytes length]; + uint8_t *reqBuf = (uint8_t *)malloc(reqLen); + if (!reqBuf) { + CFBridgingRelease(nacCtx->ctx); + free(nacCtx); + if (out_err_buf) *out_err_buf = strdup("Failed to allocate request buffer"); + return 41; + } + memcpy(reqBuf, [requestBytes bytes], reqLen); + + *out_handle = (void *)nacCtx; + *out_request_buf = reqBuf; + *out_request_len = reqLen; + return 0; + } +} + +/** + * Step 2: NACKeyEstablishment. + * Feeds Apple's session-info response into the AAAbsintheContext. + */ +int nac_ctx_key_establishment( + void *handle, + const uint8_t *session_info_buf, + size_t session_info_len, + char **out_err_buf +) { + @autoreleasepool { + if (out_err_buf) *out_err_buf = NULL; + if (!handle) { + if (out_err_buf) *out_err_buf = strdup("NULL NAC context handle"); + return 50; + } + NacContext *nacCtx = (NacContext *)handle; + id ctx = (__bridge id)(nacCtx->ctx); + NSData *sessionInfo = [NSData dataWithBytes:session_info_buf length:session_info_len]; + + NSError *nacError = nil; + BOOL keyResult = ((BOOL(*)(id, SEL, id, NSError **))objc_msgSend)( + ctx, nacCtx->keyEstabSel, sessionInfo, &nacError); + + if (!keyResult) { + if (out_err_buf) { + const char *msg = nacError ? [[nacError localizedDescription] UTF8String] : "NACKeyEstablishment failed"; + *out_err_buf = strdup(msg ? msg : "NACKeyEstablishment failed"); + } + return 10; + } + return 0; + } +} + +/** + * Step 3: NACSign. + * Produces the final validation data bytes from the established context. + */ +int nac_ctx_sign( + void *handle, + uint8_t **out_buf, + size_t *out_len, + char **out_err_buf +) { + @autoreleasepool { + if (out_buf) *out_buf = NULL; + if (out_len) *out_len = 0; + if (out_err_buf) *out_err_buf = NULL; + + if (!handle) { + if (out_err_buf) *out_err_buf = strdup("NULL NAC context handle"); + return 50; + } + NacContext *nacCtx = (NacContext *)handle; + id ctx = (__bridge id)(nacCtx->ctx); + + NSError *nacError = nil; + NSData *validationData = ((id(*)(id, SEL, id, NSError **))objc_msgSend)( + ctx, nacCtx->signSel, nil, &nacError); + + if (!validationData || ![validationData isKindOfClass:[NSData class]]) { + if (out_err_buf) { + const char *msg = nacError ? [[nacError localizedDescription] UTF8String] : "NACSign failed or returned non-data"; + *out_err_buf = strdup(msg ? msg : "NACSign failed or returned non-data"); + } + return 11; + } + + NSUInteger len = [validationData length]; + uint8_t *buf = (uint8_t *)malloc(len); + if (!buf) { + if (out_err_buf) *out_err_buf = strdup("Failed to allocate validation buffer"); + return 42; + } + memcpy(buf, [validationData bytes], len); + *out_buf = buf; + *out_len = len; + return 0; + } +} + +/** + * Release the NAC context handle. Safe to call with NULL. + */ +void nac_ctx_free(void *handle) { + if (!handle) return; + NacContext *nacCtx = (NacContext *)handle; + if (nacCtx->ctx) { + // CFBridgingRelease converts back to an ARC-managed reference; the + // temporary then goes out of scope and the AAAbsintheContext dealloc + // fires. The cast-to-void silences the unused-value warning. + (void)CFBridgingRelease(nacCtx->ctx); + nacCtx->ctx = NULL; + } + free(nacCtx); +} + +// ---- CLI entry point (excluded when building as a library) ---- + +#ifndef NAC_NO_MAIN +int main(int argc, const char *argv[]) { + @autoreleasepool { + BOOL outputBase64 = NO; + NSString *outputPath = nil; + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--base64") == 0 || strcmp(argv[i], "-b") == 0) { + outputBase64 = YES; + } else if (strcmp(argv[i], "-o") == 0 && i + 1 < argc) { + outputPath = [NSString stringWithUTF8String:argv[++i]]; + } else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { + fprintf(stderr, "Usage: %s [--base64|-b] [-o output_file]\n", argv[0]); + fprintf(stderr, " --base64 Output as base64 string (default: raw bytes)\n"); + fprintf(stderr, " -o FILE Write to file (default: stdout)\n"); + return 0; + } + } + + NSData *validationData = nil; + NSError *error = nil; + + fprintf(stderr, "Generating APNs validation data...\n"); + int result = generate_validation_data(&validationData, &error); + + if (result != 0) { + fprintf(stderr, "ERROR: %s\n", [[error localizedDescription] UTF8String]); + return result; + } + + fprintf(stderr, "Success: %lu bytes of validation data\n", (unsigned long)[validationData length]); + + if (outputPath) { + if (outputBase64) { + NSString *b64 = [validationData base64EncodedStringWithOptions:0]; + [b64 writeToFile:outputPath atomically:YES encoding:NSUTF8StringEncoding error:nil]; + } else { + [validationData writeToFile:outputPath atomically:YES]; + } + fprintf(stderr, "Written to %s\n", [outputPath UTF8String]); + } else { + if (outputBase64) { + NSString *b64 = [validationData base64EncodedStringWithOptions:0]; + printf("%s\n", [b64 UTF8String]); + } else { + fwrite([validationData bytes], 1, [validationData length], stdout); + } + } + + return 0; + } +} + +#endif // NAC_NO_MAIN diff --git a/no-heif.go b/no-heif.go deleted file mode 100644 index 985f147e..00000000 --- a/no-heif.go +++ /dev/null @@ -1,27 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build !libheif - -package main - -import "fmt" - -const CanConvertHEIF = true - -func ConvertHEIF(_ []byte) ([]byte, error) { - return nil, fmt.Errorf("mautrix-imessage was compiled without libheif") -} diff --git a/no-mac.go b/no-mac.go deleted file mode 100644 index cf2898a1..00000000 --- a/no-mac.go +++ /dev/null @@ -1,29 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build !darwin || ios - -package main - -import ( - "fmt" - "os" -) - -func checkMacPermissions() { - fmt.Println("--check-permissions is only supported on macOS") - os.Exit(2) -} diff --git a/pkg/connector/audioconvert.go b/pkg/connector/audioconvert.go new file mode 100644 index 00000000..63e1d45a --- /dev/null +++ b/pkg/connector/audioconvert.go @@ -0,0 +1,631 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +// Bidirectional OGG Opus ↔ CAF Opus remuxer for iMessage voice messages. +// +// iMessage uses Opus audio in Apple's CAF (Core Audio Format) container. +// Beeper and most Matrix clients send voice recordings as OGG Opus. +// Since both use the same Opus codec, we just extract the compressed packets +// from one container and rewrap them in the other — no transcoding needed. + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + "path/filepath" + "strings" +) + +// convertAudioForIMessage remuxes OGG Opus audio to CAF Opus for native +// iMessage voice message playback. Non-OGG formats are returned unchanged +// since iOS can play most other audio formats directly. +func convertAudioForIMessage(data []byte, mimeType, fileName string) ([]byte, string, string) { + if mimeType != "audio/ogg" && !strings.HasPrefix(mimeType, "audio/ogg;") { + return data, mimeType, fileName + } + + info, err := parseOGGOpus(data) + if err != nil { + return data, mimeType, fileName + } + + cafData, err := writeCAFOpus(info) + if err != nil { + return data, mimeType, fileName + } + + newName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".caf" + return cafData, "audio/x-caf", newName +} + +// convertAudioForMatrix remuxes CAF Opus audio to OGG Opus for Matrix clients. +// Returns the converted data, new MIME type, new filename, and duration in milliseconds. +// Non-CAF or non-Opus formats are returned unchanged with duration 0. +func convertAudioForMatrix(data []byte, mimeType, fileName string) ([]byte, string, string, int) { + if mimeType != "audio/x-caf" && !strings.HasSuffix(strings.ToLower(fileName), ".caf") { + return data, mimeType, fileName, 0 + } + + info, err := parseCAFOpus(data) + if err != nil { + return data, mimeType, fileName, 0 + } + + oggData, err := writeOGGOpus(info) + if err != nil { + return data, mimeType, fileName, 0 + } + + durationMs := int(info.GranulePos * 1000 / 48000) + newName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".ogg" + return oggData, "audio/ogg", newName, durationMs +} + +// ============================================================================ +// CAF Opus parser +// ============================================================================ + +func parseCAFOpus(data []byte) (*oggOpusInfo, error) { + if len(data) < 8 { + return nil, fmt.Errorf("CAF too short") + } + if string(data[:4]) != "caff" { + return nil, fmt.Errorf("not a CAF file") + } + + r := bytes.NewReader(data[8:]) // skip file header + + var opusHead []byte + var channels int + var preSkip int + var bytesPerPacket int + var framesPerPacket int + var validFrames int64 + var primingFrames int32 + var numPackets int64 + var vlqData []byte + var audioData []byte + + // Read chunks + for { + var chunkType [4]byte + if _, err := io.ReadFull(r, chunkType[:]); err != nil { + break + } + var chunkSize int64 + if err := binary.Read(r, binary.BigEndian, &chunkSize); err != nil { + break + } + + switch string(chunkType[:]) { + case "desc": + if chunkSize < 32 { + return nil, fmt.Errorf("CAF desc chunk too small") + } + var desc [32]byte + if _, err := io.ReadFull(r, desc[:]); err != nil { + return nil, err + } + formatID := string(desc[8:12]) + if formatID != "opus" { + return nil, fmt.Errorf("CAF format is %q, not opus", formatID) + } + bytesPerPacket = int(binary.BigEndian.Uint32(desc[16:20])) + framesPerPacket = int(binary.BigEndian.Uint32(desc[20:24])) + channels = int(binary.BigEndian.Uint32(desc[24:28])) + if chunkSize > 32 { + io.CopyN(io.Discard, r, chunkSize-32) + } + + case "magc": + cookie := make([]byte, chunkSize) + if _, err := io.ReadFull(r, cookie); err != nil { + return nil, err + } + if len(cookie) >= 12 && string(cookie[:8]) == "OpusHead" { + opusHead = cookie + preSkip = int(binary.LittleEndian.Uint16(cookie[10:12])) + if cookie[9] > 0 { + channels = int(cookie[9]) + } + } + + case "pakt": + if chunkSize < 24 { + return nil, fmt.Errorf("CAF pakt chunk too small") + } + var paktHdr [24]byte + if _, err := io.ReadFull(r, paktHdr[:]); err != nil { + return nil, err + } + numPackets = int64(binary.BigEndian.Uint64(paktHdr[0:8])) + validFrames = int64(binary.BigEndian.Uint64(paktHdr[8:16])) + primingFrames = int32(binary.BigEndian.Uint32(paktHdr[16:20])) + + remaining := chunkSize - 24 + vlqData = make([]byte, remaining) + if _, err := io.ReadFull(r, vlqData); err != nil { + return nil, err + } + + case "data": + if chunkSize == -1 { + // -1 means data extends to end of file + audioData, _ = io.ReadAll(r) + } else { + audioData = make([]byte, chunkSize) + if _, err := io.ReadFull(r, audioData); err != nil { + return nil, err + } + } + // Skip 4-byte edit count at the start of audio data + if len(audioData) >= 4 { + audioData = audioData[4:] + } + + default: + if chunkSize > 0 { + io.CopyN(io.Discard, r, chunkSize) + } + } + } + + if channels == 0 { + channels = 1 + } + + // Build OpusHead if not found in magic cookie + if opusHead == nil { + opusHead = buildOpusHead(channels, preSkip) + } + + // Decode packet sizes from the pakt chunk (deferred so desc fields are available). + // CAF packet table entries contain: + // - byte size (VLQ) if mBytesPerPacket == 0 + // - frame count (VLQ) if mFramesPerPacket == 0 + var packetSizes []int + if bytesPerPacket > 0 { + // CBR: all packets are the same size, packet table has no byte sizes + packetSizes = make([]int, numPackets) + for i := range packetSizes { + packetSizes[i] = bytesPerPacket + } + } else if vlqData != nil { + hasFrameSize := framesPerPacket == 0 + packetSizes = decodeCAFPacketSizes(vlqData, int(numPackets), hasFrameSize) + } else { + return nil, fmt.Errorf("no packet table in CAF") + } + + if framesPerPacket == 0 { + framesPerPacket = 960 // 20ms default + } + + // Split audio data into packets + var packets [][]byte + offset := 0 + for _, size := range packetSizes { + if offset+size > len(audioData) { + break + } + packets = append(packets, audioData[offset:offset+size]) + offset += size + } + + // Calculate granule position + granulePos := validFrames + int64(primingFrames) + if granulePos <= 0 { + granulePos = int64(len(packets)) * int64(framesPerPacket) + } + + return &oggOpusInfo{ + Channels: channels, + PreSkip: preSkip, + OpusHead: opusHead, + Packets: packets, + GranulePos: granulePos, + }, nil +} + +// decodeCAFPacketSizes decodes n packet byte-sizes from VLQ-encoded data. +// When hasFrameSize is true, each entry has two VLQs (byte size + frame count) +// and the frame count is skipped. +func decodeCAFPacketSizes(data []byte, n int, hasFrameSize bool) []int { + sizes := make([]int, 0, n) + pos := 0 + for i := 0; i < n && pos < len(data); i++ { + // Read byte size VLQ + val := 0 + for pos < len(data) { + b := data[pos] + pos++ + val = (val << 7) | int(b&0x7F) + if b&0x80 == 0 { + break + } + } + sizes = append(sizes, val) + // Skip frame count VLQ if present + if hasFrameSize { + for pos < len(data) { + b := data[pos] + pos++ + if b&0x80 == 0 { + break + } + } + } + } + return sizes +} + +// buildOpusHead creates a minimal OpusHead packet. +func buildOpusHead(channels, preSkip int) []byte { + head := make([]byte, 19) + copy(head[0:8], "OpusHead") + head[8] = 1 // version + head[9] = byte(channels) + binary.LittleEndian.PutUint16(head[10:12], uint16(preSkip)) + binary.LittleEndian.PutUint32(head[12:16], 48000) // input sample rate + // bytes 16-17: output gain = 0 + // byte 18: channel mapping = 0 + return head +} + +// ============================================================================ +// OGG Opus writer +// ============================================================================ + +// oggCRCTable is the CRC-32 lookup table for OGG (polynomial 0x04C11DB7, direct). +var oggCRCTable = func() *[256]uint32 { + var t [256]uint32 + for i := 0; i < 256; i++ { + r := uint32(i) << 24 + for j := 0; j < 8; j++ { + if r&0x80000000 != 0 { + r = (r << 1) ^ 0x04C11DB7 + } else { + r <<= 1 + } + } + t[i] = r + } + return &t +}() + +func oggCRC(data []byte) uint32 { + var crc uint32 + for _, b := range data { + crc = (crc << 8) ^ oggCRCTable[byte(crc>>24)^b] + } + return crc +} + +func writeOGGOpus(info *oggOpusInfo) ([]byte, error) { + var buf bytes.Buffer + serial := uint32(0x4F707573) // "Opus" + seq := uint32(0) + + // Page 1: OpusHead (BOS) + writeOGGPage(&buf, serial, seq, 0, 0x02, [][]byte{info.OpusHead}) + seq++ + + // Page 2: OpusTags + tags := buildOpusTags() + writeOGGPage(&buf, serial, seq, 0, 0x00, [][]byte{tags}) + seq++ + + // Audio pages: pack multiple packets per page (max ~48KB, max 255 segments) + const maxPagePayload = 48000 + const maxSegments = 255 + var pagePackets [][]byte + var pageSize int + var pageSegs int + var granule int64 + framesPerPacket := opusPacketFrames(info.Packets[0]) + + for i, pkt := range info.Packets { + pktSegs := len(pkt)/255 + 1 + if (pageSize+len(pkt) > maxPagePayload || pageSegs+pktSegs > maxSegments) && len(pagePackets) > 0 { + writeOGGPage(&buf, serial, seq, granule, 0x00, pagePackets) + seq++ + pagePackets = nil + pageSize = 0 + pageSegs = 0 + } + pagePackets = append(pagePackets, pkt) + pageSize += len(pkt) + pageSegs += pktSegs + granule = int64(info.PreSkip) + int64(i+1)*int64(framesPerPacket) + if granule > info.GranulePos { + granule = info.GranulePos + } + } + if len(pagePackets) > 0 { + writeOGGPage(&buf, serial, seq, info.GranulePos, 0x04, pagePackets) // EOS + } + + return buf.Bytes(), nil +} + +// writeOGGPage writes a single OGG page containing the given packets. +func writeOGGPage(buf *bytes.Buffer, serial, seq uint32, granule int64, flags byte, packets [][]byte) { + // Build segment table + var segTable []byte + for _, pkt := range packets { + remaining := len(pkt) + for remaining >= 255 { + segTable = append(segTable, 255) + remaining -= 255 + } + segTable = append(segTable, byte(remaining)) + } + + // Build page header (without CRC) + var hdr bytes.Buffer + hdr.WriteString("OggS") + hdr.WriteByte(0) // version + hdr.WriteByte(flags) // header type + binary.Write(&hdr, binary.LittleEndian, granule) + binary.Write(&hdr, binary.LittleEndian, serial) + binary.Write(&hdr, binary.LittleEndian, seq) + binary.Write(&hdr, binary.LittleEndian, uint32(0)) // CRC placeholder + hdr.WriteByte(byte(len(segTable))) + hdr.Write(segTable) + + // Compute CRC over header + payload + hdrBytes := hdr.Bytes() + // Set CRC field to 0 for computation (already 0) + var payload bytes.Buffer + for _, pkt := range packets { + payload.Write(pkt) + } + + crcData := append(hdrBytes, payload.Bytes()...) + checksum := oggCRC(crcData) + binary.LittleEndian.PutUint32(hdrBytes[22:26], checksum) + + buf.Write(hdrBytes) + buf.Write(payload.Bytes()) +} + +// buildOpusTags creates a minimal OpusTags packet. +func buildOpusTags() []byte { + var tags bytes.Buffer + tags.WriteString("OpusTags") + vendor := "mautrix-imessage" + binary.Write(&tags, binary.LittleEndian, uint32(len(vendor))) + tags.WriteString(vendor) + binary.Write(&tags, binary.LittleEndian, uint32(0)) // no comments + return tags.Bytes() +} + +// ============================================================================ +// OGG Opus parser +// ============================================================================ + +type oggOpusInfo struct { + Channels int + PreSkip int + OpusHead []byte // raw OpusHead packet (used as CAF magic cookie) + Packets [][]byte // Opus audio packets (excluding header packets) + GranulePos int64 // last page granule = total PCM frames at 48kHz +} + +func parseOGGOpus(data []byte) (*oggOpusInfo, error) { + packets, granule, err := readOGGPackets(data) + if err != nil { + return nil, err + } + if len(packets) < 3 { + return nil, fmt.Errorf("OGG stream too short: %d packets", len(packets)) + } + + head := packets[0] + if len(head) < 19 || string(head[:8]) != "OpusHead" { + return nil, fmt.Errorf("not an OGG Opus stream") + } + + return &oggOpusInfo{ + Channels: int(head[9]), + PreSkip: int(binary.LittleEndian.Uint16(head[10:12])), + OpusHead: head, + Packets: packets[2:], // skip OpusHead + OpusTags + GranulePos: granule, + }, nil +} + +// readOGGPackets reads all OGG pages and assembles complete packets. +// Returns the packets and the granule position from the last page. +func readOGGPackets(data []byte) ([][]byte, int64, error) { + r := bytes.NewReader(data) + var packets [][]byte + var current []byte + var lastGranule int64 + + for { + // Sync: read "OggS" magic + var magic [4]byte + if _, err := io.ReadFull(r, magic[:]); err != nil { + break + } + if string(magic[:]) != "OggS" { + return nil, 0, fmt.Errorf("invalid OGG page sync") + } + + // Page header: version(1) + type(1) + granule(8) + serial(4) + seq(4) + crc(4) = 22 bytes + var hdr [22]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return nil, 0, fmt.Errorf("truncated OGG page header: %w", err) + } + granule := int64(binary.LittleEndian.Uint64(hdr[2:10])) + if granule > 0 { + lastGranule = granule + } + + // Segment count + table + var nSeg [1]byte + if _, err := io.ReadFull(r, nSeg[:]); err != nil { + return nil, 0, err + } + segTable := make([]byte, nSeg[0]) + if _, err := io.ReadFull(r, segTable); err != nil { + return nil, 0, err + } + + // Read segments, assemble packets (segment < 255 = packet boundary) + for _, segSize := range segTable { + seg := make([]byte, segSize) + if _, err := io.ReadFull(r, seg); err != nil { + return nil, 0, err + } + current = append(current, seg...) + if segSize < 255 { + packets = append(packets, current) + current = nil + } + } + } + + if current != nil { + packets = append(packets, current) + } + return packets, lastGranule, nil +} + +// ============================================================================ +// CAF Opus writer +// ============================================================================ + +func writeCAFOpus(info *oggOpusInfo) ([]byte, error) { + if len(info.Packets) == 0 { + return nil, fmt.Errorf("no audio packets") + } + + framesPerPacket := opusPacketFrames(info.Packets[0]) + + validFrames := info.GranulePos - int64(info.PreSkip) + if validFrames <= 0 { + validFrames = int64(len(info.Packets)) * int64(framesPerPacket) + } + + var buf bytes.Buffer + + // -- File header -- + buf.WriteString("caff") + binary.Write(&buf, binary.BigEndian, uint16(1)) // version + binary.Write(&buf, binary.BigEndian, uint16(0)) // flags + + // -- Audio Description chunk ('desc') -- + var desc bytes.Buffer + binary.Write(&desc, binary.BigEndian, math.Float64bits(48000.0)) // sample rate + desc.WriteString("opus") // format ID + binary.Write(&desc, binary.BigEndian, uint32(0)) // format flags + binary.Write(&desc, binary.BigEndian, uint32(0)) // bytes per packet (VBR=0) + binary.Write(&desc, binary.BigEndian, uint32(framesPerPacket)) // frames per packet + binary.Write(&desc, binary.BigEndian, uint32(info.Channels)) // channels per frame + binary.Write(&desc, binary.BigEndian, uint32(0)) // bits per channel (compressed=0) + cafWriteChunk(&buf, "desc", desc.Bytes()) + + // -- Magic Cookie chunk ('magc') = OpusHead packet -- + cafWriteChunk(&buf, "magc", info.OpusHead) + + // -- Packet Table chunk ('pakt') -- + var pakt bytes.Buffer + binary.Write(&pakt, binary.BigEndian, int64(len(info.Packets))) // number of packets + binary.Write(&pakt, binary.BigEndian, validFrames) // valid frames + binary.Write(&pakt, binary.BigEndian, int32(info.PreSkip)) // priming frames + binary.Write(&pakt, binary.BigEndian, int32(0)) // remainder frames + for _, pkt := range info.Packets { + pakt.Write(cafVLQ(int64(len(pkt)))) + } + cafWriteChunk(&buf, "pakt", pakt.Bytes()) + + // -- Audio Data chunk ('data') -- + var audioData bytes.Buffer + binary.Write(&audioData, binary.BigEndian, uint32(0)) // edit count + for _, pkt := range info.Packets { + audioData.Write(pkt) + } + cafWriteChunk(&buf, "data", audioData.Bytes()) + + return buf.Bytes(), nil +} + +// cafWriteChunk writes a CAF chunk: type (4 bytes) + size (int64) + data. +func cafWriteChunk(w *bytes.Buffer, chunkType string, data []byte) { + w.WriteString(chunkType) + binary.Write(w, binary.BigEndian, int64(len(data))) + w.Write(data) +} + +// cafVLQ encodes an integer as a CAF variable-length quantity (MIDI-style VLQ). +func cafVLQ(value int64) []byte { + if value <= 0 { + return []byte{0} + } + var tmp [10]byte + i := len(tmp) - 1 + tmp[i] = byte(value & 0x7F) + value >>= 7 + for value > 0 { + i-- + tmp[i] = byte(value&0x7F) | 0x80 + value >>= 7 + } + return tmp[i:] +} + +// ============================================================================ +// Opus TOC parser +// ============================================================================ + +// opusPacketFrames returns the number of PCM frames (at 48kHz) in an Opus packet +// by parsing the TOC byte and frame count code per RFC 6716. +func opusPacketFrames(packet []byte) int { + if len(packet) == 0 { + return 960 // default 20ms at 48kHz + } + + toc := packet[0] + config := int(toc >> 3) + + // Frame duration in 48kHz samples based on TOC config + var samplesPerFrame int + switch { + case config < 12: + // SILK-only: configs 0-11 cycle through {10,20,40,60}ms + samplesPerFrame = [4]int{480, 960, 1920, 2880}[config%4] + case config < 16: + // Hybrid: configs 12-15 cycle through {10,20}ms + samplesPerFrame = [2]int{480, 960}[config%2] + default: + // CELT-only: configs 16-31 cycle through {2.5,5,10,20}ms + samplesPerFrame = [4]int{120, 240, 480, 960}[(config-16)%4] + } + + // Frame count from code field (bits 0-1 of TOC) + switch toc & 0x3 { + case 0: + return samplesPerFrame + case 1, 2: + return samplesPerFrame * 2 + case 3: + if len(packet) >= 2 { + n := int(packet[1] & 0x3F) + if n > 0 { + return samplesPerFrame * n + } + } + } + return samplesPerFrame +} diff --git a/pkg/connector/audioconvert_test.go b/pkg/connector/audioconvert_test.go new file mode 100644 index 00000000..23ce8605 --- /dev/null +++ b/pkg/connector/audioconvert_test.go @@ -0,0 +1,419 @@ +package connector + +import ( + "bytes" + "encoding/binary" + "testing" +) + +// TestCafVLQ tests the variable-length quantity encoder used in CAF packet tables. +func TestCafVLQ(t *testing.T) { + tests := []struct { + val int64 + want []byte + }{ + {0, []byte{0}}, + {1, []byte{1}}, + {127, []byte{0x7F}}, + {128, []byte{0x81, 0x00}}, + {255, []byte{0x81, 0x7F}}, + {16384, []byte{0x81, 0x80, 0x00}}, + } + for _, tt := range tests { + got := cafVLQ(tt.val) + if !bytes.Equal(got, tt.want) { + t.Errorf("cafVLQ(%d) = %v, want %v", tt.val, got, tt.want) + } + } +} + +// TestCafVLQ_Negative tests that negative values encode as 0. +func TestCafVLQ_Negative(t *testing.T) { + got := cafVLQ(-1) + if !bytes.Equal(got, []byte{0}) { + t.Errorf("cafVLQ(-1) = %v, want [0]", got) + } +} + +// TestOpusPacketFrames tests Opus TOC byte parsing per RFC 6716. +func TestOpusPacketFrames(t *testing.T) { + tests := []struct { + name string + packet []byte + want int + }{ + {"empty packet", nil, 960}, + // Config 1 (SILK 20ms), code 0 → 960 samples + {"SILK 20ms code0", []byte{0x08}, 960}, + // Config 0 (SILK 10ms), code 0 → 480 samples + {"SILK 10ms code0", []byte{0x00}, 480}, + // Config 2 (SILK 40ms), code 0 → 1920 samples + {"SILK 40ms code0", []byte{0x10}, 1920}, + // Config 3 (SILK 60ms), code 0 → 2880 samples + {"SILK 60ms code0", []byte{0x18}, 2880}, + // Config 12 (Hybrid 10ms), code 0 → 480 samples + {"Hybrid 10ms code0", []byte{0x60}, 480}, + // Config 13 (Hybrid 20ms), code 0 → 960 samples + {"Hybrid 20ms code0", []byte{0x68}, 960}, + // Config 16 (CELT 2.5ms), code 0 → 120 samples + {"CELT 2.5ms code0", []byte{0x80}, 120}, + // Config 17 (CELT 5ms), code 0 → 240 samples + {"CELT 5ms code0", []byte{0x88}, 240}, + // Config 18 (CELT 10ms), code 0 → 480 samples + {"CELT 10ms code0", []byte{0x90}, 480}, + // Config 19 (CELT 20ms), code 0 → 960 samples + {"CELT 20ms code0", []byte{0x98}, 960}, + // Code 1: two equal-size frames → double + {"SILK 20ms code1", []byte{0x09}, 1920}, + // Code 2: two different-size frames → double + {"SILK 20ms code2", []byte{0x0A}, 1920}, + // Code 3: arbitrary number (5 frames), packet[1] = 5 + {"SILK 20ms code3 x5", []byte{0x0B, 0x05}, 4800}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := opusPacketFrames(tt.packet) + if got != tt.want { + t.Errorf("opusPacketFrames = %d, want %d", got, tt.want) + } + }) + } +} + +// TestOggCRC tests the OGG CRC-32 implementation against known values. +func TestOggCRC(t *testing.T) { + // Empty data should be 0 + if got := oggCRC(nil); got != 0 { + t.Errorf("oggCRC(nil) = 0x%08x, want 0", got) + } + + // Verify CRC is deterministic + data := []byte("OggS test data") + crc1 := oggCRC(data) + crc2 := oggCRC(data) + if crc1 != crc2 { + t.Errorf("oggCRC not deterministic: %x != %x", crc1, crc2) + } + + // Different data should give different CRC + data2 := []byte("OggS test datb") + crc3 := oggCRC(data2) + if crc1 == crc3 { + t.Error("different data gave same CRC") + } +} + +// TestBuildOpusHead tests the OpusHead packet builder. +func TestBuildOpusHead(t *testing.T) { + head := buildOpusHead(2, 312) + if len(head) != 19 { + t.Fatalf("OpusHead length = %d, want 19", len(head)) + } + if string(head[0:8]) != "OpusHead" { + t.Errorf("magic = %q, want %q", string(head[0:8]), "OpusHead") + } + if head[8] != 1 { + t.Errorf("version = %d, want 1", head[8]) + } + if head[9] != 2 { + t.Errorf("channels = %d, want 2", head[9]) + } + preSkip := binary.LittleEndian.Uint16(head[10:12]) + if preSkip != 312 { + t.Errorf("preSkip = %d, want 312", preSkip) + } + sampleRate := binary.LittleEndian.Uint32(head[12:16]) + if sampleRate != 48000 { + t.Errorf("sampleRate = %d, want 48000", sampleRate) + } +} + +// TestDecodeCAFPacketSizes tests VLQ decoding of CAF packet table entries. +func TestDecodeCAFPacketSizes(t *testing.T) { + // Encode a few known sizes + var data []byte + data = append(data, cafVLQ(100)...) + data = append(data, cafVLQ(200)...) + data = append(data, cafVLQ(300)...) + + sizes := decodeCAFPacketSizes(data, 3, false) + want := []int{100, 200, 300} + if len(sizes) != len(want) { + t.Fatalf("got %d sizes, want %d", len(sizes), len(want)) + } + for i := range want { + if sizes[i] != want[i] { + t.Errorf("sizes[%d] = %d, want %d", i, sizes[i], want[i]) + } + } +} + +// TestDecodeCAFPacketSizes_WithFrameSize tests VLQ decoding when frame sizes are present. +func TestDecodeCAFPacketSizes_WithFrameSize(t *testing.T) { + var data []byte + // Each entry: byte size VLQ + frame count VLQ + data = append(data, cafVLQ(100)...) + data = append(data, cafVLQ(960)...) // frame count (skipped) + data = append(data, cafVLQ(200)...) + data = append(data, cafVLQ(960)...) + + sizes := decodeCAFPacketSizes(data, 2, true) + if len(sizes) != 2 { + t.Fatalf("got %d sizes, want 2", len(sizes)) + } + if sizes[0] != 100 || sizes[1] != 200 { + t.Errorf("sizes = %v, want [100, 200]", sizes) + } +} + +// TestBuildOpusTags tests the OpusTags packet builder. +func TestBuildOpusTags(t *testing.T) { + tags := buildOpusTags() + if string(tags[:8]) != "OpusTags" { + t.Errorf("magic = %q, want %q", string(tags[:8]), "OpusTags") + } + vendorLen := binary.LittleEndian.Uint32(tags[8:12]) + vendor := string(tags[12 : 12+vendorLen]) + if vendor != "mautrix-imessage" { + t.Errorf("vendor = %q, want %q", vendor, "mautrix-imessage") + } +} + +// TestConvertAudioForIMessage_NonOGG tests that non-OGG audio is returned unchanged. +func TestConvertAudioForIMessage_NonOGG(t *testing.T) { + data := []byte("not-opus-data") + outData, outMime, outName := convertAudioForIMessage(data, "audio/mp4", "test.m4a") + if !bytes.Equal(outData, data) { + t.Error("non-OGG data should be returned unchanged") + } + if outMime != "audio/mp4" { + t.Errorf("mime = %q, want %q", outMime, "audio/mp4") + } + if outName != "test.m4a" { + t.Errorf("name = %q, want %q", outName, "test.m4a") + } +} + +// TestConvertAudioForMatrix_NonCAF tests that non-CAF audio is returned unchanged. +func TestConvertAudioForMatrix_NonCAF(t *testing.T) { + data := []byte("not-caf-data") + outData, outMime, outName, dur := convertAudioForMatrix(data, "audio/ogg", "test.ogg") + if !bytes.Equal(outData, data) { + t.Error("non-CAF data should be returned unchanged") + } + if outMime != "audio/ogg" { + t.Errorf("mime = %q, want %q", outMime, "audio/ogg") + } + if outName != "test.ogg" { + t.Errorf("name = %q, want %q", outName, "test.ogg") + } + if dur != 0 { + t.Errorf("duration = %d, want 0", dur) + } +} + +// TestConvertAudioForIMessage_InvalidOGG tests that invalid OGG data is returned unchanged. +func TestConvertAudioForIMessage_InvalidOGG(t *testing.T) { + data := []byte("not-real-ogg-data") + outData, outMime, outName := convertAudioForIMessage(data, "audio/ogg", "test.ogg") + if !bytes.Equal(outData, data) { + t.Error("invalid OGG data should be returned unchanged") + } + if outMime != "audio/ogg" { + t.Errorf("mime = %q, want %q", outMime, "audio/ogg") + } + if outName != "test.ogg" { + t.Errorf("name = %q, want %q", outName, "test.ogg") + } +} + +// TestConvertAudioForMatrix_InvalidCAF tests that invalid CAF data is returned unchanged. +func TestConvertAudioForMatrix_InvalidCAF(t *testing.T) { + data := []byte("not-real-caf-data") + outData, outMime, outName, dur := convertAudioForMatrix(data, "audio/x-caf", "test.caf") + if !bytes.Equal(outData, data) { + t.Error("invalid CAF data should be returned unchanged") + } + if outMime != "audio/x-caf" { + t.Errorf("mime = %q, want %q", outMime, "audio/x-caf") + } + if outName != "test.caf" { + t.Errorf("name = %q, want %q", outName, "test.caf") + } + if dur != 0 { + t.Errorf("duration = %d, want 0", dur) + } +} + +// buildMinimalOGGOpus creates a minimal valid OGG Opus stream for testing. +func buildMinimalOGGOpus() []byte { + info := &oggOpusInfo{ + Channels: 1, + PreSkip: 312, + OpusHead: buildOpusHead(1, 312), + GranulePos: 48000, // 1 second + Packets: [][]byte{}, + } + + // Create some fake Opus packets (config 19 = CELT 20ms, code 0 = 1 frame = 960 samples) + for i := 0; i < 50; i++ { + // TOC byte 0x98 = config 19 (CELT 20ms), code 0 + pkt := make([]byte, 40) + pkt[0] = 0x98 + info.Packets = append(info.Packets, pkt) + } + + data, err := writeOGGOpus(info) + if err != nil { + panic(err) + } + return data +} + +// TestOGGtoCAFRoundTrip tests OGG→CAF→OGG conversion preserves audio packets. +func TestOGGtoCAFRoundTrip(t *testing.T) { + oggData := buildMinimalOGGOpus() + + // Parse OGG + info1, err := parseOGGOpus(oggData) + if err != nil { + t.Fatalf("parseOGGOpus error: %v", err) + } + + // Convert to CAF + cafData, err := writeCAFOpus(info1) + if err != nil { + t.Fatalf("writeCAFOpus error: %v", err) + } + if !bytes.HasPrefix(cafData, []byte("caff")) { + t.Error("CAF data should start with 'caff'") + } + + // Parse CAF back + info2, err := parseCAFOpus(cafData) + if err != nil { + t.Fatalf("parseCAFOpus error: %v", err) + } + + if info2.Channels != info1.Channels { + t.Errorf("channels: %d != %d", info2.Channels, info1.Channels) + } + if len(info2.Packets) != len(info1.Packets) { + t.Fatalf("packet count: %d != %d", len(info2.Packets), len(info1.Packets)) + } + for i := range info1.Packets { + if !bytes.Equal(info1.Packets[i], info2.Packets[i]) { + t.Errorf("packet %d differs", i) + break + } + } +} + +// TestConvertAudioForIMessage_ValidOGG tests full OGG→CAF conversion. +func TestConvertAudioForIMessage_ValidOGG(t *testing.T) { + oggData := buildMinimalOGGOpus() + cafData, mime, name := convertAudioForIMessage(oggData, "audio/ogg", "voice.ogg") + if mime != "audio/x-caf" { + t.Errorf("mime = %q, want %q", mime, "audio/x-caf") + } + if name != "voice.caf" { + t.Errorf("name = %q, want %q", name, "voice.caf") + } + if !bytes.HasPrefix(cafData, []byte("caff")) { + t.Error("output should be CAF data") + } +} + +// TestConvertAudioForMatrix_ValidCAF tests full CAF→OGG conversion. +func TestConvertAudioForMatrix_ValidCAF(t *testing.T) { + // First build a valid CAF from OGG + oggData := buildMinimalOGGOpus() + info, _ := parseOGGOpus(oggData) + cafData, _ := writeCAFOpus(info) + + outData, mime, name, dur := convertAudioForMatrix(cafData, "audio/x-caf", "voice.caf") + if mime != "audio/ogg" { + t.Errorf("mime = %q, want %q", mime, "audio/ogg") + } + if name != "voice.ogg" { + t.Errorf("name = %q, want %q", name, "voice.ogg") + } + if dur <= 0 { + t.Errorf("duration = %d, want > 0", dur) + } + if !bytes.HasPrefix(outData, []byte("OggS")) { + t.Error("output should be OGG data") + } +} + +// TestParseCAFOpus_TooShort tests that very short input is rejected. +func TestParseCAFOpus_TooShort(t *testing.T) { + _, err := parseCAFOpus([]byte("caff")) + if err == nil { + t.Error("expected error for too-short CAF data") + } +} + +// TestParseCAFOpus_NotCAF tests that non-CAF input is rejected. +func TestParseCAFOpus_NotCAF(t *testing.T) { + _, err := parseCAFOpus([]byte("not-a-caf-file-at-all")) + if err == nil { + t.Error("expected error for non-CAF data") + } +} + +// TestParseOGGOpus_NotOGG tests that non-OGG input is rejected. +func TestParseOGGOpus_NotOGG(t *testing.T) { + _, err := parseOGGOpus([]byte("not-ogg-data")) + if err == nil { + t.Error("expected error for non-OGG data") + } +} + +// TestParseOGGOpus_NotOpus tests that OGG with non-Opus content is rejected. +func TestParseOGGOpus_NotOpus(t *testing.T) { + // Build a minimal OGG page with non-Opus content + var buf bytes.Buffer + buf.WriteString("OggS") + buf.WriteByte(0) // version + buf.WriteByte(2) // BOS + binary.Write(&buf, binary.LittleEndian, int64(0)) // granule + binary.Write(&buf, binary.LittleEndian, uint32(1)) // serial + binary.Write(&buf, binary.LittleEndian, uint32(0)) // seq + binary.Write(&buf, binary.LittleEndian, uint32(0)) // crc + payload := []byte("VorbisHead\x00\x01\x00\x00\x00\x00\x00\x00\x00") + buf.WriteByte(byte(1)) // 1 segment + buf.WriteByte(byte(len(payload))) // segment size + buf.Write(payload) + + _, err := parseOGGOpus(buf.Bytes()) + if err == nil { + t.Error("expected error for non-Opus OGG data") + } +} + +// TestWriteCAFOpus_NoPackets tests that writing with empty packets fails. +func TestWriteCAFOpus_NoPackets(t *testing.T) { + info := &oggOpusInfo{ + Channels: 1, + PreSkip: 0, + OpusHead: buildOpusHead(1, 0), + Packets: nil, + GranulePos: 0, + } + _, err := writeCAFOpus(info) + if err == nil { + t.Error("expected error for no packets") + } +} + +// TestConvertAudioForMatrix_FileExtension tests CAF detection by filename. +func TestConvertAudioForMatrix_FileExtension(t *testing.T) { + // Should attempt CAF parsing based on filename even if mime doesn't match + data := []byte("not-real-caf") + _, outMime, _, _ := convertAudioForMatrix(data, "application/octet-stream", "voice.caf") + // Since the data isn't valid CAF, it should return unchanged + if outMime != "application/octet-stream" { + t.Errorf("mime = %q, want %q", outMime, "application/octet-stream") + } +} diff --git a/pkg/connector/bridgeadapter.go b/pkg/connector/bridgeadapter.go new file mode 100644 index 00000000..1453d95e --- /dev/null +++ b/pkg/connector/bridgeadapter.go @@ -0,0 +1,65 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "io" + "time" + + "github.com/rs/zerolog" + log "maunium.net/go/maulogger/v2" + + "github.com/lrhodin/imessage/imessage" + "github.com/lrhodin/imessage/ipc" +) + +// bridgeAdapter satisfies the legacy imessage.Bridge interface so the +// existing mac connector code can be used unmodified. +type bridgeAdapter struct { + maulog log.Logger + zlog *zerolog.Logger + ipcProc *ipc.Processor + config *imessage.PlatformConfig +} + +func newBridgeAdapter(zlog *zerolog.Logger) *bridgeAdapter { + maulog := log.Create() + // Create a no-op IPC processor (mac connector uses it only for debug tracking) + ipcProc := ipc.NewCustomProcessor(io.Discard, &emptyReader{}, maulog, false) + return &bridgeAdapter{ + maulog: maulog, + zlog: zlog, + ipcProc: ipcProc, + config: &imessage.PlatformConfig{ + Platform: "mac", + }, + } +} + +type emptyReader struct{} + +func (e *emptyReader) Read(p []byte) (n int, err error) { + // Block forever - the IPC processor won't actually read from this + select {} +} + +func (ba *bridgeAdapter) GetIPC() *ipc.Processor { return ba.ipcProc } +func (ba *bridgeAdapter) GetLog() log.Logger { return ba.maulog } +func (ba *bridgeAdapter) GetZLog() *zerolog.Logger { return ba.zlog } +func (ba *bridgeAdapter) GetConnectorConfig() *imessage.PlatformConfig { return ba.config } + +func (ba *bridgeAdapter) PingServer() (start, serverTs, end time.Time) { + now := time.Now() + return now, now, now +} + +func (ba *bridgeAdapter) SendBridgeStatus(state imessage.BridgeStatus) {} +func (ba *bridgeAdapter) ReIDPortal(oldGUID, newGUID string, mergeExisting bool) bool { return false } +func (ba *bridgeAdapter) GetMessagesSince(chatGUID string, since time.Time) []string { return nil } +func (ba *bridgeAdapter) SetPushKey(req *imessage.PushKeyRequest) {} diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go new file mode 100644 index 00000000..28107e65 --- /dev/null +++ b/pkg/connector/capabilities.go @@ -0,0 +1,111 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "go.mau.fi/util/ffmpeg" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +func supportedIfFFmpeg() event.CapabilitySupportLevel { + if ffmpeg.Supported() { + return event.CapLevelPartialSupport + } + return event.CapLevelRejected +} + +const iMessageMaxFileSize = 2000 * 1024 * 1024 // 2 GB +const capabilityID = "fi.mau.imessage.capabilities.2025_03" + +var caps = &event.RoomFeatures{ + ID: capabilityID, + + Formatting: map[event.FormattingFeature]event.CapabilitySupportLevel{ + event.FmtBold: event.CapLevelDropped, + event.FmtItalic: event.CapLevelDropped, + }, + File: map[event.CapabilityMsgType]*event.FileFeatures{ + event.MsgImage: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/jpeg": event.CapLevelFullySupported, + "image/png": event.CapLevelFullySupported, + "image/gif": event.CapLevelFullySupported, + "image/heic": event.CapLevelFullySupported, + "image/heif": event.CapLevelFullySupported, + "image/webp": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxSize: iMessageMaxFileSize, + }, + event.CapMsgGIF: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/gif": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxSize: iMessageMaxFileSize, + }, + event.MsgVideo: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "video/mp4": event.CapLevelFullySupported, + "video/quicktime": supportedIfFFmpeg(), + }, + Caption: event.CapLevelFullySupported, + MaxSize: iMessageMaxFileSize, + }, + event.MsgAudio: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "*/*": event.CapLevelFullySupported, + }, + MaxSize: iMessageMaxFileSize, + }, + event.CapMsgVoice: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "*/*": event.CapLevelFullySupported, + }, + MaxSize: iMessageMaxFileSize, + }, + event.MsgFile: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "*/*": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxSize: iMessageMaxFileSize, + }, + }, + Reply: event.CapLevelFullySupported, + Edit: event.CapLevelFullySupported, + Delete: event.CapLevelFullySupported, + DeleteChat: true, + Reaction: event.CapLevelFullySupported, + ReactionCount: 1, + ReadReceipts: true, + TypingNotifications: true, +} + +var capsDM *event.RoomFeatures + +func init() { + c := *caps + capsDM = &c + capsDM.ID = capabilityID + "+dm" +} + +var generalCaps = &bridgev2.NetworkGeneralCapabilities{ + DisappearingMessages: false, + AggressiveUpdateInfo: true, +} + +func (c *IMConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { + return generalCaps +} + +func (c *IMConnector) GetBridgeInfoVersion() (info, capabilities int) { + return 1, 1 +} diff --git a/pkg/connector/capabilities_test.go b/pkg/connector/capabilities_test.go new file mode 100644 index 00000000..5b084b7d --- /dev/null +++ b/pkg/connector/capabilities_test.go @@ -0,0 +1,134 @@ +package connector + +import ( + "testing" + + "maunium.net/go/mautrix/event" +) + +func TestCaps_NotNil(t *testing.T) { + if caps == nil { + t.Fatal("caps should not be nil") + } + if capsDM == nil { + t.Fatal("capsDM should not be nil") + } + if generalCaps == nil { + t.Fatal("generalCaps should not be nil") + } +} + +func TestCaps_ID(t *testing.T) { + if caps.ID == "" { + t.Error("caps.ID should not be empty") + } + if capsDM.ID == "" { + t.Error("capsDM.ID should not be empty") + } + if caps.ID == capsDM.ID { + t.Error("caps.ID and capsDM.ID should differ") + } +} + +func TestCaps_Features(t *testing.T) { + if caps.Reply != event.CapLevelFullySupported { + t.Errorf("caps.Reply = %v, want FullySupported", caps.Reply) + } + if caps.Edit != event.CapLevelFullySupported { + t.Errorf("caps.Edit = %v, want FullySupported", caps.Edit) + } + if caps.Delete != event.CapLevelFullySupported { + t.Errorf("caps.Delete = %v, want FullySupported", caps.Delete) + } + if caps.Reaction != event.CapLevelFullySupported { + t.Errorf("caps.Reaction = %v, want FullySupported", caps.Reaction) + } + if caps.ReactionCount != 1 { + t.Errorf("caps.ReactionCount = %d, want 1", caps.ReactionCount) + } + if !caps.ReadReceipts { + t.Error("caps.ReadReceipts should be true") + } + if !caps.TypingNotifications { + t.Error("caps.TypingNotifications should be true") + } + if !caps.DeleteChat { + t.Error("caps.DeleteChat should be true") + } +} + +func TestCaps_FileTypes(t *testing.T) { + for _, msgType := range []event.CapabilityMsgType{event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile, event.CapMsgGIF, event.CapMsgVoice} { + if _, ok := caps.File[msgType]; !ok { + t.Errorf("caps.File missing %v", msgType) + } + } +} + +func TestCaps_Formatting(t *testing.T) { + if caps.Formatting[event.FmtBold] != event.CapLevelDropped { + t.Errorf("caps.Formatting[bold] = %v, want Dropped", caps.Formatting[event.FmtBold]) + } + if caps.Formatting[event.FmtItalic] != event.CapLevelDropped { + t.Errorf("caps.Formatting[italic] = %v, want Dropped", caps.Formatting[event.FmtItalic]) + } + if _, ok := caps.Formatting[event.FmtUnderline]; ok { + t.Error("caps.Formatting should not include underline") + } + if _, ok := caps.Formatting[event.FmtStrikethrough]; ok { + t.Error("caps.Formatting should not include strikethrough") + } +} + +func TestCapsDM_NoGroupFeatures(t *testing.T) { + // DM caps should not have room state or invite/kick. + if _, ok := capsDM.State[event.StateRoomName.Type]; ok { + t.Error("capsDM should not have StateRoomName") + } + if _, ok := capsDM.State[event.StateRoomAvatar.Type]; ok { + t.Error("capsDM should not have StateRoomAvatar") + } + if _, ok := capsDM.MemberActions[event.MemberActionInvite]; ok { + t.Error("capsDM should not have MemberActionInvite") + } + if _, ok := capsDM.MemberActions[event.MemberActionKick]; ok { + t.Error("capsDM should not have MemberActionKick") + } + // Current bridge capabilities also do not expose room state or member actions for group chats. + if _, ok := caps.State[event.StateRoomName.Type]; ok { + t.Error("caps should not have StateRoomName") + } + if _, ok := caps.MemberActions[event.MemberActionInvite]; ok { + t.Error("caps should not have MemberActionInvite") + } +} + +func TestCapsDM_StillHasLeave(t *testing.T) { + if _, ok := capsDM.MemberActions[event.MemberActionLeave]; ok { + t.Error("capsDM should not have MemberActionLeave") + } +} + +func TestGeneralCaps(t *testing.T) { + if generalCaps.DisappearingMessages { + t.Error("DisappearingMessages should be false") + } + if !generalCaps.AggressiveUpdateInfo { + t.Error("AggressiveUpdateInfo should be true") + } +} + +func TestIMConnector_GetCapabilities(t *testing.T) { + c := &IMConnector{} + got := c.GetCapabilities() + if got != generalCaps { + t.Error("GetCapabilities should return generalCaps") + } +} + +func TestIMessageMaxFileSize(t *testing.T) { + expected := 2000 * 1024 * 1024 + if iMessageMaxFileSize != expected { + t.Errorf("iMessageMaxFileSize = %d, want %d", iMessageMaxFileSize, expected) + } +} diff --git a/pkg/connector/carddav_crypto.go b/pkg/connector/carddav_crypto.go new file mode 100644 index 00000000..b0c9e310 --- /dev/null +++ b/pkg/connector/carddav_crypto.go @@ -0,0 +1,134 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// AES-256-GCM encryption for CardDAV credentials stored in config. +// The encryption key is a random 32-byte file stored alongside session data. + +package connector + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" +) + +const cardDAVKeyFileName = "carddav.key" + +// cardDAVKeyDir returns the session data directory where the encryption key is stored. +func cardDAVKeyDir() string { + dir := os.Getenv("XDG_DATA_HOME") + if dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".local", "share") + } + return filepath.Join(dir, "mautrix-imessage") +} + +// cardDAVKeyPath returns the full path to the CardDAV encryption key file. +func cardDAVKeyPath() string { + return filepath.Join(cardDAVKeyDir(), cardDAVKeyFileName) +} + +// generateCardDAVKey creates a new random 32-byte AES-256 key and saves it. +// Returns the key bytes. If the key file already exists, it is overwritten. +func generateCardDAVKey() ([]byte, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("failed to generate random key: %w", err) + } + + dir := cardDAVKeyDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("failed to create key directory: %w", err) + } + + if err := os.WriteFile(cardDAVKeyPath(), key, 0600); err != nil { + return nil, fmt.Errorf("failed to write key file: %w", err) + } + + return key, nil +} + +// loadCardDAVKey reads the AES-256 key from the session data directory. +func loadCardDAVKey() ([]byte, error) { + key, err := os.ReadFile(cardDAVKeyPath()) + if err != nil { + return nil, fmt.Errorf("failed to read CardDAV key file (%s): %w", cardDAVKeyPath(), err) + } + if len(key) != 32 { + return nil, fmt.Errorf("CardDAV key file has wrong size: %d (expected 32)", len(key)) + } + return key, nil +} + +// EncryptCardDAVPassword encrypts a password using AES-256-GCM. +// Generates a new key if one doesn't exist. Returns base64-encoded ciphertext. +func EncryptCardDAVPassword(password string) (string, error) { + // Try to load existing key, generate if missing + key, err := loadCardDAVKey() + if err != nil { + key, err = generateCardDAVKey() + if err != nil { + return "", err + } + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + // Seal: nonce is prepended to ciphertext + ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// DecryptCardDAVPassword decrypts a base64-encoded AES-256-GCM ciphertext. +func DecryptCardDAVPassword(encrypted string) (string, error) { + key, err := loadCardDAVKey() + if err != nil { + return "", err + } + + ciphertext, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + return "", fmt.Errorf("failed to decode base64: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt: %w", err) + } + + return string(plaintext), nil +} diff --git a/pkg/connector/carddav_crypto_test.go b/pkg/connector/carddav_crypto_test.go new file mode 100644 index 00000000..0c7ac164 --- /dev/null +++ b/pkg/connector/carddav_crypto_test.go @@ -0,0 +1,173 @@ +package connector + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEncryptDecryptRoundTrip(t *testing.T) { + // Set up a temp directory for the key file + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + password := "my-secret-app-password" + encrypted, err := EncryptCardDAVPassword(password) + if err != nil { + t.Fatalf("EncryptCardDAVPassword error: %v", err) + } + if encrypted == "" { + t.Fatal("encrypted should not be empty") + } + if encrypted == password { + t.Fatal("encrypted should differ from plaintext") + } + + decrypted, err := DecryptCardDAVPassword(encrypted) + if err != nil { + t.Fatalf("DecryptCardDAVPassword error: %v", err) + } + if decrypted != password { + t.Errorf("decrypted = %q, want %q", decrypted, password) + } +} + +func TestEncryptDecryptRoundTrip_LongPassword(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + password := "this-is-a-very-long-password-with-special-chars-!@#$%^&*()" + encrypted, err := EncryptCardDAVPassword(password) + if err != nil { + t.Fatalf("EncryptCardDAVPassword error: %v", err) + } + + decrypted, err := DecryptCardDAVPassword(encrypted) + if err != nil { + t.Fatalf("DecryptCardDAVPassword error: %v", err) + } + if decrypted != password { + t.Errorf("decrypted = %q, want %q", decrypted, password) + } +} + +func TestDecryptWithWrongKey(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + encrypted, err := EncryptCardDAVPassword("my-password") + if err != nil { + t.Fatalf("EncryptCardDAVPassword error: %v", err) + } + + // Overwrite key file with different key + keyPath := filepath.Join(tmpDir, "mautrix-imessage", cardDAVKeyFileName) + newKey := make([]byte, 32) + for i := range newKey { + newKey[i] = byte(i) + } + os.WriteFile(keyPath, newKey, 0600) + + _, err = DecryptCardDAVPassword(encrypted) + if err == nil { + t.Error("DecryptCardDAVPassword should fail with wrong key") + } +} + +func TestDecryptInvalidBase64(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Generate a key so loadCardDAVKey succeeds + _, err := generateCardDAVKey() + if err != nil { + t.Fatalf("generateCardDAVKey error: %v", err) + } + + _, err = DecryptCardDAVPassword("not-valid-base64!!!") + if err == nil { + t.Error("DecryptCardDAVPassword should fail with invalid base64") + } +} + +func TestDecryptTooShort(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + _, err := generateCardDAVKey() + if err != nil { + t.Fatalf("generateCardDAVKey error: %v", err) + } + + // Very short base64 (1 byte decoded) + _, err = DecryptCardDAVPassword("AA==") + if err == nil { + t.Error("DecryptCardDAVPassword should fail with too-short ciphertext") + } +} + +func TestLoadCardDAVKey_WrongSize(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + dir := filepath.Join(tmpDir, "mautrix-imessage") + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, cardDAVKeyFileName), []byte("too-short"), 0600) + + _, err := loadCardDAVKey() + if err == nil { + t.Error("loadCardDAVKey should fail with wrong-size key file") + } +} + +func TestLoadCardDAVKey_Missing(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + _, err := loadCardDAVKey() + if err == nil { + t.Error("loadCardDAVKey should fail when key file is missing") + } +} + +func TestCardDAVKeyDir_XDGSet(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/tmp/test-xdg") + got := cardDAVKeyDir() + want := "/tmp/test-xdg/mautrix-imessage" + if got != want { + t.Errorf("cardDAVKeyDir() = %q, want %q", got, want) + } +} + +func TestCardDAVKeyDir_XDGUnset(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "") + got := cardDAVKeyDir() + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".local", "share", "mautrix-imessage") + if got != want { + t.Errorf("cardDAVKeyDir() = %q, want %q", got, want) + } +} + +func TestGenerateCardDAVKey(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + key, err := generateCardDAVKey() + if err != nil { + t.Fatalf("generateCardDAVKey error: %v", err) + } + if len(key) != 32 { + t.Errorf("key length = %d, want 32", len(key)) + } + + // Verify file was written + keyPath := filepath.Join(tmpDir, "mautrix-imessage", cardDAVKeyFileName) + fileKey, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("key file read error: %v", err) + } + if string(fileKey) != string(key) { + t.Error("key file content doesn't match returned key") + } +} diff --git a/pkg/connector/chatdb.go b/pkg/connector/chatdb.go new file mode 100644 index 00000000..e19fd221 --- /dev/null +++ b/pkg/connector/chatdb.go @@ -0,0 +1,569 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "bytes" + "context" + "fmt" + "image" + "image/jpeg" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/ffmpeg" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + + "github.com/lrhodin/imessage/imessage" +) + +// chatDB wraps the macOS chat.db iMessage API for backfill and contact +// resolution. It does NOT listen for incoming messages (rustpush handles that). +type chatDB struct { + api imessage.API +} + +// openChatDB attempts to open the local iMessage chat.db database. +// Returns nil if chat.db is not accessible (e.g., no Full Disk Access). +func openChatDB(log zerolog.Logger) *chatDB { + if !canReadChatDB(log) { + showDialogAndOpenFDA(log) + waitForFDA(log) + } + + adapter := newBridgeAdapter(&log) + api, err := imessage.NewAPI(adapter) + if err != nil { + log.Warn().Err(err).Msg("Failed to initialize chat.db API via imessage.NewAPI") + return nil + } + + return &chatDB{api: api} +} + +// Close stops the chat.db API. +func (db *chatDB) Close() { + if db.api != nil { + db.api.Stop() + } +} + +// findGroupChatGUID finds a group chat GUID by matching the portal's members. +// The portalID is comma-separated members like "tel:+1555...,tel:+1555...". +func (db *chatDB) findGroupChatGUID(portalID string, c *IMClient) string { + // Parse members from portal ID (lowercase for case-insensitive matching) + portalMembers := strings.Split(portalID, ",") + portalMemberSet := make(map[string]struct{}) + for _, m := range portalMembers { + portalMemberSet[strings.ToLower(stripIdentifierPrefix(m))] = struct{}{} + } + + // Search all group chats + chats, err := db.api.GetChatsWithMessagesAfter(time.Time{}) + if err != nil { + return "" + } + + for _, chat := range chats { + parsed := imessage.ParseIdentifier(chat.ChatGUID) + if !parsed.IsGroup { + continue + } + info, err := db.api.GetChatInfo(chat.ChatGUID, chat.ThreadID) + if err != nil || info == nil { + continue + } + + // Build member set from chat.db (add self, lowercase for case-insensitive matching) + chatMemberSet := make(map[string]struct{}) + chatMemberSet[strings.ToLower(stripIdentifierPrefix(c.handle))] = struct{}{} + for _, m := range info.Members { + chatMemberSet[strings.ToLower(stripIdentifierPrefix(m))] = struct{}{} + } + + // Check if members match + if len(chatMemberSet) == len(portalMemberSet) { + match := true + for m := range portalMemberSet { + if _, ok := chatMemberSet[m]; !ok { + match = false + break + } + } + if match { + return chat.ChatGUID + } + } + } + return "" +} + +// chatDBReplyTarget returns the correct MessageOptionalPartID for a reply, +// mapping chat.db balloon-part index to the emitted part IDs: +// bp<=0 -> base GUID (text body); bp>=1 -> {guid}_att{bp-1} (attachment). +// Negative part values are normalized to 0 (base-message semantics). +func chatDBReplyTarget(replyGUID string, replyPart int) *networkid.MessageOptionalPartID { + targetID := replyGUID + if replyPart >= 1 { + targetID = fmt.Sprintf("%s_att%d", replyGUID, replyPart-1) + } + return &networkid.MessageOptionalPartID{MessageID: makeMessageID(targetID)} +} + +// FetchMessages retrieves historical messages from chat.db for backfill. +func (db *chatDB) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams, c *IMClient) (*bridgev2.FetchMessagesResponse, error) { + portalID := string(params.Portal.ID) + log := zerolog.Ctx(ctx) + + var chatGUIDs []string + if strings.Contains(portalID, ",") { + // Group portal: find chat GUID by matching members + chatGUID := db.findGroupChatGUID(portalID, c) + if chatGUID != "" { + chatGUIDs = []string{chatGUID} + } + } else { + // Use contact-aware lookup: includes chat GUIDs for all of the + // contact's phone numbers, so merged DM portals get complete history. + chatGUIDs = c.getContactChatGUIDs(portalID) + } + + log.Info().Str("portal_id", portalID).Strs("chat_guids", chatGUIDs).Bool("forward", params.Forward).Msg("FetchMessages called") + + if len(chatGUIDs) == 0 { + log.Warn().Str("portal_id", portalID).Msg("Could not find chat GUID for portal") + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: params.Forward}, nil + } + + count := params.Count + if count <= 0 { + count = 50 + } + + // Fetch messages from ALL chat GUIDs and merge. For contacts with multiple + // phone numbers, this combines messages from all numbers into one timeline. + // Also tries multiple GUID formats (any/iMessage/SMS) per phone number. + var messages []*imessage.Message + var lastErr error + + // Respect the configured message cap. params.Count comes from the + // framework's MaxInitialMessages setting. + maxMessages := c.Main.Bridge.Config.Backfill.MaxInitialMessages + + for _, chatGUID := range chatGUIDs { + var msgs []*imessage.Message + if params.AnchorMessage != nil { + if params.Forward { + msgs, lastErr = db.api.GetMessagesSinceDate(chatGUID, params.AnchorMessage.Timestamp, "") + } else { + msgs, lastErr = db.api.GetMessagesBeforeWithLimit(chatGUID, params.AnchorMessage.Timestamp, count) + } + } else { + // Fetch the most recent N messages (uncapped = MaxInt32, effectively all). + msgs, lastErr = db.api.GetMessagesBeforeWithLimit(chatGUID, time.Now().Add(time.Minute), maxMessages) + } + if lastErr == nil { + messages = append(messages, msgs...) + } + } + + if len(messages) == 0 && lastErr != nil { + log.Error().Err(lastErr).Strs("chat_guids", chatGUIDs).Msg("Failed to fetch messages from chat.db") + return nil, fmt.Errorf("failed to fetch messages from chat.db: %w", lastErr) + } + + // Sort chronologically — messages may come from multiple chat GUIDs + sort.Slice(messages, func(i, j int) bool { + return messages[i].Time.Before(messages[j].Time) + }) + + log.Info().Strs("chat_guids", chatGUIDs).Int("raw_message_count", len(messages)).Msg("Got messages from chat.db") + + // Get an intent for uploading media. The bot intent works for all uploads. + intent := c.Main.Bridge.Bot + + backfillMessages := make([]*bridgev2.BackfillMessage, 0, len(messages)) + for _, msg := range messages { + if msg.ItemType != imessage.ItemTypeMessage || msg.Tapback != nil { + continue + } + sender := chatDBMakeEventSender(msg, c) + if sender.Sender == "" && !sender.IsFromMe { + continue + } + sender = c.canonicalizeDMSender(params.Portal.PortalKey, sender) + + // Strip U+FFFC (object replacement character) — inline attachment + // placeholders from NSAttributedString that render as blank + msg.Text = strings.ReplaceAll(msg.Text, "\uFFFC", "") + msg.Text = strings.TrimSpace(msg.Text) + + // Only create a text part if there's actual text content + if msg.Text != "" || msg.Subject != "" { + cm, err := convertChatDBMessage(ctx, params.Portal, intent, msg) + if err == nil { + backfillMessages = append(backfillMessages, &bridgev2.BackfillMessage{ + ConvertedMessage: cm, + Sender: sender, + ID: makeMessageID(msg.GUID), + TxnID: networkid.TransactionID(msg.GUID), + Timestamp: msg.Time, + StreamOrder: msg.Time.UnixMilli(), + }) + } + } + + // Live Photo handling: macOS stores the MOV companion as a sibling + // file next to the HEIC on disk, but does NOT list it in the + // attachment table. Bridge the original attachment first, then + // check for and bridge any companion MOV alongside it. + for i, att := range msg.Attachments { + if att == nil { + continue + } + attCm, err := convertChatDBAttachment(ctx, params.Portal, intent, msg, att, c.Main.Config.VideoTranscoding, c.Main.Config.HEICConversion, c.Main.Config.HEICJPEGQuality) + if err != nil { + log.Warn().Err(err).Str("guid", msg.GUID).Int("att_index", i).Msg("Failed to convert attachment, skipping") + continue + } + partID := fmt.Sprintf("%s_att%d", msg.GUID, i) + if msg.ReplyToGUID != "" { + attCm.ReplyTo = chatDBReplyTarget(msg.ReplyToGUID, msg.ReplyToPart) + } + backfillMessages = append(backfillMessages, &bridgev2.BackfillMessage{ + ConvertedMessage: attCm, + Sender: sender, + ID: makeMessageID(partID), + TxnID: networkid.TransactionID(partID), + Timestamp: msg.Time.Add(time.Duration(i+1) * time.Millisecond), + StreamOrder: msg.Time.UnixMilli() + int64(i+1), + }) + + // If there's a Live Photo MOV companion on disk, bridge it too. + movAtt := chatDBResolveLivePhoto(att, log) + if movAtt.PathOnDisk != att.PathOnDisk { + movCm, movErr := convertChatDBAttachment(ctx, params.Portal, intent, msg, movAtt, c.Main.Config.VideoTranscoding, c.Main.Config.HEICConversion, c.Main.Config.HEICJPEGQuality) + if movErr != nil { + log.Warn().Err(movErr).Str("guid", msg.GUID).Int("att_index", i).Msg("Failed to convert Live Photo MOV companion, skipping") + } else { + movID := fmt.Sprintf("%s_att%d_mov", msg.GUID, i) + backfillMessages = append(backfillMessages, &bridgev2.BackfillMessage{ + ConvertedMessage: movCm, + Sender: sender, + ID: makeMessageID(movID), + TxnID: networkid.TransactionID(movID), + Timestamp: msg.Time.Add(time.Duration(i+1)*time.Millisecond + 500*time.Microsecond), + StreamOrder: msg.Time.UnixMilli() + int64(i+1), + }) + } + } + } + } + + return &bridgev2.FetchMessagesResponse{ + Messages: backfillMessages, + HasMore: len(messages) >= count, + Forward: params.Forward, + AggressiveDeduplication: params.Forward, + }, nil +} + +// ============================================================================ +// chat.db ↔ portal ID conversion +// ============================================================================ + +// portalIDToChatGUIDs converts a DM portal ID to possible chat.db GUIDs. +// Returns multiple possible GUIDs to try, since macOS versions differ: +// Tahoe+ uses "any;-;" while older uses "iMessage;-;" or "SMS;-;". +// +// Note: Group portal IDs (comma-separated) are handled by findGroupChatGUID instead. +func portalIDToChatGUIDs(portalID string) []string { + // DMs: strip tel:/mailto: prefix and try multiple service prefixes + localID := stripIdentifierPrefix(portalID) + if localID == "" { + return nil + } + // Strip legacy (sms...) suffix from pre-fix portal IDs so chat.db GUID + // candidates match: "tel:+12155167207(smsft)" → localID "+12155167207(smsft)" + // → stripped "+12155167207", producing "any;-;+12155167207" which matches chat.db. + localID = stripSmsSuffix(localID) + return []string{ + "any;-;" + localID, + "iMessage;-;" + localID, + "SMS;-;" + localID, + } +} + +// identifierToPortalID converts a chat.db Identifier to a clean portal ID. +func identifierToPortalID(id imessage.Identifier) networkid.PortalID { + if id.IsGroup { + return networkid.PortalID(id.String()) + } + localID := id.LocalID + // Strip Apple SMS service suffixes: "+12155167207(smsft)" → "+12155167207", + // "787473(smsft)" → "787473". These are native Apple formats that appear in + // chat.db for SMS Forwarding service types. + localID = stripSmsSuffix(localID) + if strings.HasPrefix(localID, "+") { + return networkid.PortalID("tel:" + localID) + } + if strings.Contains(localID, "@") { + return networkid.PortalID("mailto:" + localID) + } + // Short codes and numeric-only identifiers (e.g., "242733") are SMS-based. + // Rustpush creates these with "tel:" prefix, so we must match. + if isNumeric(localID) { + return networkid.PortalID("tel:" + localID) + } + return networkid.PortalID(localID) +} + +// ============================================================================ +// chat.db message conversion +// ============================================================================ + +func chatDBMakeEventSender(msg *imessage.Message, c *IMClient) bridgev2.EventSender { + if msg.IsFromMe { + return bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: makeUserID(c.handle), + } + } + return bridgev2.EventSender{ + IsFromMe: false, + Sender: makeUserID(addIdentifierPrefix(stripSmsSuffix(msg.Sender.LocalID))), + } +} + +func convertChatDBMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *imessage.Message) (*bridgev2.ConvertedMessage, error) { + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: msg.Text, + } + if msg.Subject != "" { + if msg.Text != "" { + content.Body = fmt.Sprintf("**%s**\n%s", msg.Subject, msg.Text) + content.Format = event.FormatHTML + content.FormattedBody = fmt.Sprintf("%s
%s", msg.Subject, msg.Text) + } else { + content.Body = msg.Subject + } + } + if msg.IsEmote { + content.MsgType = event.MsgEmote + } + + // URL preview: detect URL and fetch og: metadata + image + if detectedURL := urlRegex.FindString(msg.Text); detectedURL != "" { + content.BeeperLinkPreviews = []*event.BeeperLinkPreview{ + fetchURLPreview(ctx, portal.Bridge, intent, portal.MXID, detectedURL), + } + } + + cm := &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: content, + }}, + } + if msg.ReplyToGUID != "" { + cm.ReplyTo = chatDBReplyTarget(msg.ReplyToGUID, msg.ReplyToPart) + } + return cm, nil +} + +func convertChatDBAttachment(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *imessage.Message, att *imessage.Attachment, videoTranscoding, heicConversion bool, heicQuality int) (*bridgev2.ConvertedMessage, error) { + mimeType := att.GetMimeType() + fileName := att.GetFileName() + + data, err := att.Read() + if err != nil { + return nil, fmt.Errorf("failed to read attachment %s: %w", att.PathOnDisk, err) + } + + // Convert CAF Opus voice messages to OGG Opus for Matrix/Beeper clients + var durationMs int + if mimeType == "audio/x-caf" || strings.HasSuffix(strings.ToLower(fileName), ".caf") { + data, mimeType, fileName, durationMs = convertAudioForMatrix(data, mimeType, fileName) + } + + // Remux/transcode non-MP4 videos to MP4 for broad Matrix client compatibility. + log := zerolog.Ctx(ctx) + if videoTranscoding && ffmpeg.Supported() && strings.HasPrefix(mimeType, "video/") && mimeType != "video/mp4" { + origMime := mimeType + origSize := len(data) + method := "remux" + converted, convertErr := ffmpeg.ConvertBytes(ctx, data, ".mp4", nil, + []string{"-c", "copy", "-movflags", "+faststart"}, + mimeType) + if convertErr != nil { + // Remux failed — try full re-encode + method = "re-encode" + converted, convertErr = ffmpeg.ConvertBytes(ctx, data, ".mp4", nil, + []string{"-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-movflags", "+faststart"}, + mimeType) + } + if convertErr != nil { + log.Warn().Err(convertErr).Str("guid", msg.GUID).Str("original_mime", origMime). + Msg("FFmpeg video conversion failed, uploading original") + } else { + log.Info().Str("guid", msg.GUID).Str("original_mime", origMime). + Str("method", method).Int("original_bytes", origSize).Int("converted_bytes", len(converted)). + Msg("Video transcoded to MP4") + data = converted + mimeType = "video/mp4" + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".mp4" + } + } + + // Convert HEIC/HEIF images to JPEG since most Matrix clients can't display HEIC. + var heicImg image.Image + data, mimeType, fileName, heicImg = maybeConvertHEIC(log, data, mimeType, fileName, heicQuality, heicConversion) + + // Extract image dimensions and generate thumbnail + var imgWidth, imgHeight int + var thumbData []byte + var thumbW, thumbH int + if heicImg != nil { + b := heicImg.Bounds() + imgWidth, imgHeight = b.Dx(), b.Dy() + if imgWidth > 800 || imgHeight > 800 { + thumbData, thumbW, thumbH = scaleAndEncodeThumb(heicImg, imgWidth, imgHeight) + } + } else if strings.HasPrefix(mimeType, "image/") || looksLikeImage(data) { + if mimeType == "image/gif" { + if cfg, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + imgWidth, imgHeight = cfg.Width, cfg.Height + } + } else if img, fmtName, _ := decodeImageData(data); img != nil { + b := img.Bounds() + imgWidth, imgHeight = b.Dx(), b.Dy() + // Re-encode TIFF as JPEG for compatibility (PNG is fine as-is) + if fmtName == "tiff" { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err == nil { + data = buf.Bytes() + mimeType = "image/jpeg" + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".jpg" + } + } + if imgWidth > 800 || imgHeight > 800 { + thumbData, thumbW, thumbH = scaleAndEncodeThumb(img, imgWidth, imgHeight) + } + } + } + + content := &event.MessageEventContent{ + MsgType: mimeToMsgType(mimeType), + Body: fileName, + Info: &event.FileInfo{ + MimeType: mimeType, + Size: len(data), + Width: imgWidth, + Height: imgHeight, + }, + } + + // Mark as voice message if this was a CAF voice recording + if durationMs > 0 { + content.MSC3245Voice = &event.MSC3245Voice{} + content.MSC1767Audio = &event.MSC1767Audio{ + Duration: durationMs, + } + } + + if intent != nil { + url, encFile, err := intent.UploadMedia(ctx, "", data, fileName, mimeType) + if err != nil { + return nil, fmt.Errorf("failed to upload attachment: %w", err) + } + if encFile != nil { + content.File = encFile + } else { + content.URL = url + } + + // Upload image thumbnail + if thumbData != nil { + thumbURL, thumbEnc, thumbErr := intent.UploadMedia(ctx, "", thumbData, "thumbnail.jpg", "image/jpeg") + if thumbErr == nil { + if thumbEnc != nil { + content.Info.ThumbnailFile = thumbEnc + } else { + content.Info.ThumbnailURL = thumbURL + } + content.Info.ThumbnailInfo = &event.FileInfo{ + MimeType: "image/jpeg", + Size: len(thumbData), + Width: thumbW, + Height: thumbH, + } + } + } + } + + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: content, + }}, + }, nil +} + +// chatDBResolveLivePhoto checks if a HEIC/JPG attachment has a companion .MOV +// file on disk (Apple stores Live Photo videos as sibling files but doesn't +// list them in the attachment table). If found, returns a new Attachment +// pointing to the MOV. Returns the original attachment unchanged if no +// companion is found. +func chatDBResolveLivePhoto(att *imessage.Attachment, log *zerolog.Logger) *imessage.Attachment { + path := att.PathOnDisk + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return att + } + path = filepath.Join(home, path[2:]) + } + + lower := strings.ToLower(filepath.Base(path)) + if !strings.HasSuffix(lower, ".heic") && !strings.HasSuffix(lower, ".jpg") && !strings.HasSuffix(lower, ".jpeg") { + return att + } + + // Check for sibling .MOV in the same directory + dir := filepath.Dir(path) + base := filenameBase(filepath.Base(path)) + movPath := filepath.Join(dir, base+".MOV") + + if _, err := os.Stat(movPath); err != nil { + // Try lowercase extension + movPath = filepath.Join(dir, base+".mov") + if _, err := os.Stat(movPath); err != nil { + return att // No companion MOV found — keep the HEIC + } + } + + log.Info().Str("heic", filepath.Base(path)).Str("mov", filepath.Base(movPath)). + Msg("Live Photo: swapping HEIC for MOV companion") + + return &imessage.Attachment{ + GUID: att.GUID, + PathOnDisk: movPath, + FileName: base + ".MOV", + MimeType: "video/quicktime", + } +} diff --git a/pkg/connector/chatdb_darwin.go b/pkg/connector/chatdb_darwin.go new file mode 100644 index 00000000..1f8fdfeb --- /dev/null +++ b/pkg/connector/chatdb_darwin.go @@ -0,0 +1,8 @@ +//go:build darwin + +package connector + +// Register the macOS chat.db platform (contacts, backfill, sleep detection). +// This side-effect import is Darwin-only because imessage/mac links against +// macOS frameworks (IOKit, Foundation, Contacts). +import _ "github.com/lrhodin/imessage/imessage/mac" diff --git a/pkg/connector/client.go b/pkg/connector/client.go new file mode 100644 index 00000000..ccb68c62 --- /dev/null +++ b/pkg/connector/client.go @@ -0,0 +1,10258 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "html" + "image" + "image/jpeg" + "math" + "net/url" + "path/filepath" + "regexp" + "runtime/debug" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + _ "image/gif" + _ "image/png" + + _ "golang.org/x/image/tiff" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "go.mau.fi/util/ffmpeg" + "go.mau.fi/util/ptr" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + matrixfmt "maunium.net/go/mautrix/bridgev2/matrix" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" + "maunium.net/go/mautrix/pushrules" + + "github.com/lrhodin/imessage/imessage" + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +// IMClient implements bridgev2.NetworkAPI using the rustpush iMessage protocol +// library for real-time messaging. Contact resolution uses iCloud CardDAV. + +// deletedPortalEntry tracks why a portal was marked as deleted. +// Entries in recentlyDeletedPortals serve two purposes: +// 1. Echo suppression: APNs messages with known UUIDs are dropped; +// unknown UUIDs are only allowed through if they're newer than the +// deleted chat's known tail. +// 2. Re-import guard: ingestCloudMessages marks messages as deleted=TRUE +// for portals in this map, preventing periodic re-syncs from re-importing +// messages with deleted=FALSE and triggering backfill resurrection. +type deletedPortalEntry struct { + deletedAt time.Time + isTombstone bool +} + +// recycleBinCandidate represents a portal that has messages in Apple's recycle +// bin. Stored after bootstrap and shown to the user via bridgebot notification +// so they can choose which chats to delete with !delete-stale. +type recycleBinCandidate struct { + portalID string + displayName string + recoverable int + total int +} + +// failedAttachmentEntry tracks a CloudKit attachment download/upload failure +// with a retry count. After maxAttachmentRetries attempts the attachment is +// abandoned to avoid infinite retries on permanently corrupted records. +type failedAttachmentEntry struct { + lastError string + retries int +} + +type restoreStatusFunc func(format string, args ...any) + +type restorePipelineOptions struct { + PortalID string + PortalKey networkid.PortalKey + Source string + DisplayName string + Participants []string + ChatID string + GroupID string + GroupPhotoGuid string + RecoverOnApple bool + Notify restoreStatusFunc +} + +const maxAttachmentRetries = 3 + +// recordAttachmentFailure increments the retry count for a failed attachment. +// Returns the updated entry so callers can log the retry count. +func (c *IMClient) recordAttachmentFailure(recordName, errMsg string) *failedAttachmentEntry { + entry := &failedAttachmentEntry{lastError: errMsg, retries: 1} + if prev, loaded := c.failedAttachments.Load(recordName); loaded { + old := prev.(*failedAttachmentEntry) + entry.retries = old.retries + 1 + } + c.failedAttachments.Store(recordName, entry) + return entry +} + +type IMClient struct { + Main *IMConnector + UserLogin *bridgev2.UserLogin + + // Rustpush (primary — real-time send/receive) + client *rustpushgo.Client + config *rustpushgo.WrappedOsConfig + users *rustpushgo.WrappedIdsUsers + identity *rustpushgo.WrappedIdsngmIdentity + connection *rustpushgo.WrappedApsConnection + handle string // Primary iMessage handle used for sending (e.g., tel:+1234567890) + allHandles []string // All registered handles (for IsThisUser checks) + + // iCloud token provider (auth for CardDAV, CloudKit, etc.) + tokenProvider **rustpushgo.WrappedTokenProvider + + // Contact source for name resolution (iCloud or external CardDAV) + contacts contactSource + + // sharedProfiles is the in-memory cache of shared iMessage profile records + // (Name & Photo Sharing) received via ShareProfile / UpdateProfile + // messages, keyed by sender identifier (e.g. "tel:+1234567890"). Values + // are *sharedProfileRow. Fronts sharedProfileStore which persists them + // across restarts; see pkg/connector/shared_profile.go. + sharedProfiles sync.Map + sharedProfileStore *sharedProfileStore + + // statusKitPresence tracks the last-known availability state per contact + // handle, keyed by iMessage identifier string (e.g. "tel:+1234567890"). + // Stored as bool (true = available). Used to suppress duplicate bot notices + // when StatusKit re-delivers the same presence state on reconnect. + statusKitPresence sync.Map // map[string]bool + + // statusKitPortalCache memoizes the resolved DM portal ID for a StatusKit + // presence handle. Populated after a successful IDS correlation lookup + // (see resolveStatusPortalViaIDS) so we only pay the IDS round trip once + // per unresolved handle per session. Values are networkid.PortalID. + statusKitPortalCache sync.Map // map[string]networkid.PortalID + + // sharedStreamAssetCache tracks the last observed asset GUID set per shared + // album for this session. The Shared Streams watcher uses it to suppress + // false-positive "new content" notices from Apple's getchanges endpoint, + // which also fires on metadata-only album updates. + sharedStreamAssetCache map[string]map[string]struct{} + sharedStreamAssetCacheMu sync.Mutex + + // sharedAlbumRooms caches dedicated Matrix rooms created for browsing + // shared album content. Keyed by album GUID; ephemeral per session. + sharedAlbumRooms map[string]id.RoomID + sharedAlbumRoomsMu sync.Mutex + + // statusKitBotRulePushed is set to true after we successfully install a + // sender push rule via the double puppet that silences push notifications + // from the bridge bot. Done once per session; the rule is durable on the + // homeserver so re-installs across sessions are harmless but redundant. + statusKitBotRulePushed atomic.Bool + + // lastPresenceSubscribe timestamps the most recent call to + // subscribeToContactPresence. OnKeysReceived triggers re-subscription + // when new keys arrive, but multiple key-sharing messages can arrive in + // quick succession — the debounce prevents redundant APNs subscription + // storms. + lastPresenceSubscribe time.Time + lastPresenceSubscribeLock sync.Mutex + + // Contacts readiness gate for CloudKit message sync. + contactsReady bool + contactsReadyLock sync.RWMutex + contactsReadyCh chan struct{} + + // CloudKit sync gate: prevents APNs messages from creating new portals + // until the initial CloudKit sync establishes the authoritative set of + // portals. Without this, is_from_me echoes arriving on reconnect can + // resurrect deleted portals before CloudKit sync confirms they're gone. + cloudSyncDone bool + cloudSyncDoneLock sync.RWMutex + + // cloudSyncRunning is true while any CloudKit sync cycle (bootstrap or + // periodic re-sync) is actively paging and importing records. Used by + // handleChatRecover to defer portal creation until messages are imported. + cloudSyncRunning bool + cloudSyncRunningLock sync.RWMutex + + // Cloud backfill local cache store. + cloudStore *cloudBackfillStore + + // Ford key cache — reimplementation of the 94f7b8e Ford cross-batch + // deduplication fix in Go. Populated aggressively during CloudKit + // attachment sync from every record's `lqa.protection_info` (and + // `avid.protection_info` for Live Photos), consulted on download to + // recover from MMCS dedup key mismatches. See pkg/connector/ford_cache.go. + fordCache *FordKeyCache + + // Chat.db backfill (macOS with Full Disk Access only) + chatDB *chatDB + + // Background goroutine lifecycle + stopChan chan struct{} + + // Unsend re-delivery suppression + recentUnsends map[string]time.Time + recentUnsendsLock sync.Mutex + + // SMS reaction echo suppression: tracks UUIDs of SMS reaction messages sent + // from Matrix so the outgoing echo from the iPhone relay is not processed as + // a duplicate plain-text message in the Matrix room. + recentSmsReactionEchoes map[string]time.Time + recentSmsReactionEchoesLock sync.Mutex + + // Outbound unsend echo suppression: tracks target UUIDs of unsends + // initiated from Matrix so the APNs echo doesn't get double-processed. + recentOutboundUnsends map[string]time.Time + recentOutboundUnsendsLock sync.Mutex + + // Outbound delete echo suppression: tracks portal IDs where a chat delete + // SMS portal tracking: portal IDs known to be SMS-only contacts + smsPortals map[string]bool + smsPortalsLock sync.RWMutex + + // Initial sync gate: closed once initial sync completes (or is skipped), + // so real-time messages don't race ahead of backfill. + + // Group portal fuzzy-matching index: maps each member to the set of + // group portal IDs containing that member. Lazily populated from DB. + groupPortalIndex map[string]map[string]bool + groupPortalMu sync.RWMutex + + // Actual iMessage group names (cv_name) keyed by portal ID. + // Populated from incoming messages; used for outbound routing. + imGroupNames map[string]string + imGroupNamesMu sync.RWMutex + + // In-memory group participants cache keyed by portal ID. + // Populated synchronously in makePortalKey so resolveGroupMembers + // can find participants before the async cloud_chat DB write completes. + imGroupParticipants map[string][]string + imGroupParticipantsMu sync.RWMutex + + // Persistent iMessage group UUIDs (sender_guid/gid) keyed by portal ID. + // Populated from incoming messages; used for outbound routing so that + // Apple Messages recipients match messages to the correct group thread. + imGroupGuids map[string]string + imGroupGuidsMu sync.RWMutex + + // gidAliases maps unknown gid-based portal IDs (e.g. "gid:uuid-b") to the + // resolved existing portal ID (e.g. "gid:uuid-a"). Populated when another + // rustpush client (like OpenBubbles) uses a different gid for the same + // group conversation. Avoids repeated participant-matching on each message. + gidAliases map[string]string + gidAliasesMu sync.RWMutex + + // Last active group portal per member. Updated on every incoming group + // message so typing indicators route to the correct group. + lastGroupForMember map[string]networkid.PortalKey + lastGroupForMemberMu sync.RWMutex + + // queuedPortals tracks portal_id → newest_ts for portals already queued + // this session. Prevents re-queuing on periodic syncs unless CloudKit + // has newer messages. + queuedPortals map[string]int64 + + // recentlyDeletedPortals tracks portal IDs that were deleted this + // session. Populated by: + // - CloudKit tombstones (ingestCloudChats, during sync before cloudSyncDone) + // All paths use hasMessageUUID for echo detection: known UUIDs are + // dropped, fresh UUIDs are allowed through for new conversations. + recentlyDeletedPortals map[string]deletedPortalEntry + recentlyDeletedPortalsMu sync.RWMutex + + // recycleBinCandidates stores portal IDs found in Apple's recycle bin + // during bootstrap. NOT auto-deleted — shown to the user via bridgebot + // notification so they can decide which to remove with !delete-stale. + recycleBinCandidates []recycleBinCandidate + recycleBinCandidatesMu sync.Mutex + + // restoreMu serializes chat restores to prevent race conditions when + // multiple restores run concurrently (e.g. undeleting DB rows, refreshing + // metadata, and queuing ChatResync events for two portals at once). + restoreMu sync.Mutex + // restorePipelines tracks portals with an active restore worker. + // Used to dedupe duplicate restore requests for the same portal. + restorePipelines map[string]bool + restorePipelinesMu sync.Mutex + // cloudSyncRunMu serializes manual restore-triggered CloudKit sync passes + // with the main sync controller so continuation token updates don't race. + cloudSyncRunMu sync.Mutex + + // forwardBackfillSem limits concurrent forward backfills to avoid + // overwhelming CloudKit/Matrix with simultaneous attachment downloads. + forwardBackfillSem chan struct{} + + // attachmentContentCache maps CloudKit record_name → *event.MessageEventContent. + // Populated by preUploadCloudAttachments, which runs in the cloud sync + // goroutine BEFORE createPortalsFromCloudSync. Checked first by + // downloadAndUploadAttachment so that FetchMessages (portal event loop) + // never blocks on a CloudKit download — it just reads the pre-built content. + attachmentContentCache sync.Map + + // failedAttachments tracks CloudKit record_name → *failedAttachmentEntry + // for attachments that failed to download or upload. preUploadCloudAttachments + // retries these on delayed re-syncs (15s/60s/3min) even for portals with + // fwd_backfill_done=1, ensuring transient failures don't cause permanent + // attachment loss. Capped at maxAttachmentRetries to avoid infinite retries + // on permanently corrupted CloudKit records. + failedAttachments sync.Map + + // startupTime records when this session connected. Used to suppress + // read receipts for messages that pre-date this session: re-delivered + // APNs receipts arrive with TimestampMs = delivery time (now), not the + // actual read time, so they always show the wrong "Seen at" timestamp. + startupTime time.Time + + // msgBuffer reorders incoming APNs messages by timestamp before dispatch. + // APNs delivers messages grouped by sender rather than interleaved + // chronologically, which causes out-of-order chat history in Matrix + // (since Matrix stores events in insertion order). The buffer collects + // messages, tapbacks, and edits, then flushes them sorted by timestamp + // after a quiet window or when a size limit is reached. + msgBuffer *messageBuffer + + // pendingPortalMsgs holds messages that need portal creation but arrived + // before CloudKit sync established the authoritative set of portals. + // Without this, the framework drops events where CreatePortal=false and + // portal.MXID="", and the UUIDs are already persisted so CloudKit won't + // re-deliver them. Flushed by setCloudSyncDone with CreatePortal=true. + pendingPortalMsgs []rustpushgo.WrappedMessage + pendingPortalMsgsMu sync.Mutex + + // pendingInitialBackfills counts how many forward FetchMessages calls are + // still outstanding from the bootstrap createPortalsFromCloudSync pass. + // The APNs message buffer is held until this counter reaches 0, ensuring + // buffered APNs messages are delivered to Matrix AFTER the CloudKit backfill + // (not interleaved with it). Set by createPortalsFromCloudSync before the + // first setCloudSyncDone; decremented by onForwardBackfillDone. + pendingInitialBackfills int64 + + // apnsBufferFlushedAt is the unix-millisecond time when the APNs buffer + // was flushed after all forward backfills completed. Zero until that event. + // Used to enforce a grace window: stale read receipts that were queued in + // the APNs buffer are delivered immediately on flush, so we suppress all + // read receipts for receiptGraceMs after the flush. + apnsBufferFlushedAt int64 // atomic +} + +var _ bridgev2.NetworkAPI = (*IMClient)(nil) +var _ bridgev2.EditHandlingNetworkAPI = (*IMClient)(nil) +var _ bridgev2.ReactionHandlingNetworkAPI = (*IMClient)(nil) +var _ bridgev2.ReadReceiptHandlingNetworkAPI = (*IMClient)(nil) +var _ bridgev2.TypingHandlingNetworkAPI = (*IMClient)(nil) +var _ bridgev2.IdentifierResolvingNetworkAPI = (*IMClient)(nil) +var _ bridgev2.BackfillingNetworkAPI = (*IMClient)(nil) +var _ bridgev2.BackfillingNetworkAPIWithLimits = (*IMClient)(nil) +var _ bridgev2.DeleteChatHandlingNetworkAPI = (*IMClient)(nil) +var _ rustpushgo.MessageCallback = (*IMClient)(nil) +var _ rustpushgo.UpdateUsersCallback = (*IMClient)(nil) +var _ rustpushgo.StatusCallback = (*IMClient)(nil) + +// ============================================================================ +// APNs message reorder buffer +// ============================================================================ + +const ( + // messageBufferQuietWindow is how long to wait after the last message + // before flushing the buffer. Balances latency vs. reordering accuracy. + messageBufferQuietWindow = 500 * time.Millisecond + + // messageBufferMaxSize is the maximum number of messages to hold before + // force-flushing, even if the quiet window hasn't elapsed. + messageBufferMaxSize = 50 +) + +// bufferedMessage is a message waiting in the reorder buffer. +type bufferedMessage struct { + msg rustpushgo.WrappedMessage + timestamp uint64 +} + +// messageBuffer collects incoming APNs messages and dispatches them in +// chronological (timestamp) order. While CloudKit sync is in progress +// (!cloudSyncDone), messages accumulate without flushing — this prevents +// APNs messages from being dispatched before CloudKit backfill completes, +// which would cause older CloudKit messages to appear after newer APNs +// messages in Matrix. Once cloudSyncDone fires, setCloudSyncDone triggers +// a flush. After sync, normal quiet-window / max-size flushing resumes. +// +// Only regular messages, tapbacks, and edits are buffered; time-sensitive +// events (typing, read/delivery receipts) and control events (unsends, +// renames, participant changes) bypass the buffer entirely. +type messageBuffer struct { + mu sync.Mutex + entries []bufferedMessage + timer *time.Timer + client *IMClient +} + +// add inserts a message into the buffer. While CloudKit sync is in progress, +// or while initial forward backfills are pending, messages accumulate without +// flushing. After both conditions clear, the buffer flushes on a quiet window +// (500ms) or when the max size (50) is reached. +func (b *messageBuffer) add(msg rustpushgo.WrappedMessage) { + b.mu.Lock() + b.entries = append(b.entries, bufferedMessage{ + msg: msg, + timestamp: msg.TimestampMs, + }) + + // Hold only while CloudKit data is being downloaded (!isCloudSyncDone). + // Once the data download is done, messages flow immediately so real-time + // messages are never silently swallowed. The initial buffer is flushed + // by setCloudSyncDone / onForwardBackfillDone with ordering preserved. + if !b.client.isCloudSyncDone() { + b.mu.Unlock() + return + } + + if len(b.entries) >= messageBufferMaxSize { + if b.timer != nil { + b.timer.Stop() + b.timer = nil + } + b.mu.Unlock() + go b.flush() // background, consistent with the time.AfterFunc quiet-window path + return + } + + if b.timer != nil { + b.timer.Stop() + } + b.timer = time.AfterFunc(messageBufferQuietWindow, b.flush) + b.mu.Unlock() +} + +// flush sorts all buffered messages by timestamp and dispatches them. +func (b *messageBuffer) flush() { + b.mu.Lock() + if len(b.entries) == 0 { + b.mu.Unlock() + return + } + entries := b.entries + b.entries = nil + if b.timer != nil { + b.timer.Stop() + b.timer = nil + } + b.mu.Unlock() + + sort.Slice(entries, func(i, j int) bool { + return entries[i].timestamp < entries[j].timestamp + }) + + for _, e := range entries { + b.client.dispatchBuffered(e.msg) + } +} + +// stop cancels the flush timer and discards pending messages. +// Called during Disconnect — remaining messages will be re-delivered +// by CloudKit on next sync. +func (b *messageBuffer) stop() { + b.mu.Lock() + if b.timer != nil { + b.timer.Stop() + b.timer = nil + } + b.entries = nil + b.mu.Unlock() +} + +// sendGhostReadReceipt sends a "they read my message" receipt from the ghost +// user with the correct iMessage read timestamp. The standard framework path +// (QueueRemoteEvent → MarkRead) also calls SetReadMarkers, but goes through +// ASIntent.MarkRead which strips BeeperReadExtra["ts"] for non-custom-puppet +// users. By calling SetReadMarkers directly on the ghost's underlying Matrix +// client we include the custom timestamp, giving Hungry the best chance of +// honoring it. Falls back to QueueRemoteEvent if the direct path fails. +func (c *IMClient) sendGhostReadReceipt( + log *zerolog.Logger, + ghostUserID networkid.UserID, + portalKey networkid.PortalKey, + guid string, + readTime time.Time, +) { + ctx := context.Background() + + // Step 1: Get the ghost user. + ghost, err := c.Main.Bridge.GetGhostByID(ctx, ghostUserID) + if err != nil || ghost == nil || ghost.Intent == nil { + log.Warn().Err(err).Str("ghost_user_id", string(ghostUserID)). + Msg("Ghost not found for read receipt, falling back to QueueRemoteEvent") + c.queueGhostReceiptFallback(ghostUserID, portalKey, guid, readTime) + return + } + + // Step 2: Look up the portal to get its Matrix room ID. + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if err != nil || portal == nil || portal.MXID == "" { + log.Debug().Err(err).Str("portal_id", string(portalKey.ID)). + Msg("Portal not found for ghost read receipt, falling back to QueueRemoteEvent") + c.queueGhostReceiptFallback(ghostUserID, portalKey, guid, readTime) + return + } + + // Step 3: Look up the target message in the bridge DB and ensure we target + // the same room as the portal to avoid "target event in different room". + dbMessages, err := c.Main.Bridge.DB.Message.GetAllPartsByID(ctx, portalKey.Receiver, makeMessageID(guid)) + if err != nil || len(dbMessages) == 0 { + log.Debug().Err(err).Str("portal_id", string(portalKey.ID)).Str("guid", guid). + Msg("Target message not in bridge DB, falling back to QueueRemoteEvent with ReadUpTo") + c.queueGhostReceiptFallback(ghostUserID, portalKey, guid, readTime) + return + } + targetMsg := dbMessages[0] + for _, candidate := range dbMessages { + if candidate.HasFakeMXID() { + continue + } + if candidate.Room.ID == portalKey.ID && candidate.Room.Receiver == portalKey.Receiver { + targetMsg = candidate + break + } + } + if targetMsg == nil || targetMsg.HasFakeMXID() { + log.Debug(). + Str("portal_id", string(portalKey.ID)). + Str("guid", guid). + Msg("No usable Matrix event ID for ghost read receipt, falling back to QueueRemoteEvent") + c.queueGhostReceiptFallback(ghostUserID, portalKey, guid, readTime) + return + } + if targetMsg.Room.ID != portalKey.ID || targetMsg.Room.Receiver != portalKey.Receiver { + log.Debug(). + Str("portal_id", string(portalKey.ID)). + Str("target_room_portal", string(targetMsg.Room.ID)). + Str("guid", guid). + Msg("Skipping direct ghost read receipt due to portal/target room mismatch") + c.queueGhostReceiptFallback(ghostUserID, targetMsg.Room, guid, readTime) + return + } + + // Step 4: Type-assert to access the ghost's underlying Matrix client + // and call SetReadMarkers directly (bypasses IsCustomPuppet check so + // BeeperReadExtra["ts"] is included, giving Hungry the iMessage read time). + // Do NOT use SetBeeperInboxState here — Hungry returns HTTP 400 for ghost + // (appservice puppet) users because they have no Beeper inbox. + asIntent, ok := ghost.Intent.(*matrixfmt.ASIntent) + if !ok { + log.Warn().Str("portal_id", string(portalKey.ID)). + Msg("Ghost intent is not *matrix.ASIntent, falling back to QueueRemoteEvent") + c.queueGhostReceiptFallback(ghostUserID, portalKey, guid, readTime) + return + } + + extraData := map[string]any{ + "ts": readTime.UnixMilli(), + } + req := &mautrix.ReqSetReadMarkers{ + Read: targetMsg.MXID, + FullyRead: targetMsg.MXID, + BeeperReadExtra: extraData, + BeeperFullyReadExtra: extraData, + } + err = asIntent.Matrix.SetReadMarkers(ctx, portal.MXID, req) + if err != nil { + log.Warn().Err(err).Str("portal_id", string(portalKey.ID)). + Stringer("event_id", targetMsg.MXID). + Msg("SetReadMarkers failed for ghost, falling back to QueueRemoteEvent") + c.queueGhostReceiptFallback(ghostUserID, portalKey, guid, readTime) + return + } + + log.Info(). + Str("portal_id", string(portalKey.ID)). + Str("guid", guid). + Stringer("event_id", targetMsg.MXID). + Int64("read_time_ms", readTime.UnixMilli()). + Str("read_time", readTime.UTC().Format(time.RFC3339)). + Msg("Set ghost read receipt via SetReadMarkers with correct timestamp") +} + +// queueGhostReceiptFallback sends a ghost read receipt via the standard +// QueueRemoteEvent path. The homeserver may ignore BeeperReadExtra["ts"] +// (showing server time instead), but the receipt itself will still be created. +func (c *IMClient) queueGhostReceiptFallback( + ghostUserID networkid.UserID, + portalKey networkid.PortalKey, + guid string, + readTime time.Time, +) { + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.Receipt{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventReadReceipt, + PortalKey: portalKey, + Sender: bridgev2.EventSender{ + IsFromMe: false, + Sender: ghostUserID, + }, + Timestamp: readTime, + }, + LastTarget: makeMessageID(guid), + ReadUpTo: readTime, + }) +} + +// onForwardBackfillDone is called when a single forward FetchMessages call +// completes (either returning early with no messages, or via CompleteCallback +// after bridgev2 delivers the batch to Matrix). It decrements the bootstrap +// pending counter and, when it reaches exactly 0, flushes the APNs buffer. +// +// Using == 0 (not <= 0) means the flush fires exactly once: the portal whose +// decrement lands at 0. Portals that cause the counter to go negative (e.g. +// a re-sync FetchMessages that we didn't account for) do not trigger a flush. +func (c *IMClient) onForwardBackfillDone() { + remaining := atomic.AddInt64(&c.pendingInitialBackfills, -1) + if remaining == 0 { + log.Info().Msg("All initial forward backfills complete — flushing APNs buffer") + atomic.StoreInt64(&c.apnsBufferFlushedAt, time.Now().UnixMilli()) + if c.msgBuffer != nil { + c.msgBuffer.flush() + } + c.flushPendingPortalMsgs() + + // Re-run ghost name refresh now that all backfill ghosts are in the DB. + // If contacts loaded before backfill finished, the earlier + // refreshGhostNamesFromContacts call (triggered by setContactsReady) + // may have scanned the DB before backfill ghosts existed, leaving them + // with fallback display names. Multi-handle contacts are especially + // affected because canonicalizeDMSender only remaps within the same + // portal, so multi-handle senders still create additional ghosts + // that also need display names set. + c.contactsReadyLock.RLock() + contactsReady := c.contactsReady + c.contactsReadyLock.RUnlock() + if contactsReady { + go c.refreshGhostNamesFromContacts(log.Logger) + } + + // Send StatusKit keysharing invites now that ghosts exist. + // On fresh reset, subscribeAfterInit runs before CloudKit sync + // creates ghosts, so it returns early with no handles. This + // post-backfill call ensures invites go out once we have + // handles to invite. + go c.inviteContactsToStatusSharing(log.Logger) + } +} + +// ============================================================================ +// Lifecycle +// ============================================================================ + +func (c *IMClient) loadSenderGuidsFromDB(log zerolog.Logger) { + ctx := context.Background() + portals, err := c.Main.Bridge.GetAllPortalsWithMXID(ctx) + if err != nil { + log.Warn().Err(err).Msg("Failed to load portals for sender_guid cache") + return + } + + // Migrate portal IDs that contain stale SMS suffixes before populating caches. + c.migrateSmsSuffixPortals(log, ctx, portals) + + loadedGuids := 0 + for _, portal := range portals { + if portal.Receiver != c.UserLogin.ID { + continue // Skip portals for other users + } + if meta, ok := portal.Metadata.(*PortalMetadata); ok { + if meta.SenderGuid != "" { + c.imGroupGuidsMu.Lock() + c.imGroupGuids[string(portal.ID)] = meta.SenderGuid + c.imGroupGuidsMu.Unlock() + loadedGuids++ + } + if meta.IsSms { + c.updatePortalSMS(string(portal.ID), true) + } + // NOTE: Do NOT pre-populate imGroupNames from portal metadata. + // The metadata GroupName can be stale (polluted by previous CloudKit + // sync cycles). Loading it on startup would cause resolveGroupName + // and refreshGroupPortalNamesFromContacts to revert correct room + // names back to stale values. Instead, let imGroupNames be populated + // only by real-time APNs messages (makePortalKey / handleRename). + // Outbound routing (portalToConversation) has its own metadata fallback. + } + } + if loadedGuids > 0 { + log.Info().Int("count", loadedGuids).Msg("Pre-populated sender_guid cache from database") + } +} + +// migrateSmsSuffixPortals re-IDs portals whose IDs contain stale Apple SMS +// suffixes like "(smsft)" or "(smsfp)". After stripSmsSuffix was added to +// portal ID normalization, new lookups produce clean IDs, orphaning existing +// portals that were created with the suffixed form. This runs once at startup +// to reconcile old portal rows with the new normalized format. +func (c *IMClient) migrateSmsSuffixPortals(log zerolog.Logger, ctx context.Context, portals []*bridgev2.Portal) { + migrated := 0 + for _, portal := range portals { + if portal.Receiver != c.UserLogin.ID { + continue + } + oldID := string(portal.ID) + + // Strip SMS suffixes from each member in the (possibly comma-separated) portal ID. + members := strings.Split(oldID, ",") + changed := false + for i, m := range members { + stripped := stripSmsSuffix(m) + if stripped != m { + members[i] = stripped + changed = true + } + } + if !changed { + continue + } + + // Re-sort after stripping to maintain canonical order. + sort.Strings(members) + newID := strings.Join(members, ",") + if newID == oldID { + continue + } + + oldKey := portal.PortalKey + newKey := networkid.PortalKey{ + ID: networkid.PortalID(newID), + Receiver: portal.Receiver, + } + + result, _, err := c.reIDPortalWithCacheUpdate(ctx, oldKey, newKey) + if err != nil { + log.Warn().Err(err). + Str("old_portal_id", oldID). + Str("new_portal_id", newID). + Msg("Failed to migrate SMS-suffixed portal ID") + continue + } + log.Info(). + Str("old_portal_id", oldID). + Str("new_portal_id", newID). + Int("result", int(result)). + Msg("Migrated SMS-suffixed portal ID") + migrated++ + } + if migrated > 0 { + log.Info().Int("count", migrated).Msg("Finished migrating SMS-suffixed portal IDs") + } +} + +// safeRestoreTokenProvider wraps RestoreTokenProvider with a panic recovery. +// Uniffi converts Rust panics to Go panics (CALL_UNEXPECTED_ERROR); without +// recovery here the whole process crashes instead of degrading gracefully. +func safeRestoreTokenProvider( + config *rustpushgo.WrappedOsConfig, + conn *rustpushgo.WrappedApsConnection, + username, hashedPwHex, pet, spdBase64 string, +) (tp *rustpushgo.WrappedTokenProvider, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("RestoreTokenProvider panicked: %v", r) + } + }() + return rustpushgo.RestoreTokenProvider(config, conn, username, hashedPwHex, pet, spdBase64) +} + +func (c *IMClient) Connect(ctx context.Context) { + c.startupTime = time.Now() + log := c.UserLogin.Log.With().Str("component", "imessage").Logger() + c.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) + + rustpushgo.InitLogger() + + // Validate that the software keystore still has the signing keys referenced + // by the saved user state. If the keystore file was deleted/reset while the + // bridge DB kept the old state, every IDS operation would fail with + // "Keystore error Key not found". Detect this early and ask the user to + // re-login instead of producing a cryptic send-time error. + if c.users != nil && !c.users.ValidateKeystore() { + log.Error().Msg("Keystore keys missing for saved user state — clearing stale login, please re-login") + meta := c.UserLogin.Metadata.(*UserLoginMetadata) + meta.IDSUsers = "" + meta.IDSIdentity = "" + meta.APSState = "" + if err := c.UserLogin.Save(ctx); err != nil { + log.Error().Err(err).Msg("Failed to persist cleared login state after key loss") + } + c.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Message: "Signing keys lost — please re-login to iMessage", + }) + return + } + + // Restore token provider from persisted credentials if not already set + if c.tokenProvider == nil || *c.tokenProvider == nil { + meta := c.UserLogin.Metadata.(*UserLoginMetadata) + if meta.AccountUsername != "" && meta.AccountPET != "" && meta.AccountSPDBase64 != "" { + log.Info().Msg("Restoring iCloud TokenProvider from persisted credentials") + tp, err := safeRestoreTokenProvider(c.config, c.connection, + meta.AccountUsername, meta.AccountHashedPasswordHex, + meta.AccountPET, meta.AccountSPDBase64) + if err != nil { + log.Warn().Err(err).Msg("Failed to restore TokenProvider — cloud services unavailable") + } else { + c.tokenProvider = &tp + // Seed the persisted MobileMe delegate so CloudKit / keychain + // ops have something to work with on first use. The wrapper's + // RestoreTokenProvider path intentionally returns a + // WrappedTokenProvider with empty mme_delegate_bytes (see + // pkg/rustpushgo/src/lib.rs::restore_token_provider — "callers + // must seed_mme_delegate_json() from persisted state before + // using keychain/contacts features"), so we seed it here. The + // delegate is whatever was captured during the most recent + // successful login; if it's expired, CloudKit calls will + // surface an auth error and the user can re-login. + if meta.MmeDelegateJSON != "" { + if seedErr := tp.SeedMmeDelegateJson(meta.MmeDelegateJSON); seedErr != nil { + log.Warn().Err(seedErr).Msg("Failed to seed persisted MobileMe delegate — CloudKit unavailable until re-login") + } else { + log.Info().Msg("Seeded persisted MobileMe delegate into restored TokenProvider") + } + } else { + log.Warn().Msg("TokenProvider restored but no persisted MobileMe delegate — CloudKit unavailable until re-login captures a fresh one") + } + } + } + } + + client, err := rustpushgo.NewClient(c.connection, c.users, c.identity, c.config, c.tokenProvider, c, c) + if err != nil { + log.Err(err).Msg("Failed to create rustpush client") + c.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateBadCredentials, + Message: fmt.Sprintf("Failed to connect: %v", err), + }) + return + } + c.client = client + + // Get our handle (precedence: config > login metadata > first handle) + handles := client.GetHandles() + c.allHandles = handles + if len(handles) > 0 { + c.handle = handles[0] + preferred := c.Main.Config.PreferredHandle + if preferred == "" { + if meta, ok := c.UserLogin.Metadata.(*UserLoginMetadata); ok { + preferred = meta.PreferredHandle + } + } + if preferred != "" { + found := false + for _, h := range handles { + if h == preferred { + c.handle = h + found = true + break + } + } + if !found { + log.Warn().Str("preferred", preferred).Strs("available", handles). + Msg("Preferred handle not found among registered handles, using first available") + } + } else { + log.Warn().Strs("available", handles). + Msg("No preferred_handle configured — using first available. Run the install script to select one.") + } + } + + // Persist the selected handle to metadata so it's stable across restarts. + if c.handle != "" { + if meta, ok := c.UserLogin.Metadata.(*UserLoginMetadata); ok && meta.PreferredHandle != c.handle { + meta.PreferredHandle = c.handle + log.Info().Str("handle", c.handle).Msg("Persisted selected handle to metadata") + } + } + + log.Info().Str("selected_handle", c.handle).Strs("handles", handles).Msg("Connected to iMessage") + + if c.Main.Config.VideoTranscoding { + if ffmpeg.Supported() { + log.Info().Msg("Video transcoding enabled (ffmpeg found)") + } else { + log.Warn().Msg("Video transcoding enabled in config but ffmpeg not found — install ffmpeg to enable video conversion") + } + } + + if c.Main.Config.HEICConversion { + log.Info().Msg("HEIC conversion enabled (libheif)") + } + + // Persist state after connect (APS tokens, IDS keys, device ID) + c.persistState(log) + + // Reset StatusKit APNs channel cursors to 1 BEFORE init so the client + // loads the reset state and APNs replays the current presence for each + // contact on the next subscription. Keys are preserved. + if c.client != nil { + c.client.ResetStatuskitCursors() + } + + // Initialize StatusKit presence system (non-fatal — runs in background). + // Once initialized, the Rust receive loop intercepts StatusKit APNs + // messages and invokes OnStatusUpdate for subscribed handles. + go func() { + if c.client == nil { + return + } + // Wrapped in a 30s timeout to prevent silent goroutine hangs if the + // Rust future (StatusKitClient::new → request_topics) never completes. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + log.Warn().Interface("panic", r).Msg("StatusKit init panicked — treating as init failure") + done <- fmt.Errorf("statuskit init panicked: %v", r) + } + }() + done <- c.client.InitStatuskit(c) + }() + subscribeAfterInit := func(err error) { + if err != nil { + log.Warn().Err(err).Msg("StatusKit initialization failed — presence updates unavailable") + return + } + log.Info().Msg("StatusKit presence system initialized") + // subscribeToContactPresence may have raced ahead of InitStatuskit + // and failed with "StatusKit not initialized". Re-run it now that + // the StatusKit client is guaranteed to be ready. + c.subscribeToContactPresence(log) + // Send our StatusKit key to known contacts to trigger key exchange. + // Without this, contacts' devices never send us their keys and + // presence channels are never created. + c.inviteContactsToStatusSharing(log) + } + select { + case err := <-done: + subscribeAfterInit(err) + case <-ctx.Done(): + // The Rust FFI call (StatusKitClient::new → request_topics) is + // taking longer than 30s. Don't block the connect flow, but keep + // a goroutine alive to subscribe as soon as it eventually finishes. + // The Rust side WILL set shared_statuskit/status_callback once + // StatusKitClient::new completes; we just need to subscribe then. + log.Warn().Msg("StatusKit initialization taking >30s — will subscribe when ready") + go func() { subscribeAfterInit(<-done) }() + } + }() + + // Pre-populate sender_guid cache from existing portal metadata + go c.loadSenderGuidsFromDB(log) + + // Start periodic state saver (every 5 minutes) + c.stopChan = make(chan struct{}) + c.msgBuffer = &messageBuffer{client: c} + go c.periodicStateSave(log) + go c.periodicStatusSharingReinvite(log) + go c.startSharedStreamsWatcher(log) + + // Ensure shared-profile schema and hydrate the in-memory cache from the + // DB. Runs on every bridge start so existing installs pick up the table + // without needing a fresh login or reconfiguration. + if err := c.ensureSharedProfileSchema(context.Background()); err != nil { + log.Warn().Err(err).Msg("Failed to ensure shared_profiles schema") + } else { + c.loadSharedProfilesIntoCache(context.Background(), log) + // Independent of CardDAV: push cached state to ghosts immediately + // and re-fetch each row from CloudKit. Decoupled from + // setContactsReady so a slow MobileMe-delegate retry doesn't gate + // the share-profile path (it only depends on ProfilesClient / + // keychain init, not on contacts). + go c.refreshSharedProfilesOnConnect(log) + } + + // Ensure CloudKit backfill schema/storage is available. + cloudStoreReady := true + if err = c.ensureCloudSyncStore(context.Background()); err != nil { + cloudStoreReady = false + log.Error().Err(err).Msg("Failed to initialize cloud backfill store") + } else { + // Fix any group messages that were mis-routed to the wrong portal + // (e.g., self-chat) due to the ";+;" CloudChatId routing bug. + if healed, healErr := c.cloudStore.healMisroutedGroupMessages(context.Background()); healErr != nil { + log.Warn().Err(healErr).Msg("Failed to heal mis-routed group messages") + } else if healed > 0 { + log.Info().Int("healed", healed).Msg("Healed mis-routed group messages at startup") + } + } + c.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) + + // Eagerly silence bridge bot push notifications so the rule is in place + // before any bot message (StatusKit notices, admin messages, etc.) fires. + // Run in a goroutine to avoid blocking Connect on the homeserver round-trip. + go c.ensureBotPushRuleSilenced(ctx) + + // Set up contact source: external CardDAV > local macOS > iCloud CardDAV + if c.Main.Config.CardDAV.IsConfigured() { + extContacts := newExternalCardDAVClient(c.Main.Config.CardDAV, log) + if extContacts != nil { + c.contacts = extContacts + log.Info().Str("email", c.Main.Config.CardDAV.Email).Msg("Using external CardDAV for contacts") + if syncErr := c.contacts.SyncContacts(log); syncErr != nil { + log.Warn().Err(syncErr).Msg("Initial external CardDAV sync failed") + } else { + c.setContactsReady(log) + } + go c.periodicCloudContactSync(log) + } else { + log.Warn().Msg("External CardDAV configured but failed to initialize") + } + } else if c.Main.Config.UseChatDBBackfill() { + // Chat.db mode: use local macOS Contacts (no iCloud dependency). + c.contacts = newLocalContactSource(log) + if c.contacts != nil { + if syncErr := c.contacts.SyncContacts(log); syncErr != nil { + log.Warn().Err(syncErr).Msg("Initial local contacts sync failed") + } else { + c.setContactsReady(log) + } + } else { + log.Warn().Msg("Local macOS contacts unavailable — contact names will not be resolved") + } + } else { + cloudContacts := newCloudContactsClient(c.client, log) + if cloudContacts != nil { + c.contacts = cloudContacts + log.Info().Str("url", cloudContacts.baseURL).Msg("Cloud contacts available (iCloud CardDAV)") + if syncErr := cloudContacts.SyncContacts(log); syncErr != nil { + log.Warn().Err(syncErr).Msg("Initial CardDAV sync failed") + } else { + c.setContactsReady(log) + c.persistMmeDelegate(log) + } + go c.periodicCloudContactSync(log) + } else { + // No cloud contacts available — retry periodically. + // The MobileMe delegate may have been expired on startup; + // periodic retries will pick up a fresh delegate once available. + log.Warn().Msg("Cloud contacts unavailable on startup, will retry periodically") + go c.retryCloudContacts(log) + } + } + + if c.Main.Config.UseChatDBBackfill() { + // Chat.db backfill: read local macOS Messages database + c.chatDB = openChatDB(log) + if c.chatDB != nil { + log.Info().Msg("Chat.db available for backfill") + go c.runChatDBInitialSync(log) + } else { + log.Warn().Msg("Chat.db backfill configured but chat.db not accessible") + } + // No CloudKit gate needed — open immediately + c.setCloudSyncDone() + } else if cloudStoreReady && c.Main.Config.UseCloudKitBackfill() { + c.startCloudSyncController(log) + } else { + if !c.Main.Config.CloudKitBackfill { + log.Info().Msg("CloudKit backfill disabled by config — skipping cloud sync") + } + // No CloudKit — open the APNs portal-creation gate immediately + // so real-time messages can create portals without waiting. + c.setCloudSyncDone() + } + +} + +func (c *IMClient) Disconnect() { + if c.msgBuffer != nil { + c.msgBuffer.stop() + } + if c.stopChan != nil { + close(c.stopChan) + c.stopChan = nil + } + if c.client != nil { + c.client.Stop() + c.client.Destroy() + c.client = nil + } + if c.chatDB != nil { + c.chatDB.Close() + c.chatDB = nil + } +} + +func (c *IMClient) IsLoggedIn() bool { + return c.client != nil +} + +func (c *IMClient) LogoutRemote(ctx context.Context) { + c.Disconnect() +} + +func (c *IMClient) IsThisUser(_ context.Context, userID networkid.UserID) bool { + return c.isMyHandle(string(userID)) +} + +func (c *IMClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { + if portal.RoomType == database.RoomTypeDM { + return capsDM + } + return caps +} + +// ============================================================================ +// Callbacks from rustpush +// ============================================================================ + +// statusKitModeLabel converts a Focus/DND mode identifier to a human-readable +// label for use in bridge bot notices. +func statusKitModeLabel(mode *string) string { + if mode == nil || *mode == "" { + return "" + } + switch *mode { + case "com.apple.donotdisturb.mode.default": + return "Do Not Disturb" + case "com.apple.donotdisturb.mode.sleep": + return "Sleep" + default: + // Focus modes use identifiers like "com.apple.focus.mode.personal" + // or user-defined UUIDs. Humanise what we can; fall back to a generic label. + if strings.Contains(*mode, "focus") { + return "Focus" + } + return "Do Not Disturb" + } +} + +// OnStatusUpdate is called by StatusKit when a contact's presence changes. +// Posts an m.notice in the contact DM and the last active shared group so the +// user sees status inline where they're actively chatting, similar to Apple's +// in-conversation "has notifications silenced" affordance. +// Also sets Matrix ghost presence for clients that render it. +// +// IMPORTANT: this function is called from a Rust FFI callback (inside the APNs +// receive loop). It must return quickly — any blocking work, especially any +// call back into Rust (e.g. ResolveHandle), would block the receive loop and +// can deadlock on a single-threaded tokio runtime. All non-trivial work is +// therefore dispatched to a goroutine immediately after the fast dedup check. +func (c *IMClient) OnStatusUpdate(user string, mode *string, available bool) { + log := c.UserLogin.Log.With(). + Str("component", "statuskit"). + Str("user", user). + Logger() + + // Suppress duplicate notices: only act when the state actually changes. + // Apple sends available=true for BOTH DND-on and DND-off; the real + // discriminator is whether mode is set. Track by mode string so that + // DND→Sleep (two different silenced modes) still fires two notices. + modeKey := "available" + if mode != nil && *mode != "" { + modeKey = *mode + } + // kvKeyFor returns the KV store key used to persist StatusKit state for + // a given user handle across bridge restarts. + kvKeyFor := func(u string) database.Key { + return database.Key("statuskit.presence." + u) + } + + if prev, loaded := c.statusKitPresence.Load(user); loaded { + if prev.(string) == modeKey { + log.Debug().Bool("available", available).Str("mode", modeKey).Msg("StatusKit: presence unchanged, skipping notice") + return + } + } else { + // Not in memory (first call this session for this user): check KV store + // for the state persisted from the previous bridge run. If it matches + // the incoming state, warm the in-memory cache and skip the notice so + // users don't see a flood of "has notifications turned on/off" messages + // every time the bridge restarts. + if c.Main.Bridge.DB.KV.Get(context.Background(), kvKeyFor(user)) == modeKey { + c.statusKitPresence.Store(user, modeKey) + log.Debug().Bool("available", available).Str("mode", modeKey).Msg("StatusKit: presence unchanged (restored from DB), skipping notice") + return + } + } + + log.Info(). + Bool("available", available). + Str("mode_key", modeKey). + Msg("StatusKit: received presence update — dispatching to goroutine") + + // Capture values for the goroutine (mode is a pointer; copy the string). + var modeCopy *string + if mode != nil { + s := *mode + modeCopy = &s + } + + // Optimistic dedup: store the new modeKey BEFORE dispatching so that + // rapid-fire re-deliveries of the same state (e.g. APNs replay on + // reconnect) are caught by the check above before the goroutine has + // had a chance to complete and store the key itself. + // Also persist to the KV store so the dedup survives a bridge restart. + c.statusKitPresence.Store(user, modeKey) + c.Main.Bridge.DB.KV.Set(context.Background(), kvKeyFor(user), modeKey) + + // Dispatch ALL blocking work — ghost lookup, portal resolution, IDS + // fallback, and Matrix send — to a goroutine so this callback returns + // to Rust immediately and does not block the APNs receive loop. + go func() { + ctx := context.Background() + + // Apple sends available=true for both DND-on and DND-off; the mode + // field is the real signal. mode non-nil/non-empty = DND/Focus active. + silenced := modeCopy != nil && *modeCopy != "" + presence := event.PresenceOnline + statusMsg := "" + if silenced { + presence = event.PresenceUnavailable + statusMsg = *modeCopy + } + + // findPortal returns an existing, active portal or nil. + findPortal := func(id networkid.PortalID) *bridgev2.Portal { + p, err := c.Main.Bridge.GetExistingPortalByKey(ctx, networkid.PortalKey{ + ID: id, + Receiver: c.UserLogin.ID, + }) + if err != nil || p == nil || p.MXID == "" { + return nil + } + return p + } + + normalizedUser := normalizeIdentifierForPortalID(user) + + // Resolve the portal AND the ghost handle to use for sending. + // + // For mailto: handles the ghost often has no MXID (never joined a + // Matrix room) so ghost.Intent is nil. We must use the tel: ghost + // instead — it IS in the portal and has a valid MXID. portal.ID is + // the tel: handle, so we derive the ghost from the resolved portal. + // + // Resolution order for mailto: handles: + // (1) address-book phones (fast, no network) + // (2) IDS correlation — self-bounding 5s tokio timeout in Rust + // (3) mailto: portal itself as absolute last resort + var portal *bridgev2.Portal + + if strings.HasPrefix(normalizedUser, "mailto:") { + // (1) Address-book. + contact := c.lookupContact(user) + if contact != nil { + for _, altID := range contactPortalIDs(contact) { + if !strings.HasPrefix(altID, "tel:") { + continue + } + if p := findPortal(networkid.PortalID(altID)); p != nil { + log.Info().Str("tel_handle", altID).Msg("StatusKit: resolved mailto→tel via address book") + portal = p + break + } + } + } + + // (2) IDS correlation (self-bounding, safe to call from goroutine). + if portal == nil { + if altPortal := c.resolveStatusPortalViaIDS(ctx, log, user); altPortal != nil { + log.Info().Str("user", user).Msg("StatusKit: resolved mailto→tel via IDS correlation") + portal = altPortal + } + } + + // (3) mailto: portal as last resort. + if portal == nil { + if p := findPortal(networkid.PortalID(normalizedUser)); p != nil { + log.Info().Str("portal_id", normalizedUser).Msg("StatusKit: using mailto: portal as last resort") + portal = p + } + } + } else { + portalID := c.resolveContactPortalID(normalizedUser) + portalID = c.resolveExistingDMPortalID(string(portalID)) + portal = findPortal(portalID) + + if portal == nil { + contact := c.lookupContact(user) + if contact != nil { + for _, altID := range contactPortalIDs(contact) { + if altID == normalizedUser { + continue + } + altPortalID := c.resolveContactPortalID(altID) + altPortalID = c.resolveExistingDMPortalID(string(altPortalID)) + if p := findPortal(altPortalID); p != nil { + log.Info().Str("alt_handle", altID).Msg("StatusKit: resolved DM portal via contact store") + portal = p + break + } + } + } + } + } + + if portal == nil || portal.MXID == "" { + log.Warn(). + Str("user", normalizedUser). + Msg("StatusKit: no DM portal found for presence notice") + return + } + + // Use the ghost keyed to the portal's handle — for a tel: portal this + // is the tel: ghost which has an MXID and is a member of the room. + // The mailto: ghost typically has no MXID (never joined a room) so + // ghost.Intent would be nil. Prefer portal ghost; fall back to the + // mailto: ghost if the portal ghost is unavailable. + ghostHandle := string(portal.ID) + ghost, err := c.Main.Bridge.GetGhostByID(ctx, networkid.UserID(ghostHandle)) + if err != nil || ghost == nil || ghost.Intent == nil { + // Fallback: try the original mailto: ghost. + ghost, err = c.Main.Bridge.GetGhostByID(ctx, networkid.UserID(user)) + if err != nil || ghost == nil || ghost.Intent == nil { + log.Warn().Err(err).Str("portal_handle", ghostHandle).Str("mailto_handle", user). + Msg("StatusKit: no usable ghost found — skipping notice") + return + } + log.Debug().Str("ghost", user).Msg("StatusKit: using mailto: ghost as fallback") + } else { + log.Debug().Str("ghost", ghostHandle).Msg("StatusKit: using portal ghost (tel:)") + } + + // Set Matrix presence using whichever ghost we resolved. + if asIntent, ok := ghost.Intent.(*matrixfmt.ASIntent); ok { + if err := asIntent.Matrix.SetPresence(ctx, mautrix.ReqPresence{ + Presence: presence, + StatusMsg: statusMsg, + }); err != nil { + log.Warn().Err(err).Msg("StatusKit: failed to set Matrix presence for ghost") + } + } + + log.Info(). + Str("normalized_user", normalizedUser). + Str("portal_id", string(portal.ID)). + Str("ghost_handle", ghostHandle). + Msg("StatusKit: sending presence notice to active conversations") + + name := ghost.Name + if name == "" { + name = ghostHandle + } + var notice string + if silenced { + label := statusKitModeLabel(modeCopy) + if label != "" { + notice = "🔕 " + name + " has notifications silenced (" + label + ")." + } else { + notice = "🔕 " + name + " has notifications silenced." + } + } else { + notice = name + " has notifications turned on." + } + + targetPortals := map[networkid.PortalID]*bridgev2.Portal{ + portal.ID: portal, + } + candidateHandles := map[string]bool{ + normalizedUser: true, + ghostHandle: true, + } + if contact := c.lookupContact(user); contact != nil { + for _, altID := range contactPortalIDs(contact) { + candidateHandles[altID] = true + } + } + for handleID := range candidateHandles { + if handleID == "" { + continue + } + c.lastGroupForMemberMu.RLock() + groupKey, ok := c.lastGroupForMember[handleID] + c.lastGroupForMemberMu.RUnlock() + if !ok || groupKey.ID == "" { + continue + } + groupPortal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, groupKey) + if err == nil && groupPortal != nil && groupPortal.MXID != "" { + targetPortals[groupPortal.ID] = groupPortal + } + } + + sendStatusNotice := func(targetPortal *bridgev2.Portal) error { + if targetPortal == nil || targetPortal.MXID == "" { + return fmt.Errorf("invalid target portal") + } + + // Anchor each notice timestamp 1ms before the last real iMessage in + // the target room so room ordering doesn't jump on passive status updates. + noticeTS := time.Now().Add(-1 * time.Millisecond) + if lastMsg, dbErr := c.Main.Bridge.DB.Message.GetLastNonFakePartAtOrBeforeTime( + ctx, targetPortal.PortalKey, time.Now(), + ); dbErr != nil { + log.Warn().Err(dbErr).Str("portal_id", string(targetPortal.ID)). + Msg("StatusKit: failed to query last message timestamp, using now-1ms") + } else if lastMsg != nil && !lastMsg.Timestamp.IsZero() && + time.Since(lastMsg.Timestamp) < 24*time.Hour { + noticeTS = lastMsg.Timestamp.Add(-1 * time.Millisecond) + } + + if c.Main.Bridge.Matrix.GetCapabilities().BatchSending { + batchEvt := &event.Event{ + Type: event.EventMessage, + Sender: c.Main.Bridge.Bot.GetMXID(), + RoomID: targetPortal.MXID, + Timestamp: noticeTS.UnixMilli(), + Content: event.Content{ + Parsed: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: notice, + Mentions: &event.Mentions{}, + }, + Raw: map[string]any{ + "com.beeper.action_message": map[string]any{ + "type": "presence_update", + }, + }, + }, + } + batchReq := &mautrix.ReqBeeperBatchSend{ + Forward: true, + SendNotification: false, + Events: []*event.Event{batchEvt}, + } + if dp := c.UserLogin.User.DoublePuppet(ctx); dp != nil { + batchReq.MarkReadBy = dp.GetMXID() + } + _, sendErr := c.Main.Bridge.Matrix.BatchSend(ctx, targetPortal.MXID, batchReq, nil) + return sendErr + } + + _, sendErr := c.Main.Bridge.Bot.SendMessage(ctx, targetPortal.MXID, event.EventMessage, &event.Content{ + Parsed: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: notice, + Mentions: &event.Mentions{}, + }, + Raw: map[string]any{ + "com.beeper.action_message": map[string]any{ + "type": "presence_update", + }, + }, + }, &bridgev2.MatrixSendExtra{Timestamp: noticeTS}) + return sendErr + } + + sent := 0 + for _, targetPortal := range targetPortals { + if err := sendStatusNotice(targetPortal); err != nil { + log.Warn().Err(err).Str("portal_mxid", string(targetPortal.MXID)).Msg("StatusKit: failed to send presence notice") + continue + } + sent++ + log.Info().Str("portal_mxid", string(targetPortal.MXID)).Msg("StatusKit: sent silent presence notice") + } + if sent == 0 { + log.Warn().Msg("StatusKit: failed to send presence notice to any conversation") + } + }() +} + +// ensureBotPushRuleSilenced installs push rules via the double puppet so +// that homeservers that respect Matrix push rules suppress notifications from +// the bridge bot. Called eagerly at Connect() time as a one-shot install. +// +// Note: on Beeper/Hungry this is a no-op in practice — Hungry's proprietary +// push gateway ignores Matrix push rules for DM rooms. Push suppression for +// Hungry is instead achieved via ReqBeeperBatchSend.SendNotification:false +// in the OnStatusUpdate send path. +// +// Two rules are installed for belt-and-suspenders coverage: +// - An override rule (evaluated first, before any DM-room override rules) +// with an event_match condition on the sender field. +// - A sender rule (evaluated last) as a secondary catch-all. +// +// Both rules are durable on the homeserver and persist across restarts. +// statusKitBotRulePushed guards against redundant API calls within a session. +// A no-op if the double puppet is not available or the assertion fails. +func (c *IMClient) ensureBotPushRuleSilenced(ctx context.Context) { + if c.statusKitBotRulePushed.Load() { + return + } + dp := c.UserLogin.User.DoublePuppet(ctx) + if dp == nil { + return + } + asIntent, ok := dp.(*matrixfmt.ASIntent) + if !ok { + return + } + log := c.UserLogin.Log.With().Str("component", "statuskit").Logger() + botMXID := c.Main.Bridge.Bot.GetMXID().String() + + // Override rule (highest priority — evaluated before DM override rules). + overrideRuleID := botMXID + ".dont_notify" + err := asIntent.Matrix.PutPushRule(ctx, "global", pushrules.OverrideRule, overrideRuleID, + &mautrix.ReqPutPushRule{ + Conditions: []pushrules.PushCondition{ + { + Kind: pushrules.KindEventMatch, + Key: "sender", + Pattern: botMXID, + }, + }, + Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, + }) + if err != nil { + log.Debug().Err(err).Str("bot_mxid", botMXID).Msg("Failed to install bot override push rule") + return + } + + // Sender rule (secondary catch-all for homeservers without override support). + err = asIntent.Matrix.PutPushRule(ctx, "global", pushrules.SenderRule, botMXID, + &mautrix.ReqPutPushRule{ + Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, + }) + if err != nil { + log.Debug().Err(err).Str("bot_mxid", botMXID).Msg("Failed to install bot sender push rule") + // Override rule succeeded; treat as partial success. + } + + c.statusKitBotRulePushed.Store(true) + log.Debug().Str("bot_mxid", botMXID).Msg("Bot push rules installed — bridge bot messages will not notify") +} + +// resolveStatusPortalViaIDS is the last-resort portal resolver for StatusKit +// presence. When a contact's iCloud Apple ID (e.g. mailto:aap724@icloud.com) +// doesn't appear in the contact store but still shares a person with a handle +// we do have a portal for (e.g. tel:+12012337620), the only link between them +// lives in IDS — specifically the sender_correlation_identifier returned by a +// Madrid lookup. We batch-lookup the incoming handle plus every known ghost in +// a single IDS query, then match on correlation ID to find the aliased portal. +// Results are memoized in statusKitPortalCache so we only pay the IDS round +// trip once per unresolved handle per session. +func (c *IMClient) resolveStatusPortalViaIDS(ctx context.Context, log zerolog.Logger, user string) *bridgev2.Portal { + if c.client == nil { + return nil + } + if cached, ok := c.statusKitPortalCache.Load(user); ok { + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, networkid.PortalKey{ + ID: cached.(networkid.PortalID), + Receiver: c.UserLogin.ID, + }) + if err == nil && portal != nil && portal.MXID != "" { + return portal + } + } + + rows, err := c.Main.Bridge.DB.RawDB.QueryContext(ctx, "SELECT id FROM ghost WHERE bridge_id=$1", c.Main.Bridge.ID) + if err != nil { + log.Warn().Err(err).Msg("StatusKit IDS fallback: failed to query ghosts") + return nil + } + var knownHandles []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + if id == user { + continue + } + knownHandles = append(knownHandles, id) + } + if err := rows.Err(); err != nil { + log.Warn().Err(err).Msg("StatusKit IDS fallback: ghost row iteration error") + } + rows.Close() + if len(knownHandles) == 0 { + return nil + } + + // Fast path: check the in-memory IDS cache populated from message + // processing. No network call, no blocking. If the correlation ID is + // already cached (common when the contact has sent messages before), + // this resolves instantly and we skip the slow validate_targets call. + if aliases := c.client.ResolveHandleCached(user, knownHandles); len(aliases) > 0 { + log.Info().Str("user", user).Int("aliases", len(aliases)).Msg("StatusKit IDS fallback: resolved from cache (no network)") + if portal := c.findPortalForAliases(ctx, log, user, aliases); portal != nil { + return portal + } + } + + // Slow path: validate_targets queries Apple IDS. Can block or hang; + // caller is responsible for applying a timeout. + aliases, err := c.client.ResolveHandle(user, knownHandles) + if err != nil { + log.Warn().Err(err).Str("user", user).Msg("StatusKit IDS fallback: ResolveHandle failed") + return nil + } + return c.findPortalForAliases(ctx, log, user, aliases) +} + +// findPortalForAliases iterates aliases returned by IDS correlation and +// returns the first portal found. Prefers tel: aliases over mailto: so the +// presence notice lands in the phone portal rather than the email portal. +func (c *IMClient) findPortalForAliases(ctx context.Context, log zerolog.Logger, user string, aliases []string) *bridgev2.Portal { + // Two-pass: tel: first, then anything else. + for _, preferTel := range []bool{true, false} { + for _, alias := range aliases { + if alias == user { + continue + } + if preferTel != strings.HasPrefix(alias, "tel:") { + continue + } + aliasPortalID := c.resolveContactPortalID(alias) + aliasPortalID = c.resolveExistingDMPortalID(string(aliasPortalID)) + altPortal, altErr := c.Main.Bridge.GetExistingPortalByKey(ctx, networkid.PortalKey{ + ID: aliasPortalID, + Receiver: c.UserLogin.ID, + }) + if altErr == nil && altPortal != nil && altPortal.MXID != "" { + log.Info(). + Str("original", user). + Str("alias", alias). + Str("resolved_portal_id", string(aliasPortalID)). + Msg("StatusKit: resolved DM portal via IDS correlation") + c.statusKitPortalCache.Store(user, aliasPortalID) + return altPortal + } + } + } + return nil +} + +// OnKeysReceived is called by StatusKit when a key-sharing message arrives. +// New encryption keys mean we can now subscribe to APNs presence channels +// for handles that previously had no keys. Re-subscribe to pick them up. +func (c *IMClient) OnKeysReceived() { + log := c.UserLogin.Log.With(). + Str("component", "statuskit"). + Logger() + log.Info().Msg("StatusKit: key-sharing message received — re-subscribing to presence") + go c.subscribeToContactPresence(log) +} + +// OnMessage is called by rustpush when a message is received via APNs. +func (c *IMClient) OnMessage(msg rustpushgo.WrappedMessage) { + log := c.UserLogin.Log.With(). + Str("component", "imessage"). + Str("msg_uuid", msg.Uuid). + Logger() + // Send delivery receipt if requested + if msg.SendDelivered && msg.Sender != nil && !msg.IsDelivered && !msg.IsReadReceipt { + go func() { + defer func() { + if r := recover(); r != nil { + log.Error().Interface("panic", r).Str("stack", string(debug.Stack())).Msg("Panic in SendDeliveryReceipt") + } + }() + conv := c.makeConversation(msg.Participants, msg.GroupName) + if c.client == nil { + return + } + if err := c.client.SendDeliveryReceipt(conv, c.handle); err != nil { + log.Warn().Err(err).Msg("Failed to send delivery receipt") + } + }() + } + + if msg.IsDelivered { + c.handleDeliveryReceipt(log, msg) + return + } + + if msg.IsReadReceipt { + c.handleReadReceipt(log, msg) + return + } + if msg.IsTyping { + c.handleTyping(log, msg) + return + } + if msg.IsError { + log.Warn(). + Str("for_uuid", ptrStringOr(msg.ErrorForUuid, "")). + Uint64("status", ptrUint64Or(msg.ErrorStatus, 0)). + Str("status_str", ptrStringOr(msg.ErrorStatusStr, "")). + Msg("Received iMessage error") + return + } + if msg.IsPeerCacheInvalidate { + log.Debug().Msg("Peer cache invalidated") + return + } + if msg.IsMoveToRecycleBin || msg.IsPermanentDelete { + c.handleChatDelete(log, msg) + return + } + if msg.IsRecoverChat { + c.handleChatRecover(log, msg) + return + } + // Unsends bypass the buffer — they need to apply immediately and + // don't contribute to chat ordering. + if msg.IsUnsend { + c.handleUnsend(log, msg) + return + } + // Rename, participant changes, and group photo changes bypass the buffer + // — they're control events, not content messages. + if msg.IsRename { + c.handleRename(log, msg) + return + } + if msg.IsParticipantChange { + c.handleParticipantChange(log, msg) + return + } + if msg.IsIconChange { + go c.handleIconChange(log, msg) + return + } + if msg.IsShareProfile && msg.Sender != nil && *msg.Sender != "" { + go c.handleSharedProfile(log, msg) + // Only swallow the message when it's a standalone profile-sharing + // control event (Message::ShareProfile / Message::UpdateProfile). + // iOS also piggybacks profile keys on regular text messages and + // reactions via the embedded_profile field — those still need to + // flow through the buffer so the user actually sees the message. + isStandalone := msg.Text == nil && !msg.IsTapback && !msg.IsEdit && !msg.IsUnsend + if isStandalone { + return + } + } + // Profile-sharing control messages without an embedded CloudKit record: + // log only so we can see whether peers are flipping share_contacts / + // updating their dismissed list. No state to apply on the bridge side. + if msg.IsUpdateProfile && !msg.IsShareProfile { + sender := "" + if msg.Sender != nil { + sender = *msg.Sender + } + shareContacts := false + if msg.UpdateProfileShareContacts != nil { + shareContacts = *msg.UpdateProfileShareContacts + } + log.Info(). + Str("sender", sender). + Bool("share_contacts", shareContacts). + Msg("Received UpdateProfile without embedded CloudKit record") + return + } + if msg.IsUpdateProfileSharing { + sender := "" + if msg.Sender != nil { + sender = *msg.Sender + } + log.Info(). + Str("sender", sender). + Int("dismissed", len(msg.UpdateProfileSharingDismissed)). + Int("all", len(msg.UpdateProfileSharingAll)). + Msg("Received UpdateProfileSharing") + return + } + + // "Notify Anyway" — sender deliberately broke through our Focus/DND. + // Post a silent bot notice in the relevant room so the user can see it. + if msg.IsNotifyAnyways { + if c.cloudStore != nil { + if known, _ := c.cloudStore.hasMessageUUID(context.Background(), msg.Uuid); known { + return + } + if err := c.cloudStore.persistMessageUUID(context.Background(), msg.Uuid, "", int64(msg.TimestampMs), false); err != nil { + log.Warn().Err(err).Str("uuid", msg.Uuid).Msg("Failed to persist NotifyAnyway UUID; duplicates possible on restart") + } + } + go c.handleNotifyAnyways(log, msg) + return + } + // Transcript background — someone set or cleared the iMessage chat wallpaper. + if msg.IsSetTranscriptBackground { + if c.cloudStore != nil { + if known, _ := c.cloudStore.hasMessageUUID(context.Background(), msg.Uuid); known { + return + } + if err := c.cloudStore.persistMessageUUID(context.Background(), msg.Uuid, "", int64(msg.TimestampMs), false); err != nil { + log.Warn().Err(err).Str("uuid", msg.Uuid).Msg("Failed to persist SetTranscriptBackground UUID; duplicates possible on restart") + } + } + go c.handleTranscriptBackground(log, msg) + return + } + + // Buffer regular messages, tapbacks, and edits for timestamp-based + // reordering. APNs delivers messages grouped by sender rather than + // interleaved chronologically; the buffer sorts them before dispatch. + if c.msgBuffer != nil { + c.msgBuffer.add(msg) + } else { + c.dispatchBuffered(msg) + } +} + +// dispatchBuffered routes a message that has been through the reorder buffer +// to its appropriate handler. +func (c *IMClient) dispatchBuffered(msg rustpushgo.WrappedMessage) { + log := c.UserLogin.Log.With(). + Str("component", "imessage"). + Str("msg_uuid", msg.Uuid). + Logger() + + if msg.IsTapback { + c.handleTapback(log, msg) + return + } + if msg.IsEdit { + c.handleEdit(log, msg) + return + } + + c.handleMessage(log, msg) +} + +// flushPendingPortalMsgs replays messages that were held during the CloudKit +// sync window because their portals didn't exist yet. Called by setCloudSyncDone +// after the sync gate opens, so handleMessage will see createPortal=true. +func (c *IMClient) flushPendingPortalMsgs() { + c.pendingPortalMsgsMu.Lock() + held := c.pendingPortalMsgs + c.pendingPortalMsgs = nil + c.pendingPortalMsgsMu.Unlock() + + if len(held) == 0 { + return + } + + log := c.UserLogin.Log.With().Str("component", "imessage").Logger() + log.Info().Int("count", len(held)).Msg("Replaying held messages after CloudKit sync completion") + + // Sort held messages by timestamp so they replay in chronological order. + sort.Slice(held, func(i, j int) bool { + return held[i].TimestampMs < held[j].TimestampMs + }) + + for _, msg := range held { + msgLog := log.With().Str("msg_uuid", msg.Uuid).Logger() + c.handleMessage(msgLog, msg) + } +} + +// reviveDeletedPortalShell clears chat-level deleted state without restoring +// soft-deleted transcript rows. The chat becomes live again so a new portal +// can be created, while old message UUIDs remain soft-deleted for stale-echo +// suppression. OpenBubbles unconditionally revives on any incoming message; +// we gate on tail-timestamp to avoid APNs replay false-positives. +func (c *IMClient) reviveDeletedPortalShell(ctx context.Context, portalID string) error { + if c.cloudStore != nil { + if err := c.cloudStore.undeleteCloudChatByPortalID(ctx, portalID); err != nil { + return err + } + } + c.recentlyDeletedPortalsMu.Lock() + delete(c.recentlyDeletedPortals, portalID) + c.recentlyDeletedPortalsMu.Unlock() + return nil +} + +// UpdateUsers is called when IDS keys are refreshed. +// NOTE: This callback runs on the Tokio async runtime thread. We must NOT +// make blocking FFI calls back into Rust (e.g. connection.State()) on this +// thread or the runtime will panic with "Cannot block the current thread +// from within a runtime". Spawn a goroutine so the callback returns +// immediately and the blocking work happens on a regular OS thread. +func (c *IMClient) UpdateUsers(users *rustpushgo.WrappedIdsUsers) { + c.users = users + + go func() { + log := c.UserLogin.Log.With().Str("component", "imessage").Logger() + // Persist all state (APS tokens, IDS keys, identity, device ID) — not just + // IDSUsers — so a crash between periodic saves doesn't lose APS state. + c.persistState(log) + log.Debug().Msg("IDS users updated, full state persisted") + }() +} + +// ============================================================================ +// Incoming message handlers +// ============================================================================ + +func (c *IMClient) handleMessage(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + if c.wasUnsent(msg.Uuid) { + log.Debug().Str("uuid", msg.Uuid).Msg("Suppressing re-delivery of unsent message") + return + } + if c.wasSmsReactionEcho(msg.Uuid) { + log.Debug().Str("uuid", msg.Uuid).Msg("Suppressing SMS reaction echo") + return + } + + // Skip APNs messages that were already bridged (e.g. via CloudKit backfill + // or a previous session). After the initial backfill completes and the APNs + // buffer flushes, delayed APNs deliveries can arrive with IsStoredMessage=false + // for messages that CloudKit already bridged. Check the Bridge DB for any + // message whose UUID is already known to prevent duplicates. + if msg.Uuid != "" { + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + if dbMsgs, err := c.Main.Bridge.DB.Message.GetAllPartsByID( + context.Background(), c.UserLogin.ID, makeMessageID(msg.Uuid), + ); err == nil && len(dbMsgs) > 0 { + // Only skip if the existing message is in the SAME portal. + // Apple's SMS relay can reuse UUIDs across different short-code + // conversations, causing false-positive dedup drops. + if dbMsgs[0].Room.ID == portalKey.ID { + log.Debug().Str("uuid", msg.Uuid).Bool("is_stored", msg.IsStoredMessage).Msg("Skipping message already in bridge DB") + return + } + log.Info().Str("uuid", msg.Uuid). + Str("existing_portal", string(dbMsgs[0].Room.ID)). + Str("new_portal", string(portalKey.ID)). + Msg("UUID collision across portals — allowing message through") + } + // Also check for UUID_* suffix variants (e.g. UUID_att0, UUID_att1, UUID_avid). + // Two past regressions left image messages in the bridge DB with suffixed + // IDs instead of the base UUID: + // + // 1. Pre-0755816: the attachment-index condition used the raw msg.Text + // value (non-empty for "\ufffc" placeholder) instead of the stripped + // form, so ALL image-only APNs messages were stored as UUID_att0. + // + // 2. baf5354 era: injectLivePhotoCompanion appended the MOV companion + // at original index 1 and the HEIC-drop filter kept it there, so + // Live Photo companions were stored as UUID_att1. + // + // An exact-match lookup on UUID misses both. A LIKE prefix query catches + // all suffix forms with a single round-trip. + rows, likeErr := c.Main.Bridge.DB.Database.Query( + context.Background(), + `SELECT 1 FROM message + WHERE bridge_id=$1 AND (room_receiver=$2 OR room_receiver='') AND id LIKE $3 + LIMIT 1`, + c.Main.Bridge.ID, c.UserLogin.ID, string(makeMessageID(msg.Uuid))+"_%", + ) + if likeErr == nil { + found := rows.Next() + _ = rows.Close() + if found { + log.Debug().Str("uuid", msg.Uuid).Bool("is_stored", msg.IsStoredMessage).Msg("Skipping message: UUID suffix variant found in bridge DB") + return + } + } + } + + sender := c.makeEventSender(msg.Sender) + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + sender = c.canonicalizeDMSender(portalKey, sender) + + // Drop messages that couldn't be resolved to a real portal. + // makePortalKey returns ID:"unknown" when participants and sender are both + // empty/unresolvable. Letting these through creates a junk "unknown" room. + if portalKey.ID == "unknown" { + log.Warn(). + Str("msg_uuid", msg.Uuid). + Strs("participants", msg.Participants). + Msg("Dropping message: could not resolve portal key (no participants/sender)") + return + } + + // SMS/RCS group reactions and other messages sometimes arrive from the + // iPhone relay with an empty participant list. makePortalKey() then falls + // back to [sender, our_number] and computes a DM portal key, which can split + // one group chat into spurious DM portals. + // + // Only redirect DM->group when there is unambiguous evidence that this + // payload belongs to a known group. If evidence is ambiguous, keep the DM + // portal to avoid misrouting legitimate 1:1 SMS traffic. + if msg.IsSms && isComputedDMPortalID(portalKey.ID) { + if groupKey, ok := c.resolveSMSGroupRedirectPortal(msg); ok { + log.Debug(). + Str("sender", ptr.Val(msg.Sender)). + Str("sender_guid", ptr.Val(msg.SenderGuid)). + Str("dm_portal", string(portalKey.ID)). + Str("group_portal", string(groupKey.ID)). + Msg("Redirecting SMS message from DM portal to known group portal") + portalKey = groupKey + } + } + + // Track SMS portals so outbound replies use the correct service type. + // Unconditional so SMS→iMessage transitions are reflected immediately. + smsChanged := c.updatePortalSMS(string(portalKey.ID), msg.IsSms) + + // Only create new portals after CloudKit sync is done. + cloudSyncDone := c.isCloudSyncDone() + createPortal := cloudSyncDone + + // Suppress stale echoes that would resurrect deleted portals. + // Two cases: + // 1. Portal is mid-deletion (still has MXID, but in recentlyDeletedPortals): + // Drop known UUIDs — they're echoes of messages we already bridged. + // Unknown UUIDs are only allowed through if they advance the deleted tail. + // 2. Portal is fully gone (no MXID): Drop known UUIDs — CloudKit sync + // knew about this message but chose not to create a portal. + portalID := string(portalKey.ID) + c.recentlyDeletedPortalsMu.RLock() + deletedEntry, isDeletedPortal := c.recentlyDeletedPortals[portalID] + c.recentlyDeletedPortalsMu.RUnlock() + backgroundCtx := context.Background() + msgTS := int64(msg.TimestampMs) + existingPortal, _ := c.Main.Bridge.GetExistingPortalByKey(backgroundCtx, portalKey) + + // Persist IsSms change to DB immediately so it survives a crash. + // Without this, the in-memory update above would be lost on restart + // because loadSenderGuidsFromDB only loads IsSms=true entries. + if smsChanged && existingPortal != nil { + meta, ok := existingPortal.Metadata.(*PortalMetadata) + if !ok { + meta = &PortalMetadata{} + } + if meta.IsSms != msg.IsSms { + meta.IsSms = msg.IsSms + existingPortal.Metadata = meta + if err := existingPortal.Save(backgroundCtx); err != nil { + log.Warn().Err(err). + Str("portal_id", portalID). + Bool("is_sms", msg.IsSms). + Msg("Failed to persist IsSms change to database") + } else { + log.Debug(). + Str("portal_id", portalID). + Bool("is_sms", msg.IsSms). + Msg("Persisted IsSms change to database") + } + } + } + missingPortal := existingPortal == nil || existingPortal.MXID == "" + + // Lazy-load soft-deleted portal info. Only queries the DB when we + // actually need it (deleted portal checks or soft-delete guard), not + // on every message to a missing portal. + var softDeletedInfo softDeletedPortalInfo + softDeletedInfoLoaded := false + loadSoftDeletedInfo := func() bool { + if softDeletedInfoLoaded { + return softDeletedInfo.Deleted + } + softDeletedInfoLoaded = true + if c.cloudStore == nil { + return false + } + info, err := c.cloudStore.getSoftDeletedPortalInfo(backgroundCtx, portalID) + if err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to query soft-deleted portal state") + return false + } + softDeletedInfo = info + return info.Deleted + } + + // reviveAndAllow clears the chat-level deleted state and flushes + // held messages so the genuinely newer message can create a portal. + // Returns true if the revive succeeded (or wasn't needed). + reviveAndAllow := func(reason string) bool { + revived := true + if softDeletedInfo.Deleted { + if err := c.reviveDeletedPortalShell(backgroundCtx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to revive deleted chat shell before allowing newer message") + revived = false + } + } + log.Info(). + Str("portal_id", portalID). + Str("msg_uuid", msg.Uuid). + Bool("is_tombstone", deletedEntry.isTombstone). + Int64("deleted_tail_ts", softDeletedInfo.NewestTS). + Uint64("msg_ts", msg.TimestampMs). + Msg(reason) + if c.msgBuffer != nil { + c.msgBuffer.flush() + } + c.flushPendingPortalMsgs() + return revived + } + + // Suppress stale echoes that would resurrect deleted portals. + if isDeletedPortal && createPortal { + if missingPortal { + // Compare against the deleted chat's own latest known timestamp. + // OpenBubbles unconditionally revives on any message; we add + // tail-timestamp gating to handle APNs replays that OpenBubbles + // doesn't encounter (it syncs via CloudKit, not realtime APNs). + isSoftDeleted := loadSoftDeletedInfo() + if isSoftDeleted && softDeletedInfo.NewestTS > 0 && msgTS <= softDeletedInfo.NewestTS { + log.Info(). + Str("portal_id", portalID). + Str("msg_uuid", msg.Uuid). + Bool("is_tombstone", deletedEntry.isTombstone). + Int64("deleted_tail_ts", softDeletedInfo.NewestTS). + Uint64("msg_ts", msg.TimestampMs). + Msg("Dropped message: replay for deleted portal is not newer than deleted tail") + return + } + // Use deletedAt as the suppression threshold when available: + // APNs can replay messages sent ANY time before re-connect, + // including messages sent after startup but before the delete. + // startupTime is too coarse — deletedAt catches the exact boundary. + suppressBefore := deletedEntry.deletedAt.UnixMilli() + if suppressBefore <= 0 { + suppressBefore = c.startupTime.UnixMilli() + } + if !isSoftDeleted && suppressBefore > 0 && int64(msg.TimestampMs) < suppressBefore { + log.Info(). + Str("portal_id", portalID). + Str("msg_uuid", msg.Uuid). + Bool("is_tombstone", deletedEntry.isTombstone). + Uint64("msg_ts", msg.TimestampMs). + Int64("suppress_before_ts", suppressBefore). + Msg("Dropped message: pre-delete message for deleted portal (stale echo fallback)") + return + } + if reviveAndAllow("Newer message for deleted portal — reviving chat shell and allowing new conversation") { + isDeletedPortal = false + } + // Fall through with createPortal=true to create the new portal. + } else { + // Portal still has an MXID (mid-deletion): route to existing room + // but don't create a fresh one. + createPortal = false + } + } + + if c.cloudStore != nil { + if known, _ := c.cloudStore.hasMessageUUID(backgroundCtx, msg.Uuid); known { + if isDeletedPortal { + // Portal mid-deletion with a known UUID — stale echo. + log.Info(). + Str("portal_id", portalID). + Str("msg_uuid", msg.Uuid). + Msg("Dropped message: known UUID for recently-deleted portal (mid-deletion echo)") + return + } + if createPortal && missingPortal { + // Portal fully deleted. No bridge portal exists. + log.Info(). + Str("portal_id", portalID). + Str("msg_uuid", msg.Uuid). + Msg("Dropped message: known UUID with no portal (stale echo of deleted chat)") + return + } + } + } + + // Suppress stale APNs re-delivery for chats that are still soft-deleted in + // our DB, including after restart when recentlyDeletedPortals is empty. + // Only genuinely newer traffic is allowed to recreate the portal. + if createPortal && missingPortal && !isDeletedPortal && loadSoftDeletedInfo() { + if softDeletedInfo.NewestTS > 0 && msgTS <= softDeletedInfo.NewestTS { + log.Info(). + Str("portal_id", portalID). + Str("msg_uuid", msg.Uuid). + Int64("deleted_tail_ts", softDeletedInfo.NewestTS). + Uint64("msg_ts", msg.TimestampMs). + Msg("Dropped message: soft-deleted chat replay is not newer than deleted tail") + return + } + reviveAndAllow("Newer message for soft-deleted chat — reviving chat shell and allowing portal recreation") + } + + // Hold messages for portals that don't exist yet while CloudKit sync + // is in progress. Without this, the framework drops events where + // CreatePortal=false and portal.MXID="". We intentionally skip UUID + // persistence here so that replayed messages aren't mistakenly treated + // as known echoes by hasMessageUUID on the second pass. + if !createPortal { + if missingPortal { + c.pendingPortalMsgsMu.Lock() + c.pendingPortalMsgs = append(c.pendingPortalMsgs, msg) + c.pendingPortalMsgsMu.Unlock() + log.Info(). + Str("portal_id", portalID). + Str("msg_uuid", msg.Uuid). + Msg("Held message pending CloudKit sync completion (portal doesn't exist yet)") + return + } + } + + // Persist this message UUID so cross-restart echoes are detected. + // hasMessageUUID checks cloud_message regardless of the deleted flag, + // so soft-deleted UUIDs from prior portal deletions still match. + if c.cloudStore != nil { + if err := c.cloudStore.persistMessageUUID(context.Background(), msg.Uuid, string(portalKey.ID), int64(msg.TimestampMs), sender.IsFromMe); err != nil { + log.Warn().Err(err).Str("uuid", msg.Uuid).Msg("Failed to persist message UUID; duplicates may occur on restart") + } + } + c.maybeNotifyIncomingFaceTimeInvite(log, &msg, portalKey, sender.IsFromMe, createPortal) + if createPortal || sender.IsFromMe { + log.Info(). + Bool("create_portal", createPortal). + Bool("cloud_sync_done", cloudSyncDone). + Bool("is_stored_message", msg.IsStoredMessage). + Bool("is_from_me", sender.IsFromMe). + Str("portal_id", string(portalKey.ID)). + Msg("Portal creation decision for message") + } + + hasText := msg.Text != nil && *msg.Text != "" && strings.TrimRight(*msg.Text, "\ufffc \n") != "" + if hasText { + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.Message[*rustpushgo.WrappedMessage]{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventMessage, + PortalKey: portalKey, + CreatePortal: createPortal, + Sender: sender, + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + LogContext: func(lc zerolog.Context) zerolog.Context { + return lc.Str("msg_uuid", msg.Uuid) + }, + }, + Data: &msg, + ID: makeMessageID(msg.Uuid), + ConvertMessageFunc: convertMessage, + }) + } + + // Live Photo handling: bridge both the still image and the video. + attIndex := 0 + for _, att := range msg.Attachments { + // Skip rich link sideband attachments (handled in convertMessage) + if att.MimeType == "x-richlink/meta" || att.MimeType == "x-richlink/image" { + continue + } + attID := msg.Uuid + if attIndex > 0 || hasText { + attID = fmt.Sprintf("%s_att%d", msg.Uuid, attIndex) + } + attMsg := &attachmentMessage{ + WrappedMessage: &msg, + Attachment: &att, + Index: attIndex, + } + attIndex++ + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.Message[*attachmentMessage]{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventMessage, + PortalKey: portalKey, + CreatePortal: createPortal, + Sender: sender, + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + LogContext: func(lc zerolog.Context) zerolog.Context { + return lc.Str("msg_uuid", attID) + }, + }, + Data: attMsg, + ID: makeMessageID(attID), + ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data *attachmentMessage) (*bridgev2.ConvertedMessage, error) { + return convertAttachment(ctx, portal, intent, data, c.Main.Config.VideoTranscoding, c.Main.Config.HEICConversion, c.Main.Config.HEICJPEGQuality) + }, + }) + } +} + +func (c *IMClient) handleTapback(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + // Skip stored (buffered) tapbacks — CloudKit backfill handles those via + // BackfillReaction at the correct historical position. Processing them here + // as live events gives them current server time, placing them at the end of + // the room timeline and making Beeper show a stale reaction as the room + // preview instead of the most recent message. + // Same pattern as handleRename and handleParticipantChange. + // Regression introduced by PR #20 (lwittwer/pr/tapback-edit-dedup, 693d353): + // that commit added IsStoredMessage guards to handleEdit but omitted them + // for handleTapback, leaving stored tapbacks to fire as live events. + if msg.IsStoredMessage { + log.Debug().Str("uuid", msg.Uuid).Msg("Skipping stored tapback") + return + } + + // Deduplicate: skip tapbacks already processed (e.g. via CloudKit backfill) + // to prevent duplicate reactions and notifications from stale APNs re-delivery. + // Uses the same cloud_message UUID table as handleMessage (primary key lookup). + if msg.Uuid != "" && c.cloudStore != nil { + if known, _ := c.cloudStore.hasMessageUUID(context.Background(), msg.Uuid); known { + log.Debug().Str("uuid", msg.Uuid).Bool("is_stored", msg.IsStoredMessage).Msg("Skipping tapback already in message store") + return + } + } + + targetGUID := ptrStringOr(msg.TapbackTargetUuid, "") + + // Resolve portal by target message UUID first as a safety net. + // Tapbacks usually have correct participants from the payload, but + // self-reflections can still have participants=[self, self]. + portalKey := c.resolvePortalByTargetMessage(log, targetGUID) + if portalKey.ID == "" { + portalKey = c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + } + + // Persist the tapback UUID so cross-restart APNs re-deliveries are caught + // by the hasMessageUUID check above. Uses persistTapbackUUID (not + // persistMessageUUID) to set tapback_type, preventing getConversationReadByMe + // from treating the synthetic row as a substantive message and spuriously + // flipping the conversation to unread for incoming reactions. + // APNs TapbackType is a 0-6 index; cloud_message stores raw 2000-2006 / 3000-3006. + if msg.Uuid != "" && c.cloudStore != nil { + sender := c.makeEventSender(msg.Sender) + storedType := uint32(2000) // sentinel default (Love) when TapbackType is nil + if msg.TapbackType != nil { + storedType = *msg.TapbackType + 2000 + if msg.TapbackRemove { + storedType += 1000 // removals: 3000-3006, matching TapbackRemoveOffset + } + } + if err := c.cloudStore.persistTapbackUUID(context.Background(), msg.Uuid, string(portalKey.ID), int64(msg.TimestampMs), sender.IsFromMe, storedType); err != nil { + log.Warn().Err(err).Str("uuid", msg.Uuid).Msg("Failed to persist tapback UUID; duplicates may occur on restart") + } + } + + // Sticker tapbacks (type 7) carry an image placed on top of a message + // bubble. Matrix reactions are text-only, so bridge the sticker as an + // image message replying to the target instead. + if msg.TapbackType != nil && *msg.TapbackType == 7 && msg.StickerData != nil && len(*msg.StickerData) > 0 { + stickerData := *msg.StickerData + stickerMime := "image/png" + if msg.StickerMime != nil && *msg.StickerMime != "" { + stickerMime = *msg.StickerMime + } + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.Message[*stickerTapbackData]{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventMessage, + PortalKey: portalKey, + Sender: c.canonicalizeDMSender(portalKey, c.makeEventSender(msg.Sender)), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + Data: &stickerTapbackData{ + ImageData: stickerData, + MimeType: stickerMime, + TargetID: targetGUID, + }, + ID: makeMessageID(msg.Uuid), + ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data *stickerTapbackData) (*bridgev2.ConvertedMessage, error) { + return convertStickerTapback(ctx, intent, data) + }, + }) + return + } + + emoji := tapbackTypeToEmoji(msg.TapbackType, msg.TapbackEmoji) + + evtType := bridgev2.RemoteEventReaction + if msg.TapbackRemove { + evtType = bridgev2.RemoteEventReactionRemove + } + + tapbackPart := 0 + if msg.TapbackTargetPart != nil { + tapbackPart = int(*msg.TapbackTargetPart) + } + tapbackTargetMsgID := c.resolveTapbackTargetID(targetGUID, tapbackPart) + + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.Reaction{ + EventMeta: simplevent.EventMeta{ + Type: evtType, + PortalKey: portalKey, + Sender: c.canonicalizeDMSender(portalKey, c.makeEventSender(msg.Sender)), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + TargetMessage: tapbackTargetMsgID, + Emoji: emoji, + }) +} + +func (c *IMClient) handleEdit(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + targetGUID := ptrStringOr(msg.EditTargetUuid, "") + + // Resolve portal by target message UUID first. Edit reflections from the + // user's own devices have participants=[self, self], so makePortalKey can't + // determine the correct DM portal. + portalKey := c.resolvePortalByTargetMessage(log, targetGUID) + if portalKey.ID == "" { + portalKey = c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + } + + newText := ptrStringOr(msg.EditNewText, "") + + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.Message[string]{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventEdit, + PortalKey: portalKey, + Sender: c.canonicalizeDMSender(portalKey, c.makeEventSender(msg.Sender)), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + Data: newText, + ID: makeMessageID(msg.Uuid), + TargetMessage: makeMessageID(targetGUID), + ConvertEditFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message, text string) (*bridgev2.ConvertedEdit, error) { + var targetPart *database.Message + if len(existing) > 0 { + targetPart = existing[0] + } + return &bridgev2.ConvertedEdit{ + ModifiedParts: []*bridgev2.ConvertedEditPart{{ + Part: targetPart, + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgText, + Body: text, + }, + }}, + }, nil + }, + }) +} + +func (c *IMClient) handleUnsend(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + targetGUID := ptrStringOr(msg.UnsendTargetUuid, "") + + // Suppress echo of unsends initiated from Matrix. + if c.wasOutboundUnsend(targetGUID) { + log.Debug().Str("target_uuid", targetGUID).Msg("Suppressing echo of outbound unsend") + return + } + + c.trackUnsend(targetGUID) + + // Resolve portal by target message UUID first. Unsend reflections from the + // user's own devices have participants=[self, self], so makePortalKey can't + // determine the correct DM portal. + portalKey := c.resolvePortalByTargetMessage(log, targetGUID) + if portalKey.ID == "" { + portalKey = c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + } + + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.MessageRemove{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventMessageRemove, + PortalKey: portalKey, + Sender: c.canonicalizeDMSender(portalKey, c.makeEventSender(msg.Sender)), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + TargetMessage: makeMessageID(targetGUID), + }) +} + +func (c *IMClient) handleRename(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + // Skip stored (backfilled) rename messages to prevent spurious + // room name change events from historical renames delivered on reconnect. + if msg.IsStoredMessage { + log.Debug().Str("uuid", msg.Uuid).Msg("Skipping stored rename message") + return + } + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + newName := ptrStringOr(msg.NewChatName, "") + + // Update the cached iMessage group name to the NEW name so outbound + // messages (portalToConversation) use it. makePortalKey cached whatever + // was in the conversation envelope (msg.GroupName), which may be the old + // name. Also persist to portal metadata so it survives restarts. + if newName != "" { + portalID := string(portalKey.ID) + c.imGroupNamesMu.Lock() + c.imGroupNames[portalID] = newName + c.imGroupNamesMu.Unlock() + + go func() { + ctx := context.Background() + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if err == nil && portal != nil { + meta := &PortalMetadata{} + if existing, ok := portal.Metadata.(*PortalMetadata); ok { + *meta = *existing + } + if meta.GroupName != newName { + meta.GroupName = newName + portal.Metadata = meta + _ = portal.Save(ctx) + } + } + // Also correct the stale CloudKit display_name in cloud_chat + // so resolveGroupName doesn't fall back to the old name. + if c.cloudStore != nil { + if err := c.cloudStore.updateDisplayNameByPortalID(ctx, portalID, newName); err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to update cloud_chat display_name after rename") + } + } + }() + } + + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.ChatInfoChange{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatInfoChange, + PortalKey: portalKey, + Sender: c.makeEventSender(msg.Sender), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + ChatInfoChange: &bridgev2.ChatInfoChange{ + ChatInfo: &bridgev2.ChatInfo{ + Name: &newName, + }, + }, + }) +} + +func (c *IMClient) handleParticipantChange(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + // Skip stored (backfilled) participant change messages to prevent spurious + // member change events from historical changes delivered on reconnect. + if msg.IsStoredMessage { + log.Debug().Str("uuid", msg.Uuid).Msg("Skipping stored participant change message") + return + } + // Resolve the existing portal from the OLD participant list. + oldPortalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + + if len(msg.NewParticipants) == 0 { + // No new participant list — fall back to a resync with current info. + log.Warn().Msg("Participant change with empty NewParticipants, falling back to resync") + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + PortalKey: oldPortalKey, + }, + GetChatInfoFunc: c.GetChatInfo, + }) + return + } + + // Compute new portal ID from the NEW participant list using the same + // normalization / dedup / sort logic as makePortalKey's group branch. + deduped := c.buildCanonicalParticipantList(msg.NewParticipants) + newPortalIDStr := strings.Join(deduped, ",") + oldPortalIDStr := string(oldPortalKey.ID) + + // If the portal ID changed (member added/removed), re-key it in the DB. + finalPortalKey := oldPortalKey + if newPortalIDStr != oldPortalIDStr { + ctx := context.Background() + newPortalKey := networkid.PortalKey{ + ID: networkid.PortalID(newPortalIDStr), + Receiver: c.UserLogin.ID, + } + result, _, err := c.reIDPortalWithCacheUpdate(ctx, oldPortalKey, newPortalKey) + if err != nil { + log.Err(err). + Str("old_portal_id", oldPortalIDStr). + Str("new_portal_id", newPortalIDStr). + Msg("Failed to ReID portal for participant change") + return + } + log.Info(). + Str("old_portal_id", oldPortalIDStr). + Str("new_portal_id", newPortalIDStr). + Int("result", int(result)). + Msg("ReID portal for participant change") + finalPortalKey = newPortalKey + } + + // Cache sender_guid and group_name under the (possibly new) portal ID. + // For comma-based portals and gid: portals, cache the sender_guid so + // future messages can be resolved to this portal. This is skipped for + // other portal ID formats to avoid poisoning the cache with unrelated + // sender_guids. + if msg.SenderGuid != nil && *msg.SenderGuid != "" { + portalIDStr := string(finalPortalKey.ID) + isGidPortal := strings.HasPrefix(portalIDStr, "gid:") + if strings.Contains(portalIDStr, ",") || isGidPortal { + c.imGroupGuidsMu.Lock() + c.imGroupGuids[portalIDStr] = *msg.SenderGuid + c.imGroupGuidsMu.Unlock() + } + } + if msg.GroupName != nil && *msg.GroupName != "" { + c.imGroupNamesMu.Lock() + c.imGroupNames[string(finalPortalKey.ID)] = *msg.GroupName + c.imGroupNamesMu.Unlock() + } + + // Build the full new member list for Matrix room sync. + memberMap := make(map[networkid.UserID]bridgev2.ChatMember, len(msg.NewParticipants)) + for _, p := range msg.NewParticipants { + normalized := normalizeIdentifierForPortalID(p) + if normalized == "" { + continue + } + userID := makeUserID(normalized) + if c.isMyHandle(normalized) { + memberMap[userID] = bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: userID, + }, + Membership: event.MembershipJoin, + } + } else { + memberMap[userID] = bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{Sender: userID}, + Membership: event.MembershipJoin, + } + } + } + + // Queue a ChatInfoChange with the full member list so bridgev2 syncs + // the Matrix room membership (invites new members, kicks removed ones). + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.ChatInfoChange{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatInfoChange, + PortalKey: finalPortalKey, + Sender: c.makeEventSender(msg.Sender), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + ChatInfoChange: &bridgev2.ChatInfoChange{ + MemberChanges: &bridgev2.ChatMemberList{ + IsFull: true, + MemberMap: memberMap, + }, + }, + }) +} + +// safeCloudDownloadGroupPhoto wraps the FFI call with panic recovery and a +// 90-second timeout, matching the pattern used by safeCloudDownloadAttachment. +func safeCloudDownloadGroupPhoto(ctx context.Context, client *rustpushgo.Client, recordName string) ([]byte, error) { + type dlResult struct { + data []byte + err error + } + ch := make(chan dlResult, 1) + go func() { + var res dlResult + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + log.Error().Str("ffi_method", "CloudDownloadGroupPhoto"). + Str("record_name", recordName). + Str("stack", stack). + Msgf("FFI panic recovered: %v", r) + res = dlResult{err: fmt.Errorf("FFI panic in CloudDownloadGroupPhoto: %v", r)} + } + ch <- res + }() + d, e := client.CloudDownloadGroupPhoto(recordName) + res = dlResult{data: d, err: e} + }() + select { + case res := <-ch: + return res.data, res.err + case <-time.After(90 * time.Second): + log.Error().Str("ffi_method", "CloudDownloadGroupPhoto"). + Str("record_name", recordName). + Msg("CloudDownloadGroupPhoto timed out after 90s — inner goroutine leaked until FFI unblocks") + return nil, fmt.Errorf("CloudDownloadGroupPhoto timed out after 90s") + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// fetchAndCacheGroupPhoto attempts to download the group photo from CloudKit +// using the record_name stored during chat sync. It tries the CloudKit "gp" +// (group photo) asset field on the chat record. Apple's iMessage clients may +// not always write to this field (preferring MMCS delivery via APNs IconChange +// messages), so failures are expected and logged at debug level rather than +// warning. On success, the bytes are persisted to group_photo_cache so +// subsequent GetChatInfo calls can serve the photo without a network round-trip. +// Returns (photoData, timestampMs); both are zero on any failure. +func (c *IMClient) fetchAndCacheGroupPhoto(ctx context.Context, log zerolog.Logger, portalID string) ([]byte, int64) { + if c.cloudStore == nil { + return nil, 0 + } + _, recordName, err := c.cloudStore.getGroupPhotoByPortalID(ctx, portalID) + if err != nil { + log.Debug().Err(err).Msg("group_photo: failed to look up record_name for CloudKit download") + return nil, 0 + } + if recordName == "" { + log.Debug().Msg("group_photo: no group_photo_guid in cloud_chat, skipping CloudKit download") + return nil, 0 + } + log.Debug().Str("record_name", recordName).Msg("group_photo: attempting CloudKit download") + data, dlErr := safeCloudDownloadGroupPhoto(ctx, c.client, recordName) + if dlErr != nil { + log.Debug().Err(dlErr).Str("record_name", recordName). + Msg("group_photo: CloudKit download failed (expected if Apple did not write gp asset)") + return nil, 0 + } + if len(data) == 0 { + log.Debug().Str("record_name", recordName).Msg("group_photo: CloudKit download returned empty data") + return nil, 0 + } + ts := time.Now().UnixMilli() + if saveErr := c.cloudStore.saveGroupPhoto(ctx, portalID, ts, data); saveErr != nil { + log.Warn().Err(saveErr).Msg("group_photo: failed to cache downloaded photo") + } else { + log.Info().Str("record_name", recordName).Int("bytes", len(data)). + Msg("group_photo: downloaded and cached from CloudKit") + } + return data, ts +} + +// handleIconChange processes a group photo (icon) change from APNs. +// When a participant changes or clears the group photo from their iMessage +// client, Apple delivers an IconChange message with MMCS transfer data. +// The Rust layer downloads the photo inline; we apply it directly to the room. +// +// Stored (IsStoredMessage) icon changes are NOT skipped: when the bridge +// reconnects after being offline, Apple may deliver a queued IconChange with +// valid MMCS data, letting us catch up on changes that occurred while offline. +// If the MMCS URL has expired Rust returns nil bytes and we warn harmlessly. +func (c *IMClient) handleIconChange(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + portalID := string(portalKey.ID) + log = log.With().Str("portal_id", portalID).Bool("stored", msg.IsStoredMessage).Logger() + + if msg.GroupPhotoCleared { + // Photo was removed — clear the avatar and wipe the local cache so + // GetChatInfo won't re-apply a stale photo after a restart. + log.Info().Msg("Group photo cleared via APNs IconChange") + if c.cloudStore != nil { + if err := c.cloudStore.clearGroupPhoto(context.Background(), portalID); err != nil { + log.Warn().Err(err).Msg("Failed to clear cached group photo in DB") + } + } + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.ChatInfoChange{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatInfoChange, + PortalKey: portalKey, + Sender: c.makeEventSender(msg.Sender), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + ChatInfoChange: &bridgev2.ChatInfoChange{ + ChatInfo: &bridgev2.ChatInfo{ + Avatar: &bridgev2.Avatar{ + ID: networkid.AvatarID(""), + Get: func(ctx context.Context) ([]byte, error) { return nil, nil }, + }, + }, + }, + }) + return + } + + // New photo set — Rust already downloaded the MMCS bytes inline. + // Apple delivers group photos via MMCS in IconChange messages (not CloudKit). + if msg.IconChangePhotoData == nil || len(*msg.IconChangePhotoData) == 0 { + log.Warn().Msg("IconChange received but photo data is empty (MMCS download may have failed)") + return + } + photoData := *msg.IconChangePhotoData + // Timestamp-based avatar ID — changes when the photo changes, so bridgev2 + // always re-applies the new avatar even if the portal already has one. + avatarID := networkid.AvatarID(fmt.Sprintf("icon-change:%d", msg.TimestampMs)) + log.Info().Int("size", len(photoData)).Msg("Group photo changed via APNs IconChange — applying MMCS photo") + + // Persist bytes to DB so GetChatInfo can apply the correct avatar after a + // restart without a CloudKit round-trip. Apple's native clients never write + // to the CloudKit gp asset field — MMCS is the only delivery mechanism. + if c.cloudStore != nil { + if err := c.cloudStore.saveGroupPhoto(context.Background(), portalID, int64(msg.TimestampMs), photoData); err != nil { + log.Warn().Err(err).Msg("Failed to persist group photo bytes to DB") + } + } + + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.ChatInfoChange{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatInfoChange, + PortalKey: portalKey, + Sender: c.makeEventSender(msg.Sender), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + ChatInfoChange: &bridgev2.ChatInfoChange{ + ChatInfo: &bridgev2.ChatInfo{ + Avatar: &bridgev2.Avatar{ + ID: avatarID, + Get: func(ctx context.Context) ([]byte, error) { return photoData, nil }, + }, + }, + }, + }) +} + +const faceTimeRingMarker = "[[FACETIME_RING]]" +const faceTimeMissedMarker = "[[FACETIME_MISSED]]" +const faceTimeAnsweredElsewhereMarker = "[[FACETIME_ANSWERED_ELSEWHERE]]" + +// handleNotifyAnyways posts a silent bot notice when a contact deliberately +// breaks through our Focus / Do Not Disturb by tapping "Notify Anyway" on their +// device. The notice is delivered to the portal that corresponds to this chat so +// the user can see who sent it. Stored messages are silently dropped. +func (c *IMClient) handleNotifyAnyways(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + if msg.IsStoredMessage { + log.Debug().Msg("Skipping stored NotifyAnyways message") + return + } + + rawText := strings.TrimSpace(ptrStringOr(msg.Text, "")) + if strings.HasPrefix(rawText, faceTimeRingMarker) { + c.handleFaceTimeRingNotice(log, msg, rawText) + return + } + if strings.HasPrefix(rawText, faceTimeMissedMarker) { + c.handleFaceTimeMissedNotice(log, msg) + return + } + if strings.HasPrefix(rawText, faceTimeAnsweredElsewhereMarker) { + c.handleFaceTimeAnsweredElsewhereNotice(log, msg) + return + } + + ctx := context.Background() + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if err != nil || portal == nil || portal.MXID == "" { + log.Debug().Err(err).Msg("NotifyAnyways: no portal found, skipping notice") + return + } + + // Resolve a friendly name from the ghost (same as StatusKit notices). + senderHandle := ptrStringOr(msg.Sender, "") + name := senderHandle + if senderHandle != "" { + ghost, ghostErr := c.Main.Bridge.GetGhostByID(ctx, makeUserID(normalizeIdentifierForPortalID(senderHandle))) + if ghostErr == nil && ghost != nil && ghost.Name != "" { + name = ghost.Name + } + } + + notice := "🔔 " + name + " sent a Notify Anyway (tapped through Focus / Do Not Disturb)." + log.Info(). + Str("sender", senderHandle). + Str("portal_mxid", string(portal.MXID)). + Msg("NotifyAnyways: posting notice") + + _, sendErr := c.Main.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ + Parsed: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: notice, + Mentions: &event.Mentions{}, + }, + }, nil) + if sendErr != nil { + log.Warn().Err(sendErr).Msg("NotifyAnyways: failed to send notice") + } +} + +func (c *IMClient) handleFaceTimeRingNotice(log zerolog.Logger, msg rustpushgo.WrappedMessage, rawText string) { + ctx := context.Background() + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + + senderHandle := ptrStringOr(msg.Sender, "") + name := stripIdentifierPrefix(senderHandle) + if senderHandle != "" { + ghost, ghostErr := c.Main.Bridge.GetGhostByID(ctx, makeUserID(normalizeIdentifierForPortalID(senderHandle))) + if ghostErr == nil && ghost != nil && ghost.Name != "" { + name = ghost.Name + } + } + if name == "" { + name = "someone" + } + + link := firstFaceTimeLinkInText(rawText) + if link == "" { + // Native FaceTime ring — no link embedded. Use the session guid + // from the marker text to mint a session-specific link that joins + // the caller's actual session (not a stale bridge link). + if ft, ftErr := c.client.GetFacetimeClient(); ftErr == nil { + if guid := extractFaceTimeGuid(rawText); guid != "" { + if sessionLink, slErr := ft.GetSessionLink(guid); slErr == nil { + link = sessionLink + } else { + log.Debug().Err(slErr).Str("guid", guid).Msg("FaceTimeRing: GetSessionLink failed, falling back to bridge link") + } + } + if link == "" { + if generated, genErr := getFaceTimeLinkWithRecovery(ft, c.handle); genErr == nil { + link = generated + } else { + log.Warn().Err(genErr).Msg("FaceTimeRing: failed to generate fallback FaceTime link") + } + } + } + } + if link != "" && c.handle != "" { + // Pre-fill the join page's display-name field with the bridge owner's + // handle — same transformation as the outbound !im facetime flow at + // facetime.go:391. Without this the user lands on the web FT join + // page with the name field blank and has to type it themselves. + link = appendFaceTimeLinkName(link, stripIdentifierPrefix(c.handle)) + } + + // Build the notice as markdown so the join link renders as a tappable + // anchor in the formatted_body. Plain-URL notices aren't autolinked by + // every Matrix client; wrapping the URL in [text](url) guarantees an + // tag reaches the client. + noticeMarkdown := "📞 **Incoming FaceTime call from " + name + ".**" + if link != "" { + noticeMarkdown += "\n\n[**Answer FaceTime call**](" + link + ")" + noticeMarkdown += "\n\nRaw link (if the button above doesn't open): " + link + } + + sendNotice := func(roomID id.RoomID) error { + content := format.RenderMarkdown(noticeMarkdown, true, false) + content.MsgType = event.MsgNotice + content.Mentions = &event.Mentions{} + _, sendErr := c.Main.Bridge.Bot.SendMessage(ctx, roomID, event.EventMessage, &event.Content{ + Parsed: &content, + }, nil) + return sendErr + } + + if err == nil && portal != nil && portal.MXID != "" { + if sendErr := sendNotice(portal.MXID); sendErr == nil { + log.Info(). + Str("sender", senderHandle). + Str("portal_mxid", string(portal.MXID)). + Msg("FaceTimeRing: posted incoming call notice to portal") + return + } else { + log.Warn().Err(sendErr).Msg("FaceTimeRing: failed to send portal notice") + } + } + + mgmtRoom, mgmtErr := c.UserLogin.User.GetManagementRoom(ctx) + if mgmtErr != nil { + log.Warn().Err(mgmtErr).Msg("FaceTimeRing: failed to get management room for fallback notice") + return + } + if sendErr := sendNotice(mgmtRoom); sendErr != nil { + log.Warn().Err(sendErr).Msg("FaceTimeRing: failed to send management room notice") + return + } + log.Info(). + Str("sender", senderHandle). + Str("management_room", string(mgmtRoom)). + Msg("FaceTimeRing: posted incoming call notice to management room") +} + +func (c *IMClient) handleFaceTimeMissedNotice(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + ctx := context.Background() + senderHandle := ptrStringOr(msg.Sender, "") + name := stripIdentifierPrefix(senderHandle) + if senderHandle != "" { + ghost, ghostErr := c.Main.Bridge.GetGhostByID(ctx, makeUserID(normalizeIdentifierForPortalID(senderHandle))) + if ghostErr == nil && ghost != nil && ghost.Name != "" { + name = ghost.Name + } + } + if name == "" { + name = "someone" + } + + // Build a call-back button that uses the bridge's pending-ring flow, + // same mechanism as the outbound `!im facetime` command. Tap → letmein + // approve adds the user to a pre-armed session → JoinEvent fires + // maybe_fire_pending_ring → ft.ring() against the original caller. + // By the time their phone rings the user is already a live participant + // so the callee's answer connects cleanly. + // + // No facetime:// fallback: that scheme only worked on native iOS/macOS + // and provided no bridge integration. If the bridge-link arm fails we + // still post the notice with no callback button; the user can always + // `!im facetime` in the portal manually. + noticeMarkdown := "📞 **Missed FaceTime call from " + name + ".**" + if senderHandle != "" && c.handle != "" { + if ft, ftErr := c.client.GetFacetimeClient(); ftErr == nil { + if webLink, _, armErr := armBridgeFaceTimeCall(ft, c.handle, senderHandle, 3600); armErr == nil { + noticeMarkdown += "\n\n[**📞 Call back " + name + "**](" + webLink + ")" + noticeMarkdown += "\n\n⚠️ **Tapping this link will ring " + name + "'s phone.** The ring fires the moment you join — open the link when you're ready to be on camera. Works on iOS, macOS, Android, Windows, and Linux.\n\nRaw URL: " + webLink + } else { + log.Warn().Err(armErr).Str("caller", senderHandle).Msg("FaceTimeMissed: bridge-link callback arm failed; notice posted without callback button") + } + } + } + + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + sendNotice := func(roomID id.RoomID) error { + content := format.RenderMarkdown(noticeMarkdown, true, false) + content.MsgType = event.MsgNotice + content.Mentions = &event.Mentions{} + _, sendErr := c.Main.Bridge.Bot.SendMessage(ctx, roomID, event.EventMessage, &event.Content{ + Parsed: &content, + }, nil) + return sendErr + } + if err == nil && portal != nil && portal.MXID != "" { + if sendErr := sendNotice(portal.MXID); sendErr == nil { + log.Info().Str("sender", senderHandle).Str("portal_mxid", string(portal.MXID)).Bool("has_callback", senderHandle != "" && c.handle != "").Msg("FaceTimeMissed: posted missed call notice to portal") + return + } + } + mgmtRoom, mgmtErr := c.UserLogin.User.GetManagementRoom(ctx) + if mgmtErr != nil { + log.Warn().Err(mgmtErr).Msg("FaceTimeMissed: failed to get management room for fallback notice") + return + } + if sendErr := sendNotice(mgmtRoom); sendErr != nil { + log.Warn().Err(sendErr).Msg("FaceTimeMissed: failed to send management room notice") + return + } + log.Info().Str("sender", senderHandle).Str("management_room", string(mgmtRoom)).Bool("has_callback", senderHandle != "" && c.handle != "").Msg("FaceTimeMissed: posted missed call notice to management room") +} + +func (c *IMClient) handleFaceTimeAnsweredElsewhereNotice(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + ctx := context.Background() + notice := "📞 Incoming FaceTime call was answered on another device." + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + sendNotice := func(roomID id.RoomID) error { + _, sendErr := c.Main.Bridge.Bot.SendMessage(ctx, roomID, event.EventMessage, &event.Content{ + Parsed: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: notice, + Mentions: &event.Mentions{}, + }, + }, nil) + return sendErr + } + senderHandle := ptrStringOr(msg.Sender, "") + if err == nil && portal != nil && portal.MXID != "" { + if sendErr := sendNotice(portal.MXID); sendErr == nil { + log.Info().Str("sender", senderHandle).Str("portal_mxid", string(portal.MXID)).Msg("FaceTimeAnsweredElsewhere: posted notice to portal") + return + } + } + mgmtRoom, mgmtErr := c.UserLogin.User.GetManagementRoom(ctx) + if mgmtErr != nil { + log.Warn().Err(mgmtErr).Msg("FaceTimeAnsweredElsewhere: failed to get management room for fallback notice") + return + } + if sendErr := sendNotice(mgmtRoom); sendErr != nil { + log.Warn().Err(sendErr).Msg("FaceTimeAnsweredElsewhere: failed to send management room notice") + return + } + log.Info().Str("sender", senderHandle).Str("management_room", string(mgmtRoom)).Msg("FaceTimeAnsweredElsewhere: posted notice to management room") +} + +// handleTranscriptBackground posts a silent bot notice when a participant sets +// or removes the custom iMessage chat wallpaper (transcript background). +// Stored messages are silently dropped. +func (c *IMClient) handleTranscriptBackground(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + if msg.IsStoredMessage { + log.Debug().Msg("Skipping stored SetTranscriptBackground message") + return + } + ctx := context.Background() + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if err != nil || portal == nil || portal.MXID == "" { + log.Debug().Err(err).Msg("SetTranscriptBackground: no portal found, skipping notice") + return + } + + senderHandle := ptrStringOr(msg.Sender, "") + name := senderHandle + if senderHandle != "" { + ghost, ghostErr := c.Main.Bridge.GetGhostByID(ctx, makeUserID(normalizeIdentifierForPortalID(senderHandle))) + if ghostErr == nil && ghost != nil && ghost.Name != "" { + name = ghost.Name + } + } + + var notice string + isRemove := msg.TranscriptBackgroundRemove != nil && *msg.TranscriptBackgroundRemove + if isRemove { + notice = "🖼️ " + name + " removed the chat background." + } else { + notice = "🖼️ " + name + " set a new chat background." + } + + log.Info(). + Str("sender", senderHandle). + Bool("remove", isRemove). + Str("portal_mxid", string(portal.MXID)). + Msg("SetTranscriptBackground: posting notice") + + _, sendErr := c.Main.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ + Parsed: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: notice, + Mentions: &event.Mentions{}, + }, + }, nil) + if sendErr != nil { + log.Warn().Err(sendErr).Msg("SetTranscriptBackground: failed to send notice") + } +} + +// makeDeletePortalKey constructs a PortalKey from the delete/recover-specific +// fields in a WrappedMessage. Delete and recover APNs messages populate +// DeleteChatGuid, DeleteChatGroupId, and DeleteChatParticipants instead of +// the regular Participants/GroupName/Sender/SenderGuid fields. +// +// Strategy: look up the portal_id from cloud_chat DB first (most reliable), +// then fall back to parsing the chat GUID or building from participants. +func (c *IMClient) makeDeletePortalKey(log zerolog.Logger, msg rustpushgo.WrappedMessage) networkid.PortalKey { + ctx := context.Background() + + // Best path: look up portal_id from cloud_chat by chat GUID or group_id. + // The cloud_chat table knows the correct portal_id for both DMs and groups. + if c.cloudStore != nil { + // Try chat GUID first (e.g. "iMessage;-;+1234567890" or "iMessage;+;chat123") + if msg.DeleteChatGuid != nil && *msg.DeleteChatGuid != "" { + if portalID, err := c.cloudStore.getChatPortalID(ctx, *msg.DeleteChatGuid); err == nil && portalID != "" { + log.Debug().Str("portal_id", portalID).Str("chat_guid", *msg.DeleteChatGuid).Msg("Resolved delete portal from cloud_chat by chat GUID") + return networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: c.UserLogin.ID} + } + } + // Try group_id (UUID) + if msg.DeleteChatGroupId != nil && *msg.DeleteChatGroupId != "" { + if portalID, err := c.cloudStore.getChatPortalID(ctx, *msg.DeleteChatGroupId); err == nil && portalID != "" { + log.Debug().Str("portal_id", portalID).Str("group_id", *msg.DeleteChatGroupId).Msg("Resolved delete portal from cloud_chat by group ID") + return networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: c.UserLogin.ID} + } + } + } + + // Fallback: parse the chat GUID to construct portal ID directly. + if msg.DeleteChatGuid != nil && *msg.DeleteChatGuid != "" { + parsed := imessage.ParseIdentifier(*msg.DeleteChatGuid) + if parsed.LocalID != "" && !parsed.IsGroup { + portalID := identifierToPortalID(parsed) + log.Debug().Str("portal_id", string(portalID)).Msg("Resolved delete portal from chat GUID parsing") + return networkid.PortalKey{ID: portalID, Receiver: c.UserLogin.ID} + } + } + + // Fallback: use group_id for groups + if msg.DeleteChatGroupId != nil && *msg.DeleteChatGroupId != "" && len(msg.DeleteChatParticipants) > 1 { + gidID := "gid:" + strings.ToLower(*msg.DeleteChatGroupId) + portalKey := networkid.PortalKey{ID: networkid.PortalID(gidID), Receiver: c.UserLogin.ID} + + c.gidAliasesMu.RLock() + aliasedID, hasAlias := c.gidAliases[gidID] + c.gidAliasesMu.RUnlock() + if hasAlias { + if c.guidCacheMatchIsStale(aliasedID, msg.DeleteChatParticipants) { + c.gidAliasesMu.Lock() + // Compare-before-delete: another handler may have repaired + // the alias between our RLock read and this write lock. + if c.gidAliases[gidID] == aliasedID { + delete(c.gidAliases, gidID) + } + c.gidAliasesMu.Unlock() + log.Warn(). + Str("gid_id", gidID). + Str("stale_alias", aliasedID). + Msg("Cleared stale gid alias in handleDeleteChat: participant mismatch") + // Fall through to direct gid: lookup / resolveExistingGroupByGid + } else { + portalKey.ID = networkid.PortalID(aliasedID) + return portalKey + } + } + if existing, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey); existing != nil && existing.MXID != "" { + return portalKey + } + if len(msg.DeleteChatParticipants) > 0 { + resolved := c.resolveExistingGroupByGid(gidID, *msg.DeleteChatGroupId, msg.DeleteChatParticipants) + return networkid.PortalKey{ID: resolved, Receiver: c.UserLogin.ID} + } + return portalKey + } + + // Last resort: build from participants + if len(msg.DeleteChatParticipants) == 1 { + portalID := addIdentifierPrefix(msg.DeleteChatParticipants[0]) + return networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: c.UserLogin.ID} + } + if len(msg.DeleteChatParticipants) > 1 { + members := make([]string, 0, len(msg.DeleteChatParticipants)+1) + members = append(members, c.handle) + for _, p := range msg.DeleteChatParticipants { + members = append(members, addIdentifierPrefix(p)) + } + sort.Strings(members) + return networkid.PortalKey{ + ID: networkid.PortalID(strings.Join(members, ",")), + Receiver: c.UserLogin.ID, + } + } + + log.Warn().Msg("Delete/recover message has no delete-specific fields, falling back to regular makePortalKey") + return c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) +} + +func (c *IMClient) handleMessageDelete(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + deleteType := "MoveToRecycleBin" + if msg.IsPermanentDelete { + deleteType = "PermanentDelete" + } + + log.Info(). + Str("delete_type", deleteType). + Int("uuid_count", len(msg.DeleteMessageUuids)). + Msg("Processing per-message delete") + + for _, targetUUID := range msg.DeleteMessageUuids { + portalKey := c.resolvePortalByTargetMessage(log, targetUUID) + if portalKey.ID == "" { + log.Debug(). + Str("target_uuid", targetUUID). + Msg("Message UUID not found in bridge DB, skipping") + continue + } + + log.Info(). + Str("target_uuid", targetUUID). + Str("portal_id", string(portalKey.ID)). + Msg("Sending redaction for deleted message") + + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.MessageRemove{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventMessageRemove, + PortalKey: portalKey, + Sender: c.makeEventSender(msg.Sender), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + TargetMessage: makeMessageID(targetUUID), + }) + } +} + +func (c *IMClient) handleChatDelete(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + deleteType := "MoveToRecycleBin" + if msg.IsPermanentDelete { + deleteType = "PermanentDelete" + } + + // Per-message delete: DeleteMessageUuids is populated, chat-level fields are empty. + // Route to handleMessageDelete which does per-message redaction via the bridge DB. + if len(msg.DeleteMessageUuids) > 0 { + c.handleMessageDelete(log, msg) + return + } + + // chatdb backend: preserve master behavior — ignore Apple-initiated deletes. + if !c.Main.Config.UseCloudKitBackfill() { + log.Info().Str("delete_type", deleteType).Msg("Ignoring incoming Apple chat delete (chatdb backend)") + return + } + + portalKey := c.makeDeletePortalKey(log, msg) + portalID := string(portalKey.ID) + + log.Info(). + Str("delete_type", deleteType). + Str("portal_id", portalID). + Str("msg_uuid", msg.Uuid). + Msg("Processing incoming Apple chat delete") + + // Track as recently deleted so APNs echoes don't recreate it. + c.trackDeletedChat(portalID) + + // Soft-delete local DB records (preserves UUIDs for echo detection). + if c.cloudStore != nil { + if err := c.cloudStore.clearRestoreOverride(context.Background(), portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to clear restore override for Apple-deleted chat") + } + if err := c.cloudStore.deleteLocalChatByPortalID(context.Background(), portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to soft-delete local records for Apple-deleted chat") + } + } + + // Queue bridge portal deletion if it exists. + existing, _ := c.Main.Bridge.GetExistingPortalByKey(context.Background(), portalKey) + if existing != nil && existing.MXID != "" { + log.Info(). + Str("portal_id", portalID). + Str("delete_type", deleteType). + Msg("Deleting Beeper portal for Apple-deleted chat") + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.ChatDelete{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatDelete, + PortalKey: portalKey, + Timestamp: time.Now(), + LogContext: func(lc zerolog.Context) zerolog.Context { + return lc.Str("source", "apns_chat_delete").Str("delete_type", deleteType) + }, + }, + OnlyForMe: true, + }) + } else { + log.Debug(). + Str("portal_id", portalID). + Msg("No existing portal for Apple-deleted chat (already gone or never created)") + } +} + +func (c *IMClient) handleChatRecover(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + // chatdb backend: no-op. Recovery is CloudKit-only. + if !c.Main.Config.UseCloudKitBackfill() { + log.Debug().Msg("Ignoring RecoverChat (chatdb backend)") + return + } + + // Log all available fields from the recover APNs message for diagnostics. + // Recovery failures are often caused by missing/empty delete-specific fields. + recoverLog := log.With(). + Str("msg_uuid", msg.Uuid). + Interface("delete_chat_guid", msg.DeleteChatGuid). + Interface("delete_chat_group_id", msg.DeleteChatGroupId). + Int("delete_participants_count", len(msg.DeleteChatParticipants)). + Strs("delete_participants", msg.DeleteChatParticipants). + Interface("sender_guid", msg.SenderGuid). + Logger() + + portalKey := c.makeDeletePortalKey(recoverLog, msg) + portalID := string(portalKey.ID) + + if portalID == "" { + recoverLog.Warn().Msg("Chat recovery: could not resolve portal ID from recover message — skipping") + return + } + + // For group portals, cross-reference cloud_chat to find the canonical + // portal_id. The APNs recover message may carry the chat_id UUID + // (different from the group_id UUID), producing gid: while + // the existing portal uses gid:. Without this, we'd create + // a duplicate portal for the same group. + if strings.HasPrefix(portalID, "gid:") && c.cloudStore != nil { + uuid := strings.TrimPrefix(portalID, "gid:") + if altPortalIDs, err := c.cloudStore.findPortalIDsByGroupID(context.Background(), uuid); err == nil { + for _, altID := range altPortalIDs { + if altID != portalID { + recoverLog.Info(). + Str("original_portal_id", portalID). + Str("canonical_portal_id", altID). + Msg("Resolved group recovery portal to canonical portal_id via cloud_chat") + portalID = altID + portalKey.ID = networkid.PortalID(altID) + break + } + } + } + } + + // Always honor RecoverChat from APNs. Two scenarios: + // 1. Echo of our own !restore-chat → portal already exists → ChatResync is a no-op. + // 2. User recovered from iPhone/Mac → legitimate recovery → we should recreate the portal. + // Previously this blocked non-tombstone entries, but that prevented legitimate + // iPhone recoveries of chats deleted from Beeper. + recoverLog.Info(). + Str("portal_id", portalID). + Msg("Processing incoming Apple chat recovery from trash — queuing") + + // Cache participants from the recover message so resolveGroupMembers can find + // them during ChatResync even before cloud_chat persistence settles. + var seedParticipants []string + for _, p := range msg.DeleteChatParticipants { + if n := normalizeIdentifierForPortalID(p); n != "" { + seedParticipants = append(seedParticipants, n) + } + } + if strings.HasPrefix(portalID, "gid:") && len(msg.DeleteChatParticipants) > 0 { + normalized := make([]string, 0, len(msg.DeleteChatParticipants)+1) + normalized = append(normalized, c.handle) + for _, p := range msg.DeleteChatParticipants { + normalized = append(normalized, addIdentifierPrefix(p)) + } + sort.Strings(normalized) + c.imGroupParticipantsMu.Lock() + c.imGroupParticipants[portalID] = normalized + c.imGroupParticipantsMu.Unlock() + } + + chatID := "" + if msg.DeleteChatGuid != nil { + chatID = *msg.DeleteChatGuid + } + groupID := "" + if msg.DeleteChatGroupId != nil { + groupID = *msg.DeleteChatGroupId + } + if err := c.startRestoreBackfillPipeline(restorePipelineOptions{ + PortalID: portalID, + PortalKey: portalKey, + Source: "apns_chat_recover", + Participants: seedParticipants, + ChatID: chatID, + GroupID: groupID, + // APNs recover indicates Apple-side recovery already happened. + RecoverOnApple: false, + }); err != nil { + recoverLog.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to start restore pipeline for recovered chat") + } +} + +func (c *IMClient) startRestoreBackfillPipeline(opts restorePipelineOptions) error { + portalID := strings.TrimSpace(opts.PortalID) + if portalID == "" { + return fmt.Errorf("restore portal ID is empty") + } + opts.PortalID = portalID + if opts.PortalKey.ID == "" { + opts.PortalKey.ID = networkid.PortalID(portalID) + } + if opts.PortalKey.Receiver == "" && c.UserLogin != nil { + opts.PortalKey.Receiver = c.UserLogin.ID + } + if opts.Source == "" { + opts.Source = "restore_pipeline" + } + + c.restorePipelinesMu.Lock() + if c.restorePipelines == nil { + c.restorePipelines = make(map[string]bool) + } + if c.restorePipelines[portalID] { + c.restorePipelinesMu.Unlock() + if opts.Notify != nil { + label := opts.DisplayName + if label == "" { + label = portalID + } + opts.Notify("Restore for **%s** is already in progress.", label) + } + return nil + } + c.restorePipelines[portalID] = true + c.restorePipelinesMu.Unlock() + + go c.runRestoreBackfillPipeline(opts) + return nil +} + +func (c *IMClient) finishRestoreBackfillPipeline(portalID string) { + c.restorePipelinesMu.Lock() + delete(c.restorePipelines, portalID) + c.restorePipelinesMu.Unlock() +} + +func (c *IMClient) notifyRestoreStatus(opts restorePipelineOptions, format string, args ...any) { + if opts.Notify == nil { + return + } + opts.Notify(format, args...) +} + +func restoreRetryDelay(attempt int) time.Duration { + switch attempt { + case 1: + return 15 * time.Second + case 2: + return 60 * time.Second + case 3: + return 3 * time.Minute + default: + return 10 * time.Minute + } +} + +func (c *IMClient) runRestoreBackfillPipeline(opts restorePipelineOptions) { + defer c.finishRestoreBackfillPipeline(opts.PortalID) + + portalID := opts.PortalID + portalKey := opts.PortalKey + ctx := context.Background() + + displayName := strings.TrimSpace(opts.DisplayName) + if displayName == "" { + displayName = friendlyPortalName(ctx, c.Main.Bridge, c, portalKey, portalID) + } + if displayName == "" { + displayName = portalID + } + + log := c.UserLogin.Log.With(). + Str("portal_id", portalID). + Str("source", opts.Source). + Logger() + + if !c.Main.Config.UseCloudKitBackfill() || c.cloudStore == nil { + log.Warn().Msg("Restore pipeline started without CloudKit backfill; queueing plain ChatResync") + c.refreshRecoveredPortalAfterCloudSync(log, portalKey, opts.Source) + c.notifyRestoreStatus(opts, "Restore of **%s** queued.", displayName) + return + } + + c.notifyRestoreStatus(opts, "Restoring **%s** — syncing iCloud history…", displayName) + + // Stage 1: restore prerequisites (undelete + metadata seeding). + // Lock only covers DB mutations. Network I/O (metadata refresh, + // recycle bin recovery, Apple APNs) runs after unlock to avoid + // starving concurrent restores during slow CloudKit calls. + c.restoreMu.Lock() + if err := c.cloudStore.setRestoreOverride(ctx, portalID); err != nil { + log.Warn().Err(err).Msg("Failed to persist restore override") + } + c.recentlyDeletedPortalsMu.Lock() + delete(c.recentlyDeletedPortals, portalID) + c.recentlyDeletedPortalsMu.Unlock() + if len(opts.Participants) > 0 || opts.DisplayName != "" || opts.ChatID != "" || opts.GroupID != "" || opts.GroupPhotoGuid != "" { + c.cloudStore.seedChatFromRecycleBin( + ctx, + portalID, + opts.ChatID, + opts.GroupID, + opts.DisplayName, + opts.GroupPhotoGuid, + opts.Participants, + ) + } + // Clear the chat-level tombstone (deleted=TRUE) that seedDeletedChatsFromRecycleBin + // may have set. Without this, the main CloudKit zone message sync's portalHasChat + // check returns false and silently discards all messages for this portal. + if err := c.cloudStore.undeleteCloudChatByPortalID(ctx, portalID); err != nil { + log.Warn().Err(err).Msg("Failed to undelete cloud_chat tombstone for restore") + } else { + log.Info().Msg("Cleared cloud_chat tombstone for restore portal") + } + undeleted, err := c.cloudStore.undeleteCloudMessagesByPortalID(ctx, portalID) + if err != nil { + log.Warn().Err(err).Msg("Failed to undelete cloud_message rows") + } else { + log.Info().Int("undeleted", undeleted).Msg("Undeleted cloud_message rows for restore") + } + needsRecoverMessages := false + if hasMessages, err := c.cloudStore.hasPortalMessages(ctx, portalID); err != nil { + log.Warn().Err(err).Msg("Failed to check portal messages during restore") + } else if !hasMessages { + needsRecoverMessages = true + } + c.restoreMu.Unlock() + + // Network I/O: refresh metadata and recover messages outside the mutex. + // The restorePipelines map already prevents per-portal concurrency. + if strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") { + c.refreshRecoveredChatMetadata(log, portalID, opts.Participants) + } + if needsRecoverMessages { + c.recoverMessagesFromRecycleBin(log, portalID, opts.Participants) + } + if opts.RecoverOnApple { + c.recoverChatOnApple(portalID) + } + + // Stage 2: Attempt to import CloudKit message history. + // + // Retry up to maxRestoreAttempts times. CloudKit is eventually consistent — + // after Apple recovers a chat, messages may still be propagating from the + // recycle bin zone back to the main zone. Each attempt runs the full + // targeted+unfiltered CloudFetchRecentMessages path. + // + // We do NOT do a forced full-zone re-sync here. Clearing global zone tokens + // causes every message in every portal to be re-ingested through + // resolveConversationID. When cloud_chat rows for other portals are + // soft-deleted at that moment, from-me messages can be misrouted to the + // self-chat portal, corrupting it. + + // Create a context that cancels on shutdown so CloudKit fetches + // are bounded and interruptible. + fetchCtx, fetchCancel := context.WithCancel(context.Background()) + defer fetchCancel() + if c.stopChan != nil { + stopCh := c.stopChan + go func() { + select { + case <-stopCh: + fetchCancel() + case <-fetchCtx.Done(): + } + }() + } + + const maxRestoreAttempts = 4 + historyImported := false + for attempt := range maxRestoreAttempts { + if attempt > 0 { + delay := restoreRetryDelay(attempt) + c.notifyRestoreStatus(opts, "Restore for **%s** is still syncing. Retrying in %s.", displayName, delay.Round(time.Second)) + select { + case <-time.After(delay): + case <-fetchCtx.Done(): + log.Info().Int("attempt", attempt).Msg("Restore pipeline stopped during retry wait") + } + if fetchCtx.Err() != nil { + break + } + } + + attemptCtx, attemptCancel := context.WithTimeout(fetchCtx, 2*time.Minute) + imported, diag, importErr := c.fetchRecoveredMessagesFromCloudKit(attemptCtx, log.With().Int("attempt", attempt+1).Logger(), portalID) + attemptCancel() + + if fetchCtx.Err() != nil { + break + } + if importErr != nil { + log.Warn().Err(importErr).Int("attempt", attempt+1).Msg("Targeted restore fetch failed") + } else if imported > 0 { + log.Info(). + Int("imported", imported). + Int("attempt", attempt+1). + Msg("Restore pipeline: imported messages from CloudKit") + historyImported = true + break + } else { + // Emit a diagnostic notification to help diagnose zero-message fetches. + // Two cases: + // diag.UnfilteredTotal == 0: messages not yet in main CloudKit zone + // (likely still propagating from recycle bin after Apple recovery) + // → worth retrying later, CloudKit is eventually consistent + // diag.UnfilteredTotal > 0 but matched == 0: messages are in the + // zone but under a different chatId (e.g. phone number vs email). + // SampleChatIDs shows what chatIds ARE present. + // → retrying will never help; stop and report to the user + if diag != nil && diag.UnfilteredTotal > 0 { + log.Warn().Int("attempt", attempt+1). + Int("zone_total", diag.UnfilteredTotal). + Strs("sample_chat_ids", diag.SampleChatIDs). + Msg("Restore pipeline: messages in zone but none matched portal — chatId format mismatch, stopping retries") + c.notifyRestoreStatus(opts, + "Restore of **%s** — scanned %d messages in recovery zone but none matched this chat. "+ + "The chat may be in the main iCloud zone (will appear once sync completes). "+ + "Sample IDs found: %s. Check logs for `Unfiltered scan complete`.", + displayName, diag.UnfilteredTotal, strings.Join(diag.SampleChatIDs, ", ")) + // A chatId format mismatch won't resolve on its own — break out of + // the retry loop immediately. + break + } + // diag.UnfilteredTotal == 0 (or diag is nil): messages not yet visible + // in the main zone — may still be in recycle bin. Worth retrying. + if diag != nil { + log.Warn().Int("attempt", attempt+1). + Msg("Restore pipeline: CloudKit main zone empty (messages may still be in recycle bin)") + if attempt == 0 { + c.notifyRestoreStatus(opts, "Restore of **%s** — iCloud history not yet available (messages may still be moving from recycle bin to main zone). Retrying…", displayName) + } + } else { + log.Warn().Int("attempt", attempt+1).Msg("Restore pipeline: fetch returned 0 messages") + } + } + } + + // If shutdown was the reason we exited the retry loop, don't create + // portals or send messages — just exit cleanly. + if fetchCtx.Err() != nil { + log.Info().Msg("Restore pipeline: shutdown detected, skipping portal recreation") + return + } + + // Always recreate the portal regardless of whether we found history. + // The old code always did this — without it, a failed/delayed fetch means + // the portal never comes back at all. The normal CloudKit sync will + // backfill history when messages become available in the main zone. + // + // When no history was imported, send a bot notice into the restored room + // so the user sees the chat in Beeper (empty rooms may be hidden) and + // understands why there's no message history. + var postCreate func(context.Context, *bridgev2.Portal) + if !historyImported { + postCreate = func(ctx context.Context, portal *bridgev2.Portal) { + if portal == nil || portal.MXID == "" { + return + } + notice := "Chat restored from iCloud. No message history was available — messages may have expired from Apple's recycle bin." + _, err := c.Main.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{ + Parsed: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: notice, + }, + }, nil) + if err != nil { + log.Warn().Err(err).Str("portal_id", string(portal.ID)). + Msg("Failed to send empty-restore notice to room") + } + } + } + c.queueRecoveredPortalResync(log, portalKey, opts.Source, postCreate) + if historyImported { + c.notifyRestoreStatus(opts, "Restore of **%s** complete — history backfill is running.", displayName) + } else { + log.Warn().Int("attempts", maxRestoreAttempts).Msg("Restore pipeline: no CloudKit history found after all attempts; portal recreated anyway") + c.notifyRestoreStatus(opts, "Restore of **%s** — chat recreated. Message history may appear once iCloud finishes syncing.", displayName) + } +} + +// refreshRecoveredChatMetadata performs a targeted CloudKit chat scan and +// re-ingests matching chat records for a recovered portal. This is restore-flow +// specific and ensures recovered group portals pick up custom names/photos even +// when the local tombstone row had sparse metadata. +func (c *IMClient) refreshRecoveredChatMetadata(log zerolog.Logger, portalID string, knownParticipants ...[]string) { + if c.client == nil || c.cloudStore == nil { + return + } + // Guard the CloudKit FFI calls below (upstream cloudkit.rs has + // reachable panic sites via type assertions). Missing one metadata + // refresh is strictly safer than crashing the bridge. + defer func() { + if r := recover(); r != nil { + log.Error().Interface("panic", r).Str("portal_id", portalID). + Msg("refreshRecoveredChatMetadata panicked — skipped") + } + }() + + ctx := context.Background() + targetGroupID := "" + targetUUID := "" + if strings.HasPrefix(portalID, "gid:") { + targetUUID = strings.TrimPrefix(portalID, "gid:") + targetGroupID = targetUUID + } + targetChatID := c.cloudStore.getChatIdentifierByPortalID(ctx, portalID) + + // Search the RECYCLE BIN for metadata, not the main chatManateeZone. + // Deleted chats are moved to the recycle bin zone — they no longer + // appear in the main zone. Paging chatManateeZone would never find + // them, wasting hundreds of CloudKit API calls for nothing. + recoverableChats, err := c.client.ListRecoverableChats() + if err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("Failed to list recoverable chats for metadata refresh") + return + } + + log.Info(). + Str("portal_id", portalID). + Str("target_chat_id", targetChatID). + Str("target_group_id", targetGroupID). + Int("recycle_bin_count", len(recoverableChats)). + Msg("refreshRecoveredChatMetadata: searching recycle bin") + for i, chat := range recoverableChats { + dn := "" + if chat.DisplayName != nil { + dn = *chat.DisplayName + } + log.Debug(). + Int("index", i). + Str("cloud_chat_id", chat.CloudChatId). + Str("group_id", chat.GroupId). + Int64("style", chat.Style). + Str("display_name", dn). + Int("participants", len(chat.Participants)). + Msg("refreshRecoveredChatMetadata: recycle bin entry") + } + + var matched []rustpushgo.WrappedCloudSyncChat + for _, chat := range recoverableChats { + sameChatID := targetChatID != "" && normalizeUUID(chat.CloudChatId) == normalizeUUID(targetChatID) + sameGroupID := targetGroupID != "" && normalizeUUID(chat.GroupId) == normalizeUUID(targetGroupID) + sameChatUUID := targetUUID != "" && normalizeUUID(chat.CloudChatId) == normalizeUUID(targetUUID) + if sameChatID || sameGroupID || sameChatUUID { + matched = append(matched, chat) + } + } + + // Fallback: if UUID matching failed (e.g. portal ID is a per-participant + // encryption UUID, not the real group_id), try matching by participants. + // Per-participant encryption envelopes each get a unique UUID, so the + // portal's gid: won't match any recycle bin chat record. But the + // chat record's participants WILL overlap with our known participants. + if len(matched) == 0 && len(knownParticipants) > 0 && len(knownParticipants[0]) > 0 { + knownSet := make(map[string]bool) + for _, p := range knownParticipants[0] { + norm := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(p, "tel:"), "mailto:")) + if norm != "" { + knownSet[norm] = true + } + } + for _, chat := range recoverableChats { + if chat.Style != 43 || len(chat.Participants) == 0 { + continue + } + overlapCount := 0 + for _, cp := range chat.Participants { + norm := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(normalizeIdentifierForPortalID(cp), "tel:"), "mailto:")) + if norm != "" && knownSet[norm] { + overlapCount++ + } + } + if overlapCount > 0 { + matched = append(matched, chat) + } + } + if len(matched) > 0 { + log.Info().Str("portal_id", portalID).Int("matched", len(matched)). + Msg("Matched recycle bin chat by participant overlap (per-participant UUID fallback)") + // Seed directly with our portal ID rather than going through + // ingestCloudChats, which would resolve to gid: + // (not our per-participant UUID portal ID). + for _, chat := range matched { + displayName := "" + if chat.DisplayName != nil { + displayName = *chat.DisplayName + } + var normParts []string + for _, p := range chat.Participants { + if n := normalizeIdentifierForPortalID(p); n != "" { + normParts = append(normParts, n) + } + } + photoGuid := "" + if chat.GroupPhotoGuid != nil { + photoGuid = *chat.GroupPhotoGuid + } + c.cloudStore.seedChatFromRecycleBin(ctx, portalID, chat.CloudChatId, chat.GroupId, displayName, photoGuid, normParts) + } + log.Info().Str("portal_id", portalID).Int("matched", len(matched)). + Msg("Seeded chat metadata from recycle bin (participant fallback)") + return + } + } + + if len(matched) == 0 { + log.Debug().Str("portal_id", portalID). + Msg("No match in recycle bin — scanning main CloudKit chat zone for group metadata") + // Recycle bin is empty (Apple already recovered the chat back to the + // main zone). Scan CloudSyncChats to find the group record with the + // custom name and photo. Limit to 30 pages to avoid excessive API use; + // recently-recovered chats should appear near the top of the feed. + knownSet := make(map[string]bool) + for _, slice := range knownParticipants { + for _, p := range slice { + norm := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(p, "tel:"), "mailto:")) + if norm != "" { + knownSet[norm] = true + } + } + } + // Also build from cloud_chat participants if knownParticipants is empty. + if len(knownSet) == 0 { + dbParts, _ := c.cloudStore.getChatParticipantsByPortalID(ctx, portalID) + for _, p := range dbParts { + norm := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(p, "tel:"), "mailto:")) + if norm != "" { + knownSet[norm] = true + } + } + } + if len(knownSet) > 0 { + var token *string + const maxChatScanPages = 30 + for page := 0; page < maxChatScanPages; page++ { + chatsPage, pageErr := safeCloudSyncChats(c.client, token) + if pageErr != nil { + log.Warn().Err(pageErr).Int("page", page). + Msg("CloudSyncChats page failed during main-zone name scan") + break + } + for _, chat := range chatsPage.Chats { + if chat.Style != 43 || len(chat.Participants) == 0 { + continue + } + overlapCount := 0 + for _, cp := range chat.Participants { + norm := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(normalizeIdentifierForPortalID(cp), "tel:"), "mailto:")) + if norm != "" && knownSet[norm] { + overlapCount++ + } + } + if overlapCount > 0 { + matched = append(matched, chat) + } + } + if len(matched) > 0 { + log.Info().Str("portal_id", portalID).Int("page", page).Int("matched", len(matched)). + Msg("Found group chat in main CloudKit zone (participant match)") + break + } + if chatsPage.ContinuationToken == nil { + break + } + token = chatsPage.ContinuationToken + } + } + if len(matched) == 0 { + log.Debug().Str("portal_id", portalID). + Msg("No matching chat metadata found in recycle bin or main zone") + return + } + // Seed from main zone match using same logic as recycle bin path. + for _, chat := range matched { + displayName := "" + if chat.DisplayName != nil { + displayName = *chat.DisplayName + } + var normParts []string + for _, p := range chat.Participants { + if n := normalizeIdentifierForPortalID(p); n != "" { + normParts = append(normParts, n) + } + } + photoGuid := "" + if chat.GroupPhotoGuid != nil { + photoGuid = *chat.GroupPhotoGuid + } + c.cloudStore.seedChatFromRecycleBin(ctx, portalID, chat.CloudChatId, chat.GroupId, displayName, photoGuid, normParts) + } + log.Info().Str("portal_id", portalID).Int("matched", len(matched)). + Msg("Seeded group chat metadata from main CloudKit zone") + return + } + + // Seed matched records directly with our portal ID rather than using + // ingestCloudChats. ingestCloudChats calls resolvePortalIDForCloudChat + // which can resolve to a DIFFERENT portal ID (e.g. gid: + // instead of our gid:), causing messages to be + // routed to wrong portals (including the self-chat). + for _, chat := range matched { + displayName := "" + if chat.DisplayName != nil { + displayName = *chat.DisplayName + } + var normParts []string + for _, p := range chat.Participants { + if n := normalizeIdentifierForPortalID(p); n != "" { + normParts = append(normParts, n) + } + } + photoGuid := "" + if chat.GroupPhotoGuid != nil { + photoGuid = *chat.GroupPhotoGuid + } + c.cloudStore.seedChatFromRecycleBin(ctx, portalID, chat.CloudChatId, chat.GroupId, displayName, photoGuid, normParts) + } + + log.Info().Str("portal_id", portalID).Int("matched", len(matched)). + Msg("Seeded recovered chat metadata from recycle bin (UUID match)") +} + +// recoverMessagesFromRecycleBin attempts to recover messages for a portal from +// Apple's recycle bin zone. When a chat is deleted, its messages are moved to +// recoverableMessageDeleteZone — NOT the main messageManateeZone. This function +// uses ListRecoverableMessageGuids to find those messages and cross-references +// them with the local cloud_message table to undelete matching rows. +// +// This replaces the old approach of paging ALL of messageManateeZone (which was +// both extremely expensive and incorrect — deleted messages aren't there). +func (c *IMClient) recoverMessagesFromRecycleBin(log zerolog.Logger, portalID string, knownParticipants ...[]string) { + if c.client == nil || c.cloudStore == nil { + return + } + // CloudKit FFI path — guard against upstream panics (cloudkit.rs type + // assertions). Dropping one restore pass is safer than crashing. + defer func() { + if r := recover(); r != nil { + log.Error().Interface("panic", r).Str("portal_id", portalID). + Msg("recoverMessagesFromRecycleBin panicked — skipped") + } + }() + + ctx := context.Background() + + // Get recoverable message GUIDs from the recycle bin zone. + entries, err := c.client.ListRecoverableMessageGuids() + if err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("Failed to list recoverable message GUIDs for restore") + return + } + + if len(entries) == 0 { + log.Debug().Str("portal_id", portalID).Msg("No recoverable messages found in recycle bin") + return + } + + // Extract just the GUIDs from entries (format: "guid|metadata..."). + var allGUIDs []string + for _, entry := range entries { + guid := recoverableGUIDFromEntry(entry) + if guid != "" { + allGUIDs = append(allGUIDs, guid) + } + } + + if len(allGUIDs) == 0 { + return + } + + // Undelete cloud_message rows for this portal whose GUIDs appear in the + // recycle bin. The rows were soft-deleted during CloudKit sync when their + // parent chat was deleted. + nowMS := time.Now().UnixMilli() + restored := 0 + const chunkSize = 500 + for i := 0; i < len(allGUIDs); i += chunkSize { + end := i + chunkSize + if end > len(allGUIDs) { + end = len(allGUIDs) + } + chunk := allGUIDs[i:end] + + placeholders := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)+3) + args = append(args, c.cloudStore.loginID, portalID, nowMS) + for j, guid := range chunk { + placeholders[j] = fmt.Sprintf("$%d", j+4) + args = append(args, guid) + } + query := fmt.Sprintf( + `UPDATE cloud_message SET deleted=FALSE, updated_ts=$3 WHERE login_id=$1 AND portal_id=$2 AND guid IN (%s) AND deleted=TRUE`, + strings.Join(placeholders, ","), + ) + result, execErr := c.cloudStore.db.Exec(ctx, query, args...) + if execErr != nil { + log.Warn().Err(execErr).Msg("Failed to undelete recovered messages from recycle bin") + continue + } + n, _ := result.RowsAffected() + restored += int(n) + } + + log.Info().Str("portal_id", portalID).Int("recycle_bin_guids", len(allGUIDs)). + Int("restored", restored).Msg("Recovered messages from recycle bin") + + // If no existing rows were undeleted (cloud_message is empty), seed + // placeholder rows from the recycle bin metadata. This makes + // hasPortalMessages() return true so GetChatInfo.CanBackfill enables + // backfill. The placeholder rows carry enough metadata (guid, sender, + // timestamp, cloud_chat_id) for the bridge to page CloudKit messages. + if restored == 0 { + // Build a set of acceptable senders from known participants. + // This prevents mixing messages from other deleted groups when + // accepting per-participant encryption UUID gid: entries. + knownSenders := make(map[string]bool) + if len(knownParticipants) > 0 { + for _, p := range knownParticipants[0] { + norm := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(p, "tel:"), "mailto:")) + if norm != "" { + knownSenders[norm] = true + } + } + } + isGidPortal := strings.HasPrefix(portalID, "gid:") + + seeded := 0 + for _, entry := range entries { + metadata, ok := parseRecoverableMessageMetadata(entry) + if !ok { + continue + } + guid := recoverableGUIDFromEntry(entry) + if guid == "" { + continue + } + resolvedPortal := c.resolveConversationID(ctx, metadata.wrappedMessage(guid)) + if resolvedPortal == portalID { + // Exact match — always accept + } else if isGidPortal && strings.HasPrefix(resolvedPortal, "gid:") { + // Per-participant encryption UUID. Accept if sender is a + // known participant, or if we have no participant data. + if len(knownSenders) > 0 { + senderNorm := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix( + normalizeIdentifierForPortalID(metadata.Sender), "tel:"), "mailto:")) + if !knownSenders[senderNorm] && !metadata.IsFromMe { + continue + } + } + // Accept: same group, different per-participant UUID + } else { + continue + } + + _, insertErr := c.cloudStore.db.Exec(ctx, ` + INSERT INTO cloud_message (login_id, guid, chat_id, portal_id, timestamp_ms, sender, is_from_me, service, deleted, created_ts, updated_ts, record_name, has_body) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, FALSE, $9, $9, $10, TRUE) + ON CONFLICT (login_id, guid) DO UPDATE SET deleted=FALSE, portal_id=$4 + `, c.cloudStore.loginID, guid, metadata.CloudChatID, portalID, metadata.TimestampMS, metadata.Sender, metadata.IsFromMe, metadata.Service, nowMS, metadata.RecordName) + if insertErr != nil { + log.Debug().Err(insertErr).Str("guid", guid).Msg("Failed to seed cloud_message from recycle bin") + continue + } + seeded++ + } + if seeded > 0 { + log.Info().Str("portal_id", portalID).Int("seeded", seeded). + Msg("Seeded cloud_message from recycle bin metadata (cloud_message was empty)") + } + } +} + +// fetchAndResyncRecoveredChat fetches messages from CloudKit for a recovered +// chat, imports them into the local cache, then queues ChatResync. +func (c *IMClient) fetchAndResyncRecoveredChat(log zerolog.Logger, portalKey networkid.PortalKey, portalID string) { + imported, _, err := c.fetchRecoveredMessagesFromCloudKit(context.Background(), log, portalID) + if err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to import CloudKit messages for recovered chat") + } else { + log.Info().Int("imported", imported).Str("portal_id", portalID). + Msg("Imported CloudKit messages for recovered chat") + } + c.refreshRecoveredPortalAfterCloudSync(log, portalKey, "apns_chat_recover_fetched") +} + +// restoreFetchDiagnostic carries diagnostic info from fetchRecoveredMessagesFromCloudKit +// for user-visible status messages when the fetch returns 0 messages. +type restoreFetchDiagnostic struct { + // UnfilteredTotal is the total number of non-deleted messages found in + // CloudFetchRecentMessages with no chatId filter. If 0, messages are not + // yet in the main CloudKit zone (likely still in the recycle bin zone + // waiting for Apple-side propagation after chat recovery). + UnfilteredTotal int + // SampleChatIDs holds up to 20 chatId values from the unfiltered scan + // that did NOT match the target portal — helps diagnose chatId format + // mismatches (e.g. messages stored under a phone number instead of email). + SampleChatIDs []string +} + +// safeCloudFetchRecent wraps CloudFetchRecentMessages in a panic recovery. +// Rust CloudKit parsing panics (e.g. "Operation UUID has no response?") propagate +// through FFI as Go panics. Recovering here prevents a single bad CloudKit +// response from crashing the whole bridge. +func (c *IMClient) safeCloudFetchRecent(log zerolog.Logger, chatID *string, maxPages, maxMessages uint32) (msgs []rustpushgo.WrappedCloudSyncMessage, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("CloudFetchRecentMessages panicked: %v", r) + log.Error().Err(err).Msg("Caught Rust panic in CloudFetchRecentMessages") + } + }() + return c.client.CloudFetchRecentMessages(0, chatID, maxPages, maxMessages) +} + +// fetchRecoveredMessagesFromCloudKit runs the targeted restore fetch path and +// imports matched rows into cloud_message for the given portal. +// Returns (importedCount, diagnostic, error). diagnostic is non-nil when the +// unfiltered fallback ran (all targeted fetches returned 0). +func (c *IMClient) fetchRecoveredMessagesFromCloudKit(ctx context.Context, log zerolog.Logger, portalID string) (int, *restoreFetchDiagnostic, error) { + if c.cloudStore == nil { + return 0, nil, fmt.Errorf("cloud store not initialized") + } + if c.client == nil { + return 0, nil, fmt.Errorf("rustpush client not initialized") + } + + // Resolve the CloudKit chat_id for this portal. + cloudChatID := c.cloudStore.getChatIdentifierByPortalID(ctx, portalID) + // Also try the stored group_id as a fallback chatId — it may differ from + // the per-participant UUID in the portal_id (see per-participant UUID lore). + groupIDForFetch := "" + if strings.HasPrefix(portalID, "gid:") { + groupIDForFetch = c.cloudStore.getGroupIDForPortalID(ctx, portalID) + } + if cloudChatID == "" { + if strings.HasPrefix(portalID, "gid:") { + uuid := strings.TrimPrefix(portalID, "gid:") + cloudChatID = "any;+;" + uuid + } else { + identifier := strings.TrimPrefix(strings.TrimPrefix(portalID, "mailto:"), "tel:") + cloudChatID = "iMessage;-;" + identifier + } + } + + log.Info(). + Str("portal_id", portalID). + Str("cloud_chat_id", cloudChatID). + Msg("Fetching messages from CloudKit for recovered chat with empty local cache") + + // Build the set of portal IDs that should be considered "this portal". + acceptableIDs := map[string]bool{portalID: true} + if !strings.Contains(portalID, ",") && !strings.HasPrefix(portalID, "gid:") { + contactResolved := string(c.resolveContactPortalID(portalID)) + acceptableIDs[contactResolved] = true + contact := c.lookupContact(portalID) + if contact != nil { + for _, altID := range contactPortalIDs(contact) { + acceptableIDs[altID] = true + } + } + } + + rawIdentifier := strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(portalID, "tel:"), "mailto:"), "gid:") + acceptableChatIDSuffixes := map[string]bool{ + strings.ToLower(rawIdentifier): true, + } + if !strings.Contains(portalID, ",") && !strings.HasPrefix(portalID, "gid:") { + for id := range acceptableIDs { + raw := strings.TrimPrefix(strings.TrimPrefix(id, "tel:"), "mailto:") + acceptableChatIDSuffixes[strings.ToLower(raw)] = true + } + } + if strings.HasPrefix(portalID, "gid:") { + portalUUID := strings.TrimPrefix(portalID, "gid:") + acceptableChatIDSuffixes[normalizeUUID(portalUUID)] = true + acceptableChatIDSuffixes[strings.ToLower(portalUUID)] = true + if chatID := c.cloudStore.getChatIdentifierByPortalID(ctx, portalID); chatID != "" { + acceptableChatIDSuffixes[normalizeUUID(chatID)] = true + acceptableChatIDSuffixes[strings.ToLower(chatID)] = true + } + if gid := c.cloudStore.getGroupIDForPortalID(ctx, portalID); gid != "" { + acceptableChatIDSuffixes[normalizeUUID(gid)] = true + acceptableChatIDSuffixes[strings.ToLower(gid)] = true + } + } + + var matched []rustpushgo.WrappedCloudSyncMessage + tryTargetedFetch := func(chatID string) { + if chatID == "" || ctx.Err() != nil { + return + } + chatIDCopy := chatID + targeted, fetchErr := c.safeCloudFetchRecent(log, &chatIDCopy, 50, 5000) + if fetchErr != nil { + log.Warn().Err(fetchErr).Str("cloud_chat_id", chatID). + Msg("CloudFetchRecentMessages failed for recovered chat") + return + } + log.Info().Int("count", len(targeted)).Str("portal_id", portalID). + Str("cloud_chat_id", chatID). + Msg("Targeted CloudKit fetch for recovered chat") + for _, msg := range targeted { + if !msg.Deleted { + matched = append(matched, msg) + } + } + } + tryTargetedFetch(cloudChatID) + if len(matched) == 0 && !strings.HasPrefix(portalID, "gid:") { + suffix := "" + if strings.HasPrefix(cloudChatID, "any;-;") { + suffix = strings.TrimPrefix(cloudChatID, "any;-;") + tryTargetedFetch("iMessage;-;" + suffix) + } else if strings.HasPrefix(cloudChatID, "iMessage;-;") { + suffix = strings.TrimPrefix(cloudChatID, "iMessage;-;") + tryTargetedFetch("any;-;" + suffix) + } + if len(matched) == 0 && suffix != "" { + tryTargetedFetch("SMS;-;" + suffix) + } + } + if len(matched) == 0 && !strings.Contains(cloudChatID, ";") && cloudChatID != "" { + if strings.HasPrefix(portalID, "gid:") { + tryTargetedFetch("any;+;" + cloudChatID) + } else { + tryTargetedFetch("iMessage;-;" + cloudChatID) + if len(matched) == 0 { + tryTargetedFetch("any;-;" + cloudChatID) + } + if len(matched) == 0 { + tryTargetedFetch("SMS;-;" + cloudChatID) + } + } + } + if len(matched) == 0 && groupIDForFetch != "" && groupIDForFetch != strings.TrimPrefix(cloudChatID, "any;+;") { + tryTargetedFetch("any;+;" + groupIDForFetch) + if len(matched) == 0 { + tryTargetedFetch("iMessage;+;" + groupIDForFetch) + } + } + + var diag *restoreFetchDiagnostic + if len(matched) == 0 && ctx.Err() != nil { + return 0, nil, ctx.Err() + } + if len(matched) == 0 { + log.Info().Str("portal_id", portalID). + Msg("All targeted fetches returned 0 — falling back to unfiltered CloudFetchRecentMessages") + // Use the same page/message limits as the targeted fetch (50 pages, 5000 msgs). + // Larger limits (e.g. 500 pages) caused CloudKit to return malformed responses + // that panicked the Rust code ("Operation UUID has no response?"), crashing the + // bridge. 50 pages × ~200 msgs/page = ~10k messages — enough for diagnostics. + const maxRecoveryPages = 50 + unfiltered, scanErr := c.safeCloudFetchRecent(log, nil, maxRecoveryPages, 10000) + if scanErr != nil { + return 0, nil, fmt.Errorf("unfiltered CloudFetchRecentMessages failed: %w", scanErr) + } + seenChatIDs := make(map[string]bool) + for _, msg := range unfiltered { + if msg.Deleted { + continue + } + resolved := c.resolveConversationID(ctx, msg) + if acceptableIDs[resolved] { + matched = append(matched, msg) + continue + } + if msg.CloudChatId == "" { + continue + } + suffix := strings.ToLower(msg.CloudChatId) + if parts := strings.SplitN(suffix, ";-;", 2); len(parts) == 2 { + suffix = parts[1] + } else if parts := strings.SplitN(suffix, ";+;", 2); len(parts) == 2 { + suffix = parts[1] + } + if acceptableChatIDSuffixes[suffix] || acceptableChatIDSuffixes[normalizeUUID(suffix)] { + matched = append(matched, msg) + } else if !seenChatIDs[msg.CloudChatId] && len(seenChatIDs) < 20 { + seenChatIDs[msg.CloudChatId] = true + } + } + sampleIDs := make([]string, 0, len(seenChatIDs)) + for k := range seenChatIDs { + sampleIDs = append(sampleIDs, k) + } + sort.Strings(sampleIDs) + log.Info().Int("total", len(unfiltered)).Int("matched", len(matched)). + Str("portal_id", portalID). + Strs("sample_chat_ids", sampleIDs). + Msg("Unfiltered scan complete") + diag = &restoreFetchDiagnostic{ + UnfilteredTotal: len(unfiltered), + SampleChatIDs: sampleIDs, + } + } + + if len(matched) == 0 { + return 0, diag, nil + } + + rows := make([]cloudMessageRow, 0, len(matched)) + for _, msg := range matched { + if msg.Guid == "" || msg.Deleted { + continue + } + text := "" + if msg.Text != nil { + text = *msg.Text + } + subject := "" + if msg.Subject != nil { + subject = *msg.Subject + } + timestampMS := msg.TimestampMs + if timestampMS <= 0 { + timestampMS = time.Now().UnixMilli() + } + tapbackTargetGUID := "" + if msg.TapbackTargetGuid != nil { + tapbackTargetGUID = *msg.TapbackTargetGuid + } + tapbackEmoji := "" + if msg.TapbackEmoji != nil { + tapbackEmoji = *msg.TapbackEmoji + } + rows = append(rows, cloudMessageRow{ + GUID: msg.Guid, + RecordName: msg.RecordName, + CloudChatID: msg.CloudChatId, + PortalID: portalID, + TimestampMS: timestampMS, + Sender: msg.Sender, + IsFromMe: msg.IsFromMe, + Text: text, + Subject: subject, + Service: msg.Service, + Deleted: false, + TapbackType: msg.TapbackType, + TapbackTargetGUID: tapbackTargetGUID, + TapbackEmoji: tapbackEmoji, + DateReadMS: msg.DateReadMs, + HasBody: msg.HasBody, + }) + } + if len(rows) == 0 { + return 0, diag, nil + } + if err := c.cloudStore.upsertMessageBatch(ctx, rows); err != nil { + return 0, diag, fmt.Errorf("failed to import CloudKit messages for recovered chat: %w", err) + } + return len(rows), diag, nil +} + +func (c *IMClient) queueRecoveredPortalResync(log zerolog.Logger, portalKey networkid.PortalKey, source string, postCreate func(context.Context, *bridgev2.Portal)) { + portalID := string(portalKey.ID) + + // Use the newest BACKFILLABLE content timestamp. Placeholder rows from + // recycle-bin seeding can have GUID/timestamp metadata but no message body; + // treating those as "latest message" causes false-success resyncs that + // still backfill zero messages. + var latestMessageTS time.Time + if c.cloudStore != nil { + if newestTS, err := c.cloudStore.getNewestBackfillableMessageTimestamp(context.Background(), portalID, true); err == nil && newestTS > 0 { + latestMessageTS = time.UnixMilli(newestTS) + } + } + + log.Info(). + Str("portal_id", portalID). + Str("source", source). + Time("latest_message_ts", latestMessageTS). + Msg("Queueing ChatResync for recovered chat") + + // Always CreatePortal=true for recovery. The portal room may have been + // deleted (com.beeper.delete_chat or ChatDelete) but the portal DB row + // can linger with a stale MXID. Without CreatePortal=true, bridgev2 + // treats the resync as a metadata update and skips forward backfill. + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + PortalKey: portalKey, + CreatePortal: true, + Timestamp: time.Now(), + LogContext: func(lc zerolog.Context) zerolog.Context { + return lc.Str("source", source) + }, + PostHandleFunc: postCreate, + }, + GetChatInfoFunc: c.GetChatInfo, + LatestMessageTS: latestMessageTS, + }) +} + +func (c *IMClient) refreshRecoveredPortalAfterCloudSync(log zerolog.Logger, portalKey networkid.PortalKey, source string) { + c.queueRecoveredPortalResync(log, portalKey, source, nil) +} + +func (c *IMClient) handleReadReceipt(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + if msg.IsStoredMessage { + log.Debug().Str("uuid", msg.Uuid).Msg("Skipping stored read receipt") + return + } + // Drop read receipts while CloudKit sync is in progress or the APNs buffer + // hasn't been flushed yet (forward backfills still pending, or the 10-second + // safety net hasn't fired). Once flushedAt is stamped, backfill is done and + // any arriving receipt is a genuine live event. + flushedAt := atomic.LoadInt64(&c.apnsBufferFlushedAt) + syncDone := c.isCloudSyncDone() + if !syncDone || flushedAt == 0 { + log.Debug(). + Str("uuid", msg.Uuid). + Bool("sync_done", syncDone). + Int64("flushed_at", flushedAt). + Msg("Skipping read receipt during CloudKit sync/backfill") + return + } + + // Suppress duplicate receipts in the post-backfill grace window (60s). + // After the APNs buffer flushes, stale receipts that arrived between the + // 30s IsStoredMessage window and backfill completion can leak through. + // If CloudKit already has a valid date_read_ms for this message, a + // synthetic receipt with the correct historical timestamp was already + // queued — drop the duplicate to prevent overwriting with time.Now(). + const postBackfillGraceMS = 60_000 + if c.cloudStore != nil && (time.Now().UnixMilli()-flushedAt) < postBackfillGraceMS { + if hasReceipt, err := c.cloudStore.hasCloudReadReceipt(context.Background(), msg.Uuid); err == nil && hasReceipt { + log.Debug(). + Str("uuid", msg.Uuid). + Msg("Skipping duplicate read receipt — CloudKit already has date_read_ms (post-backfill grace)") + return + } + } + + portalKey := c.makeReceiptPortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + ctx := context.Background() + + // Try sender_guid lookup — first check gid: portal ID, then cache + if msg.SenderGuid != nil && *msg.SenderGuid != "" { + gidPortalKey := networkid.PortalKey{ID: networkid.PortalID("gid:" + *msg.SenderGuid), Receiver: c.UserLogin.ID} + if gidPortal, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, gidPortalKey); gidPortal != nil && gidPortal.MXID != "" { + portalKey = gidPortalKey + log.Debug(). + Str("sender_guid", *msg.SenderGuid). + Str("resolved_portal", string(portalKey.ID)). + Msg("Resolved read receipt portal via gid: lookup") + goto resolved + } + c.imGroupGuidsMu.RLock() + for portalIDStr, guid := range c.imGroupGuids { + if strings.EqualFold(guid, *msg.SenderGuid) { + if c.guidCacheMatchIsStale(portalIDStr, msg.Participants) { + continue + } + portalKey = networkid.PortalKey{ID: networkid.PortalID(portalIDStr), Receiver: c.UserLogin.ID} + c.imGroupGuidsMu.RUnlock() + log.Debug(). + Str("sender_guid", *msg.SenderGuid). + Str("resolved_portal", string(portalKey.ID)). + Msg("Resolved read receipt portal via sender_guid cache lookup") + goto resolved + } + } + c.imGroupGuidsMu.RUnlock() + } + + // Fall back to group member tracking + if msg.Sender != nil { + if groupKey, ok := c.findGroupPortalForMember(*msg.Sender); ok { + portalKey = groupKey + log.Debug(). + Str("sender", *msg.Sender). + Str("resolved_portal", string(portalKey.ID)). + Msg("Resolved read receipt portal via group member lookup") + goto resolved + } + } + + // Last resort: use the initial portal key if it resolves to a valid portal. + { + portal, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if portal != nil && portal.MXID != "" { + goto resolved + } + } +resolved: + if msg.Uuid != "" { + if msgPortal := c.resolvePortalByTargetMessage(log, msg.Uuid); msgPortal.ID != "" && + (msgPortal.ID != portalKey.ID || msgPortal.Receiver != portalKey.Receiver) { + log.Debug(). + Str("uuid", msg.Uuid). + Str("old_portal", string(portalKey.ID)). + Str("resolved_portal", string(msgPortal.ID)). + Msg("Resolved read receipt portal via target message UUID") + portalKey = msgPortal + } + } + + readTime := time.UnixMilli(int64(msg.TimestampMs)) + sender := c.makeEventSender(msg.Sender) + sender = c.canonicalizeDMSender(portalKey, sender) + + if !sender.IsFromMe { + // Skip ghost receipts for messages that were backfilled from CloudKit. + // APNs read receipts for group chats lack participants/senderGuid, so + // the portal key often resolves to a DM rather than a gid: portal. + // Check cloud_message regardless of portal type — any message that was + // synced from CloudKit is a backfilled message whose ghost receipt + // would incorrectly show "Seen by [person]" on outgoing messages. + // Real-time messages (not in cloud_message) are unaffected. + if c.cloudStore != nil { + if backfilled, err := c.cloudStore.isCloudBackfilledMessage(context.Background(), msg.Uuid); err == nil && backfilled { + log.Debug(). + Str("uuid", msg.Uuid). + Str("portal_id", string(portalKey.ID)). + Msg("Skipping ghost read receipt for backfilled message") + return + } + } + // Ghost receipt ("they read my message"): bypass the framework and call + // SetBeeperInboxState directly. The standard framework path (QueueRemoteEvent + // → MarkRead → SetReadMarkers) ignores BeeperReadExtra["ts"] for ghost + // users, causing the homeserver to use server time instead of the actual + // read time from the APNs receipt. + c.sendGhostReadReceipt(&log, sender.Sender, portalKey, msg.Uuid, readTime) + } else { + // Self-receipt (unlikely for APNs read receipts, but handle gracefully). + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.Receipt{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventReadReceipt, + PortalKey: portalKey, + Sender: sender, + Timestamp: readTime, + }, + LastTarget: makeMessageID(msg.Uuid), + }) + } +} + +func (c *IMClient) handleDeliveryReceipt(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + // Don't gate on IsStoredMessage. Delivery receipts arrive within seconds + // of a send — almost always inside the 30s post-reconnect window — and + // SendMessageStatus is idempotent, so dropping them only erases the + // "delivered" tick from genuinely live messages. Read receipts naturally + // land later (after the user actually reads), which is why the same gate + // on handleReadReceipt didn't have the same visible regression. + ctx := context.Background() + + // Mirror handleReadReceipt's portal-resolution chain. Without these + // fallbacks, any drift in makeReceiptPortalKey output (e.g. sender_guid + // format churn) silently drops every delivery receipt while read receipts + // keep working via their fallback chain — which manifests as "Beeper + // shows read but never delivered" with zero log signal. + portalKey := c.makeReceiptPortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + resolved := false + + if msg.SenderGuid != nil && *msg.SenderGuid != "" { + gidPortalKey := networkid.PortalKey{ID: networkid.PortalID("gid:" + *msg.SenderGuid), Receiver: c.UserLogin.ID} + if gidPortal, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, gidPortalKey); gidPortal != nil && gidPortal.MXID != "" { + portalKey = gidPortalKey + log.Debug(). + Str("sender_guid", *msg.SenderGuid). + Str("resolved_portal", string(portalKey.ID)). + Msg("Resolved delivery receipt portal via gid: lookup") + resolved = true + } + if !resolved { + c.imGroupGuidsMu.RLock() + for portalIDStr, guid := range c.imGroupGuids { + if strings.EqualFold(guid, *msg.SenderGuid) { + if c.guidCacheMatchIsStale(portalIDStr, msg.Participants) { + continue + } + portalKey = networkid.PortalKey{ID: networkid.PortalID(portalIDStr), Receiver: c.UserLogin.ID} + log.Debug(). + Str("sender_guid", *msg.SenderGuid). + Str("resolved_portal", string(portalKey.ID)). + Msg("Resolved delivery receipt portal via sender_guid cache lookup") + resolved = true + break + } + } + c.imGroupGuidsMu.RUnlock() + } + } + + if !resolved && msg.Sender != nil { + if groupKey, ok := c.findGroupPortalForMember(*msg.Sender); ok { + portalKey = groupKey + log.Debug(). + Str("sender", *msg.Sender). + Str("resolved_portal", string(portalKey.ID)). + Msg("Resolved delivery receipt portal via group member lookup") + resolved = true + } + } + + if !resolved { + if portal, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey); portal != nil && portal.MXID != "" { + resolved = true + } + } + + if msg.Uuid != "" { + if msgPortal := c.resolvePortalByTargetMessage(log, msg.Uuid); msgPortal.ID != "" && + (msgPortal.ID != portalKey.ID || msgPortal.Receiver != portalKey.Receiver) { + log.Debug(). + Str("uuid", msg.Uuid). + Str("old_portal", string(portalKey.ID)). + Str("resolved_portal", string(msgPortal.ID)). + Msg("Resolved delivery receipt portal via target message UUID") + portalKey = msgPortal + resolved = true + } + } + + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if err != nil || portal == nil || portal.MXID == "" { + log.Debug(). + Err(err). + Bool("resolved", resolved). + Str("uuid", msg.Uuid). + Str("portal_key", string(portalKey.ID)). + Str("sender_guid", ptrStringOr(msg.SenderGuid, "")). + Msg("Dropping delivery receipt: no portal found after fallback chain") + return + } + + msgID := makeMessageID(msg.Uuid) + dbMessages, err := c.Main.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, msgID) + if err != nil || len(dbMessages) == 0 { + // Case-insensitive fallback — SMS relays may normalize UUID case + altUUID := strings.ToUpper(msg.Uuid) + if altUUID == msg.Uuid { + altUUID = strings.ToLower(msg.Uuid) + } + altMsgID := makeMessageID(altUUID) + dbMessages, err = c.Main.Bridge.DB.Message.GetAllPartsByID(ctx, portal.Receiver, altMsgID) + if err != nil || len(dbMessages) == 0 { + log.Debug(). + Err(err). + Str("uuid", msg.Uuid). + Str("portal_id", string(portalKey.ID)). + Str("receiver", string(portal.Receiver)). + Msg("Dropping delivery receipt: target message not in bridge DB") + return + } + log.Info(). + Str("uuid", msg.Uuid). + Str("alt_uuid", altUUID). + Str("portal_id", string(portalKey.ID)). + Msg("Delivery receipt matched via case-insensitive UUID fallback") + } + + normalizedSender := normalizeIdentifierForPortalID(ptrStringOr(msg.Sender, "")) + // For DM portals, use the portal ID as the sender identity so the ghost + // matches the canonical handle (avoids phantom ghost from alternate handles). + portalID := string(portalKey.ID) + if !strings.Contains(portalID, ",") && !strings.HasPrefix(portalID, "gid:") { + normalizedSender = portalID + } + senderUserID := makeUserID(normalizedSender) + ghost, err := c.Main.Bridge.GetGhostByID(ctx, senderUserID) + if err != nil || ghost == nil { + log.Debug(). + Err(err). + Str("uuid", msg.Uuid). + Str("portal_id", portalID). + Str("sender_user_id", string(senderUserID)). + Msg("Dropping delivery receipt: ghost not found for sender") + return + } + + for _, dbMsg := range dbMessages { + c.Main.Bridge.Matrix.SendMessageStatus(ctx, &bridgev2.MessageStatus{ + Status: event.MessageStatusSuccess, + DeliveredTo: []id.UserID{ghost.Intent.GetMXID()}, + }, &bridgev2.MessageStatusEventInfo{ + RoomID: portal.MXID, + SourceEventID: dbMsg.MXID, + Sender: dbMsg.SenderMXID, + }) + } +} + +func (c *IMClient) handleTyping(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + portalKey := c.makePortalKey(msg.Participants, msg.GroupName, msg.Sender, msg.SenderGuid) + + // For group typing indicators, iMessage may only include [sender, target] + // without the full participant list. If the portal key resolves to a + // non-existent portal (DM-style key), try sender_guid lookup first. + ctx := context.Background() + portal, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if (portal == nil || portal.MXID == "") && msg.SenderGuid != nil && *msg.SenderGuid != "" { + // Try gid: portal ID first + gidKey := networkid.PortalKey{ID: networkid.PortalID("gid:" + *msg.SenderGuid), Receiver: c.UserLogin.ID} + if gidPortal, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, gidKey); gidPortal != nil && gidPortal.MXID != "" { + portalKey = gidKey + log.Debug(). + Str("sender_guid", *msg.SenderGuid). + Str("resolved_portal", string(portalKey.ID)). + Msg("Resolved typing portal via gid: lookup") + goto found + } + // Fall back to cache lookup for legacy portals + c.imGroupGuidsMu.RLock() + for portalIDStr, guid := range c.imGroupGuids { + if strings.EqualFold(guid, *msg.SenderGuid) { + if c.guidCacheMatchIsStale(portalIDStr, msg.Participants) { + continue + } + portalKey = networkid.PortalKey{ID: networkid.PortalID(portalIDStr), Receiver: c.UserLogin.ID} + c.imGroupGuidsMu.RUnlock() + log.Debug(). + Str("sender_guid", *msg.SenderGuid). + Str("resolved_portal", string(portalKey.ID)). + Msg("Resolved typing portal via sender_guid cache lookup") + goto found + } + } + c.imGroupGuidsMu.RUnlock() + } + // Fall back to member tracking + if (portal == nil || portal.MXID == "") && msg.Sender != nil { + if groupKey, ok := c.findGroupPortalForMember(*msg.Sender); ok { + portalKey = groupKey + log.Debug(). + Str("sender", *msg.Sender). + Str("resolved_portal", string(portalKey.ID)). + Msg("Resolved typing portal via group member lookup") + } + } +found: + + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.Typing{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventTyping, + PortalKey: portalKey, + Sender: c.canonicalizeDMSender(portalKey, c.makeEventSender(msg.Sender)), + Timestamp: time.UnixMilli(int64(msg.TimestampMs)), + }, + Timeout: 60 * time.Second, + }) +} + +// ============================================================================ +// Matrix → iMessage +// ============================================================================ + +// convertURLPreviewToIMessage encodes a Beeper link preview (or auto-detected +// URL) into the sideband text prefix that Rust parses for rich link sending. +// Follows the pattern from mautrix-whatsapp's urlpreview.go. +func (c *IMClient) convertURLPreviewToIMessage(ctx context.Context, content *event.MessageEventContent) string { + log := zerolog.Ctx(ctx) + body := content.Body + + // Note: we intentionally do NOT treat empty BeeperLinkPreviews ([]) as + // "explicitly disabled." Beeper sends [] when it can't generate a preview + // itself (e.g. bare domains like "x.com"), but our bridge has its own + // og: scraper that can often succeed. So we fall through to auto-detection. + + // Priority 1: Explicit BeeperLinkPreviews from Matrix + if len(content.BeeperLinkPreviews) > 0 { + lp := content.BeeperLinkPreviews[0] + canonical := lp.CanonicalURL + if canonical == "" { + canonical = lp.MatchedURL + } + log.Debug(). + Str("matched_url", lp.MatchedURL). + Str("canonical_url", canonical). + Str("title", lp.Title). + Msg("Encoding Beeper link preview for iMessage") + return "\x00RL\x01" + lp.MatchedURL + "\x01" + canonical + "\x01" + lp.Title + "\x01" + lp.Description + "\x00" + body + } + + // Priority 2: Auto-detect URL and fetch preview via homeserver or og: scraping + if detectedURL := urlRegex.FindString(body); detectedURL != "" && isLikelyURL(detectedURL) { + fetchURL := normalizeURL(detectedURL) + log.Debug().Str("detected_url", detectedURL).Msg("Auto-detected URL in outbound message, fetching preview") + title, desc := "", "" + // Try homeserver preview first + if mc, ok := c.Main.Bridge.Matrix.(bridgev2.MatrixConnectorWithURLPreviews); ok { + if lp, err := mc.GetURLPreview(ctx, fetchURL); err == nil && lp != nil { + title = lp.Title + desc = lp.Description + log.Debug().Str("title", title).Str("description", desc).Msg("Got URL preview from homeserver for outbound") + } else if err != nil { + log.Debug().Err(err).Msg("Failed to fetch URL preview from homeserver for outbound") + } + } + // Fall back to our own og: scraping if homeserver didn't provide metadata + if title == "" && desc == "" { + ogData := fetchPageMetadata(ctx, fetchURL) + title = ogData["title"] + desc = ogData["description"] + if title != "" || desc != "" { + log.Debug().Str("title", title).Str("description", desc).Msg("Got URL preview from og: scraping for outbound") + } + } + return "\x00RL\x01" + detectedURL + "\x01" + fetchURL + "\x01" + title + "\x01" + desc + "\x00" + body + } + + return body +} + +// retrySendOnAPNsFlap retries an APNs-dependent send up to three times across +// 3s total when a transient APNs flap manifests as "Send timeout; try again". +// APNs reconnect grace is 30s on our side, so a short retry almost always +// lands on the restored connection. +func retrySendOnAPNsFlap(op func() error) error { + var err error + for attempt := 0; attempt < 3; attempt++ { + err = op() + if err == nil { + return nil + } + if msg := strings.ToLower(err.Error()); !strings.Contains(msg, "send timeout; try again") && !strings.Contains(msg, "sendtimedout") { + return err + } + time.Sleep(time.Duration(1+attempt) * time.Second) + } + return err +} + +func (c *IMClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { + if c.client == nil { + return nil, bridgev2.ErrNotLoggedIn + } + + conv := c.portalToConversation(msg.Portal) + + // File/image messages + if msg.Content.URL != "" || msg.Content.File != nil { + return c.handleMatrixFile(ctx, msg, conv) + } + + textToSend := c.convertURLPreviewToIMessage(ctx, msg.Content) + + replyGuid, replyPart := extractReplyInfo(msg.ReplyTo) + var uuid string + err := retrySendOnAPNsFlap(func() error { + var sendErr error + uuid, sendErr = c.client.SendMessage(conv, textToSend, nil, c.handle, replyGuid, replyPart, nil) + return sendErr + }) + if err != nil { + return nil, fmt.Errorf("failed to send iMessage: %w", err) + } + zerolog.Ctx(ctx).Info(). + Str("uuid", uuid). + Str("portal_id", string(msg.Portal.ID)). + Bool("is_sms", c.isPortalSMS(string(msg.Portal.ID))). + Msg("Message sent, storing UUID in bridge DB") + // Persist UUID immediately so echo detection works even if the portal + // is deleted before the APNs echo arrives. + if c.cloudStore != nil { + if err := c.cloudStore.persistMessageUUID(ctx, uuid, string(msg.Portal.ID), time.Now().UnixMilli(), true); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("uuid", uuid).Msg("Failed to persist sent message UUID; echo may be delivered as duplicate") + } + } + + // If the outbound message has a URL and the client didn't provide previews, + // add com.beeper.linkpreviews so Beeper renders it. Fire the double puppet + // edit for both nil (field omitted) and empty slice (client couldn't preview) + // since the bridge has its own og: scraper that can often succeed where + // the client couldn't. + if len(msg.Content.BeeperLinkPreviews) == 0 { + if detectedURL := urlRegex.FindString(msg.Content.Body); detectedURL != "" && isLikelyURL(detectedURL) { + go c.addOutboundURLPreview(msg.Event.ID, msg.Portal.MXID, msg.Content.Body, msg.Content.MsgType, detectedURL) + } + } + + return &bridgev2.MatrixMessageResponse{ + DB: &database.Message{ + ID: makeMessageID(uuid), + SenderID: makeUserID(c.handle), + Timestamp: time.Now(), + Metadata: &MessageMetadata{}, + }, + }, nil +} + +// addOutboundURLPreview edits an outbound Matrix event to add com.beeper.linkpreviews +// so Beeper displays a URL preview for messages sent from the client. +func (c *IMClient) addOutboundURLPreview(eventID id.EventID, roomID id.RoomID, body string, msgType event.MessageType, detectedURL string) { + log := c.UserLogin.Log.With(). + Str("component", "url_preview"). + Stringer("event_id", eventID). + Str("detected_url", detectedURL). + Logger() + ctx := log.WithContext(context.Background()) + + intent := c.UserLogin.User.DoublePuppet(ctx) + if intent == nil { + log.Debug().Msg("No double puppet available, skipping outbound URL preview edit") + return + } + + preview := fetchURLPreview(ctx, c.Main.Bridge, intent, roomID, detectedURL) + + editContent := &event.MessageEventContent{ + MsgType: msgType, + Body: body, + BeeperLinkPreviews: []*event.BeeperLinkPreview{preview}, + } + editContent.SetEdit(eventID) + + wrappedContent := &event.Content{Parsed: editContent} + _, err := intent.SendMessage(ctx, roomID, event.EventMessage, wrappedContent, nil) + if err != nil { + log.Warn().Err(err).Msg("Failed to send outbound URL preview edit") + } else { + log.Debug().Str("title", preview.Title).Msg("Sent outbound URL preview edit") + } +} + +// fixOutboundImage re-uploads a corrected image to Matrix and edits the +// original event so all Beeper clients (desktop, Android, etc.) see the +// image with the right format, MIME type, and dimensions. +func (c *IMClient) fixOutboundImage(msg *bridgev2.MatrixMessage, data []byte, mimeType, fileName string, width, height int) { + log := c.UserLogin.Log.With(). + Str("component", "image_fix"). + Stringer("event_id", msg.Event.ID). + Logger() + ctx := log.WithContext(context.Background()) + + intent := c.UserLogin.User.DoublePuppet(ctx) + if intent == nil { + log.Debug().Msg("No double puppet available, skipping outbound image fix") + return + } + + url, encFile, err := intent.UploadMedia(ctx, "", data, fileName, mimeType) + if err != nil { + log.Warn().Err(err).Msg("Failed to upload corrected image") + return + } + + editContent := &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: fileName, + Info: &event.FileInfo{ + MimeType: mimeType, + Size: len(data), + Width: width, + Height: height, + }, + } + if encFile != nil { + editContent.File = encFile + } else { + editContent.URL = url + } + editContent.SetEdit(msg.Event.ID) + + wrappedContent := &event.Content{Parsed: editContent} + _, err = intent.SendMessage(ctx, msg.Portal.MXID, event.EventMessage, wrappedContent, nil) + if err != nil { + log.Warn().Err(err).Msg("Failed to edit outbound image event") + } else { + log.Debug().Str("mime", mimeType).Int("size", len(data)).Msg("Fixed outbound image on Matrix") + } +} + +func (c *IMClient) handleMatrixFile(ctx context.Context, msg *bridgev2.MatrixMessage, conv rustpushgo.WrappedConversation) (*bridgev2.MatrixMessageResponse, error) { + var data []byte + var err error + if msg.Content.File != nil { + data, err = c.Main.Bridge.Bot.DownloadMedia(ctx, msg.Content.File.URL, msg.Content.File) + } else { + data, err = c.Main.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, nil) + } + if err != nil { + return nil, fmt.Errorf("failed to download media: %w", err) + } + + fileName := msg.Content.FileName + if fileName == "" { + fileName = msg.Content.Body + } + if fileName == "" { + fileName = "file" + } + + mimeType := "application/octet-stream" + if msg.Content.Info != nil && msg.Content.Info.MimeType != "" { + mimeType = msg.Content.Info.MimeType + } + + // Convert OGG Opus voice recordings to CAF Opus for native iMessage playback + data, mimeType, fileName = convertAudioForIMessage(data, mimeType, fileName) + + // Process outbound images: detect actual format, convert non-JPEG to JPEG, + // correct MIME type, and edit the Matrix event so all clients see it right. + var matrixEdited bool + if looksLikeImage(data) { + origMime := mimeType + if mimeType == "image/gif" { + // GIFs are fine as-is, just detect correct MIME + if detected := detectImageMIME(data); detected != "" && detected != mimeType { + mimeType = detected + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".gif" + } + } else if img, _, isJPEG := decodeImageData(data); img != nil { + if !isJPEG { + var buf bytes.Buffer + if encErr := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); encErr == nil { + data = buf.Bytes() + mimeType = "image/jpeg" + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".jpg" + } + } else if detected := detectImageMIME(data); detected != "" && detected != mimeType { + mimeType = detected + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".jpg" + } + // Edit the Matrix event with corrected image so other Beeper clients see it right + if mimeType != origMime { + b := img.Bounds() + go c.fixOutboundImage(msg, data, mimeType, fileName, b.Dx(), b.Dy()) + matrixEdited = true + } + } else { + // Can't decode but fix MIME type at least + if detected := detectImageMIME(data); detected != "" && detected != mimeType { + mimeType = detected + ext := ".bin" + switch detected { + case "image/jpeg": + ext = ".jpg" + case "image/png": + ext = ".png" + case "image/tiff": + ext = ".tiff" + } + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ext + } + } + } + _ = matrixEdited + + replyGuid, replyPart := extractReplyInfo(msg.ReplyTo) + + // Per MSC2530, when FileName is set and differs from Body, Body is the caption. + // Some clients duplicate the filename into Body (no real caption) — treating that + // as a caption would land in the iMessage subject field and show to iPhone users. + var caption *string + if msg.Content.FileName != "" && msg.Content.Body != "" && msg.Content.Body != msg.Content.FileName { + caption = &msg.Content.Body + } + + var uuid string + err = retrySendOnAPNsFlap(func() error { + var sendErr error + uuid, sendErr = c.client.SendAttachment(conv, data, mimeType, mimeToUTI(mimeType), fileName, c.handle, replyGuid, replyPart, caption) + return sendErr + }) + if err != nil { + return nil, fmt.Errorf("failed to send attachment: %w", err) + } + // Persist UUID immediately so echo detection works even if the portal + // is deleted before the APNs echo arrives. + if c.cloudStore != nil { + if err := c.cloudStore.persistMessageUUID(ctx, uuid, string(msg.Portal.ID), time.Now().UnixMilli(), true); err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Str("uuid", uuid).Msg("Failed to persist sent attachment UUID; echo may be delivered as duplicate") + } + } + + return &bridgev2.MatrixMessageResponse{ + DB: &database.Message{ + ID: makeMessageID(uuid), + SenderID: makeUserID(c.handle), + Timestamp: time.Now(), + Metadata: &MessageMetadata{HasAttachments: true}, + }, + }, nil +} + +func (c *IMClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error { + if c.client == nil { + return nil + } + conv := c.portalToConversation(msg.Portal) + if conv.IsSms { + return nil + } + return retrySendOnAPNsFlap(func() error { + return c.client.SendTyping(conv, msg.IsTyping, c.handle) + }) +} + +func (c *IMClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) error { + if c.client == nil { + return nil + } + conv := c.portalToConversation(receipt.Portal) + if conv.IsSms { + return nil + } + var forUuid *string + if receipt.ExactMessage != nil { + uuid := string(receipt.ExactMessage.ID) + // Strip attachment suffixes like _att0, _att1 — Rust expects a pure UUID + if idx := strings.Index(uuid, "_att"); idx > 0 { + uuid = uuid[:idx] + } + forUuid = &uuid + } + err := retrySendOnAPNsFlap(func() error { + return c.client.SendReadReceipt(conv, c.handle, forUuid) + }) + if err != nil { + errStr := err.Error() + // Suppress non-actionable failures: IDS lookup errors (6001) for + // urn:biz: business chat portals and edge cases between restart and SMS + // state restoration; NoValidTargets for contacts with no reachable handle. + // All other errors propagate normally so real network/auth failures surface. + if strings.Contains(errStr, "6001") || strings.Contains(errStr, "NoValidTargets") { + zerolog.Ctx(ctx).Warn().Err(err). + Str("portal_id", string(receipt.Portal.ID)). + Msg("Read receipt failed (non-actionable, suppressed)") + return nil + } + return err + } + return nil +} + +func (c *IMClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { + if c.client == nil { + return bridgev2.ErrNotLoggedIn + } + + conv := c.portalToConversation(msg.Portal) + if conv.IsSms { + return fmt.Errorf("edits are not supported for SMS conversations") + } + targetGUID := string(msg.EditTarget.ID) + + err := retrySendOnAPNsFlap(func() error { + _, sendErr := c.client.SendEdit(conv, targetGUID, 0, msg.Content.Body, c.handle) + return sendErr + }) + if err == nil { + // Work around mautrix-go bridgev2 not incrementing EditCount before saving. + msg.EditTarget.EditCount++ + } + return err +} + +func (c *IMClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { + if c.client == nil { + return bridgev2.ErrNotLoggedIn + } + + conv := c.portalToConversation(msg.Portal) + if conv.IsSms { + return fmt.Errorf("message retraction is not supported for SMS conversations") + } + + // Track outbound unsend so we can suppress the APNs echo. + c.trackOutboundUnsend(string(msg.TargetMessage.ID)) + err := retrySendOnAPNsFlap(func() error { + _, sendErr := c.client.SendUnsend(conv, string(msg.TargetMessage.ID), 0, c.handle) + return sendErr + }) + + // Soft-delete the message in local DB so it doesn't re-bridge on backfill, + // while preserving the UUID for echo detection. + if c.cloudStore != nil { + c.cloudStore.softDeleteMessageByGUID(ctx, string(msg.TargetMessage.ID)) + } + + return err +} + +func (c *IMClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { + return bridgev2.MatrixReactionPreResponse{ + SenderID: makeUserID(c.handle), + Emoji: msg.Content.RelatesTo.Key, + }, nil +} + +func (c *IMClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (*database.Reaction, error) { + if c.client == nil { + return nil, bridgev2.ErrNotLoggedIn + } + + conv := c.portalToConversation(msg.Portal) + reaction, emoji := emojiToTapbackType(msg.Content.RelatesTo.Key) + + if conv.IsSms { + // For SMS/RCS portals, send the tapback as an SMS text message via the + // iPhone relay format instead of as an iMessage tapback (Message::React). + // + // SendTapback() generates Message::React, which causes prepare_send() in + // Rust to assign a new random sender_guid when the conversation has none + // (as SMS/RCS groups always do). The iPhone relay treats any unrecognised + // sender_guid as a new iMessage group conversation rather than routing the + // tapback to the existing SMS/RCS thread — producing a phantom new thread. + // + // Sending via SendMessage() with the reaction text uses RawSmsOutgoingMessage + // format, which the iPhone routes by participants without needing a stable + // sender_guid, correctly delivering to the existing SMS/RCS thread. + // Strip _attN suffix: SMS doesn't support part-targeting, and the + // suffixed ID would fail lookups and relay routing. + targetGUID, _ := extractTapbackTarget(string(msg.TargetMessage.ID)) + reactionText := formatSMSReactionText(reaction, emoji, false) + if c.cloudStore != nil { + if origText, err := c.cloudStore.getMessageTextByGUID(ctx, targetGUID); err == nil && origText != "" { + reactionText = formatSMSReactionTextWithBody(reaction, emoji, origText, false) + } + } + var uuid string + err := retrySendOnAPNsFlap(func() error { + var sendErr error + uuid, sendErr = c.client.SendMessage(conv, reactionText, nil, c.handle, &targetGUID, nil, nil) + return sendErr + }) + if err != nil { + return nil, fmt.Errorf("failed to send SMS reaction: %w", err) + } + c.trackSmsReactionEcho(uuid) + // Return nil: SMS reactions are sent as plain text, not as structured + // tapbacks, so there is no database.Reaction to store. The remove path + // (HandleMatrixReactionRemove) reads the target from the Matrix event + // directly and does not need a stored Reaction record. + return nil, nil + } + + targetUUID, targetPart := extractTapbackTarget(string(msg.TargetMessage.ID)) + err := retrySendOnAPNsFlap(func() error { + _, sendErr := c.client.SendTapback(conv, targetUUID, targetPart, reaction, emoji, false, c.handle) + return sendErr + }) + if err != nil { + return nil, fmt.Errorf("failed to send tapback: %w", err) + } + + return &database.Reaction{ + MessageID: msg.TargetMessage.ID, + SenderID: makeUserID(c.handle), + Emoji: msg.Content.RelatesTo.Key, + Metadata: &MessageMetadata{}, + MXID: msg.Event.ID, + }, nil +} + +func (c *IMClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error { + if c.client == nil { + return bridgev2.ErrNotLoggedIn + } + + conv := c.portalToConversation(msg.Portal) + reaction, emoji := emojiToTapbackType(msg.TargetReaction.Emoji) + + if conv.IsSms { + // Same SMS routing fix as HandleMatrixReaction: use SendMessage instead + // of SendTapback to avoid the phantom new-thread creation. + // Strip _attN suffix: SMS doesn't support part-targeting, and the + // suffixed ID would fail lookups and relay routing. + targetGUID, _ := extractTapbackTarget(string(msg.TargetReaction.MessageID)) + reactionText := formatSMSReactionText(reaction, emoji, true) + if c.cloudStore != nil { + if origText, err := c.cloudStore.getMessageTextByGUID(ctx, targetGUID); err == nil && origText != "" { + reactionText = formatSMSReactionTextWithBody(reaction, emoji, origText, true) + } + } + var uuid string + err := retrySendOnAPNsFlap(func() error { + var sendErr error + uuid, sendErr = c.client.SendMessage(conv, reactionText, nil, c.handle, &targetGUID, nil, nil) + return sendErr + }) + if err != nil { + return err + } + c.trackSmsReactionEcho(uuid) + return nil + } + + targetUUID, targetPart := extractTapbackTarget(string(msg.TargetReaction.MessageID)) + return retrySendOnAPNsFlap(func() error { + _, sendErr := c.client.SendTapback(conv, targetUUID, targetPart, reaction, emoji, true, c.handle) + return sendErr + }) +} + +// HandleMatrixDeleteChat is called when the user deletes a chat in Matrix/Beeper. +// It cleans up local state (echo detection, local DB) but does NOT touch Apple: +// no MoveToRecycleBin APNs message, no CloudKit record deletion. The chat stays +// on the user's Apple devices; only the Beeper portal is removed. +func (c *IMClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2.MatrixDeleteChat) error { + if c.client == nil { + return bridgev2.ErrNotLoggedIn + } + + // Flush the APNs reorder buffer before processing the delete. + if c.msgBuffer != nil { + c.msgBuffer.flush() + } + c.flushPendingPortalMsgs() + + log := zerolog.Ctx(ctx) + portalID := string(msg.Portal.ID) + + conv := c.portalToConversation(msg.Portal) + chatGuid := c.portalToChatGUID(portalID) + + log.Info().Str("chat_guid", chatGuid).Str("portal_id", portalID).Msg("Deleting chat from Apple") + + // Track as recently deleted so stale APNs echoes don't recreate it. + c.trackDeletedChat(portalID) + + // Clean up local DB FIRST (fast) — prevents portal resurrection on restart + // even if the CloudKit calls below hang or fail. + var chatRecordNames, msgRecordNames []string + if c.cloudStore != nil { + if err := c.cloudStore.clearRestoreOverride(ctx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to clear restore override for locally deleted chat") + } + chatRecordNames, _ = c.cloudStore.getCloudRecordNamesByPortalID(ctx, portalID) + msgRecordNames, _ = c.cloudStore.getMessageRecordNamesByPortalID(ctx, portalID) + + // Fallback: if no record_names by portal_id, look up by group_id. + groupID := "" + if strings.HasPrefix(portalID, "gid:") { + groupID = strings.TrimPrefix(portalID, "gid:") + } + if len(chatRecordNames) == 0 && groupID != "" { + chatRecordNames, _ = c.cloudStore.getCloudRecordNamesByGroupID(ctx, groupID) + } + if len(msgRecordNames) == 0 && groupID != "" { + msgRecordNames, _ = c.cloudStore.getMessageRecordNamesByGroupID(ctx, groupID) + } + + // Soft-delete local records for both portal_id and group_id. + if err := c.cloudStore.deleteLocalChatByPortalID(ctx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to soft-delete local cloud records") + } else { + log.Info().Str("portal_id", portalID).Msg("Soft-deleted local cloud_chat and cloud_message records") + } + if groupID != "" { + if err := c.cloudStore.deleteLocalChatByGroupID(ctx, groupID); err != nil { + log.Warn().Err(err).Str("group_id", groupID).Msg("Failed to soft-delete local cloud records by group_id") + } + } + } + + // Delete from Apple — CloudKit backend only. chatdb users should not have + // Apple-side chats deleted when they remove a portal from Beeper. + if c.Main.Config.UseCloudKitBackfill() { + c.deleteFromApple(portalID, conv, chatGuid, chatRecordNames, msgRecordNames) + } + + return nil +} + +// portalToChatGUID constructs the iMessage chat GUID for a portal, used for +// MoveToRecycleBin messages. Tries the cloud_chat DB first (has the exact +// chat_identifier from CloudKit), then falls back to constructing it from the +// portal ID. +func (c *IMClient) portalToChatGUID(portalID string) string { + service := "iMessage" + if c.isPortalSMS(portalID) { + service = "SMS" + } + + // Try cloud store first — it has the authoritative chat_identifier. + // CloudKit stores just the identifier part (e.g. "rollingonchrome@proton.me"), + // so we need to add the service prefix to form a full chat GUID. + if c.cloudStore != nil { + if chatID := c.cloudStore.getChatIdentifierByPortalID(context.Background(), portalID); chatID != "" { + // If it already has a service prefix (e.g. "iMessage;-;..."), return as-is. + if strings.Contains(chatID, ";") { + return chatID + } + // Add the service prefix for bare identifiers. + sep := ";-;" + if strings.HasPrefix(portalID, "gid:") { + sep = ";+;" + } + return service + sep + chatID + } + } + // Fallback: construct from portal ID. + if strings.HasPrefix(portalID, "gid:") { + return service + ";+;" + strings.TrimPrefix(portalID, "gid:") + } + cleanID := strings.TrimPrefix(strings.TrimPrefix(portalID, "tel:"), "mailto:") + // Strip legacy (sms...) suffix from pre-fix portal IDs so the chat GUID + // passed to Rust is well-formed (e.g., "SMS;-;+12155167207" not "SMS;-;+12155167207(smsft)"). + cleanID = stripSmsSuffix(cleanID) + return service + ";-;" + cleanID +} + +// deleteFromApple sends MoveToRecycleBin via APNs and deletes known CloudKit +// records synchronously, then kicks off a background scan to catch any records +// not in the local DB. Local DB is already cleaned before this is called, so +// restarts are safe even if the background scan is interrupted. +func (c *IMClient) deleteFromApple(portalID string, conv rustpushgo.WrappedConversation, chatGuid string, chatRecordNames, msgRecordNames []string) { + log := c.Main.Bridge.Log.With().Str("portal_id", portalID).Str("chat_guid", chatGuid).Logger() + + // Send MoveToRecycleBin via APNs — notifies other Apple devices. + if err := c.client.SendMoveToRecycleBin(conv, c.handle, chatGuid); err != nil { + log.Warn().Err(err).Msg("Failed to send MoveToRecycleBin via APNs") + } else { + log.Info().Msg("Sent MoveToRecycleBin via APNs") + } + + // Delete known records from CloudKit synchronously (fast — no full table scan). + if len(chatRecordNames) > 0 { + if err := c.client.DeleteCloudChats(chatRecordNames); err != nil { + log.Warn().Err(err).Strs("record_names", chatRecordNames).Msg("Failed to delete chats from CloudKit") + } else { + log.Info().Strs("record_names", chatRecordNames).Msg("Deleted chat records from CloudKit") + } + } + // NOTE: We intentionally do NOT call DeleteCloudMessages here. + // DeleteCloudMessages is a hard CloudKit delete from messageManateeZone — if + // called before iPhone has a chance to process the MoveToRecycleBin APNs + // command and move records to recoverableMessageDeleteZone, the messages are + // permanently destroyed and can never be recovered. + // Instead we rely on: + // 1. SendMoveToRecycleBin (APNs) → iPhone moves messages to recycle bin in CloudKit + // 2. Local soft-delete via deleteLocalChatByPortalID prevents portal resurrection + // even while messages are still visible in messageManateeZone pending iPhone processing + + // Background: scan CloudKit chat records if we don't have record names locally. + // Only scans the chat zone (small) — NOT the message zone (can be 100k+ records). + // Message records are handled by MoveToRecycleBin moving them to Apple's trash; + // local soft-delete of cloud_message rows prevents portal resurrection. + if len(chatRecordNames) == 0 && chatGuid != "" { + go func() { + defer func() { + if r := recover(); r != nil { + log.Error().Interface("panic", r).Msg("Panic in background CloudKit chat deletion scan") + } + }() + c.findAndDeleteCloudChatByIdentifier(log, chatGuid) + }() + } + +} + +// recoverChatOnApple sends a RecoverChat APNs message (command 182) and +// re-uploads the chat record to CloudKit. This is the inverse of deleteFromApple +// and makes the chat reappear on other Apple devices after a Beeper-side restore. +func (c *IMClient) recoverChatOnApple(portalID string) { + if c.client == nil { + return + } + + log := c.Main.Bridge.Log.With().Str("portal_id", portalID).Logger() + chatGuid := c.portalToChatGUID(portalID) + + // Build a minimal conversation for the APNs recover message. + isGroup := strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") + isSms := c.isPortalSMS(portalID) + var conv rustpushgo.WrappedConversation + + if isGroup { + var participants []string + if c.cloudStore != nil { + if parts, err := c.cloudStore.getChatParticipantsByPortalID(context.Background(), portalID); err == nil && len(parts) > 0 { + participants = parts + } + } + if len(participants) == 0 && strings.Contains(portalID, ",") { + participants = strings.Split(portalID, ",") + } + var senderGuid *string + if strings.HasPrefix(portalID, "gid:") { + guid := strings.TrimPrefix(portalID, "gid:") + c.imGroupGuidsMu.RLock() + if cached := c.imGroupGuids[portalID]; cached != "" { + guid = cached + } + c.imGroupGuidsMu.RUnlock() + senderGuid = &guid + } + conv = rustpushgo.WrappedConversation{ + Participants: participants, + SenderGuid: senderGuid, + IsSms: isSms, + } + } else { + sendTo := c.resolveSendTarget(portalID) + participants := []string{c.handle, sendTo} + if c.isMyHandle(sendTo) { + participants = []string{sendTo} + } + conv = rustpushgo.WrappedConversation{ + Participants: participants, + IsSms: isSms, + } + } + + // Send RecoverChat via APNs (command 182) — notifies other Apple devices. + if err := c.client.SendRecoverChat(conv, c.handle, chatGuid); err != nil { + log.Warn().Err(err).Str("chat_guid", chatGuid).Msg("Failed to send RecoverChat via APNs") + } else { + log.Info().Str("chat_guid", chatGuid).Msg("Sent RecoverChat via APNs — chat will reappear on Apple devices") + } + + // Re-upload the chat record to CloudKit so it reappears in chatManateeZone. + if c.cloudStore != nil { + rec, err := c.cloudStore.getCloudChatRecordByPortalID(context.Background(), portalID) + if err == nil && rec != nil { + if err := c.client.RestoreCloudChat( + rec.RecordName, rec.ChatIdentifier, rec.GroupID, + rec.Style, rec.Service, rec.DisplayName, rec.Participants, + ); err != nil { + log.Warn().Err(err).Msg("Failed to restore chat record in CloudKit") + } else { + log.Info().Str("record_name", rec.RecordName).Msg("Restored chat record in CloudKit") + } + } else if err != nil { + log.Debug().Err(err).Msg("No cloud_chat record found for CloudKit restore (may be a new chat)") + } + } +} + +// findAndDeleteCloudChatByIdentifier syncs all chat records from CloudKit, +// finds ones matching the given chat_identifier (e.g. "iMessage;-;user@example.com"), +// and deletes them. Used as a fallback when local DB doesn't have record_names. +// +// Note: This uses the same CloudSyncChats API starting from nil token (full scan). +// CloudKit change tokens are client-side state — this does NOT advance the main +// sync controller's server-side watermark or cause it to miss changes. +func (c *IMClient) findAndDeleteCloudChatByIdentifier(log zerolog.Logger, chatIdentifier string) { + log.Info().Str("chat_identifier", chatIdentifier).Msg("Querying CloudKit for chat records to delete") + + var matchingRecordNames []string + var token *string + + for page := 0; page < 256; page++ { + resp, err := safeCloudSyncChats(c.client, token) + if err != nil { + log.Warn().Err(err).Msg("Failed to sync chats from CloudKit for delete lookup") + return + } + for _, chat := range resp.Chats { + if chat.CloudChatId == chatIdentifier && chat.RecordName != "" { + matchingRecordNames = append(matchingRecordNames, chat.RecordName) + } + } + prev := ptrStringOr(token, "") + token = resp.ContinuationToken + if resp.Done || (page > 0 && prev == ptrStringOr(token, "")) { + break + } + } + + if len(matchingRecordNames) == 0 { + log.Info().Str("chat_identifier", chatIdentifier).Msg("No matching chat records found in CloudKit") + } else if err := c.client.DeleteCloudChats(matchingRecordNames); err != nil { + log.Warn().Err(err).Strs("record_names", matchingRecordNames).Msg("Failed to delete chat records found via CloudKit query") + } else { + log.Info().Int("count", len(matchingRecordNames)).Str("chat_identifier", chatIdentifier).Msg("Deleted chat records found via CloudKit query") + } +} + +// ============================================================================ +// Chat & user info +// ============================================================================ + +func (c *IMClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { + portalID := string(portal.ID) + // Groups use "gid:" portal IDs, or legacy comma-separated participant IDs + isGroup := strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") + + canBackfill := false + if c.Main.Config.UseChatDBBackfill() { + canBackfill = c.chatDB != nil + } else if c.cloudStore != nil { + if hasMessages, err := c.cloudStore.hasPortalMessages(ctx, portalID); err == nil { + canBackfill = hasMessages + } + // Force backfill for portals with a restore override (from + // !restore-chat or APNs RecoverChat). Even when cloud_message is + // empty, the restore is legitimate and forward backfill should run + // to page fresh messages from CloudKit. + if !canBackfill { + if overridden, err := c.cloudStore.hasRestoreOverride(ctx, portalID); err == nil && overridden { + canBackfill = true + } + } + } + + chatInfo := &bridgev2.ChatInfo{ + CanBackfill: canBackfill, + // Suppress bridge bot timeline messages for name/member changes + // during ChatResync. Real-time renames go through handleRename + // which sends its own ChatInfoChange event (with timeline visibility). + ExcludeChangesFromTimeline: true, + } + + if isGroup { + chatInfo.Type = ptr.Ptr(database.RoomTypeDefault) + + // For gid: portals, look up members from cloud_chat table; + // for legacy comma-separated IDs, parse from the portal ID. + memberList := c.resolveGroupMembers(ctx, portalID) + + memberMap := make(map[networkid.UserID]bridgev2.ChatMember) + for _, member := range memberList { + userID := makeUserID(member) + if c.isMyHandle(member) { + memberMap[userID] = bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: userID, + }, + Membership: event.MembershipJoin, + } + } else { + memberMap[userID] = bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{Sender: userID}, + Membership: event.MembershipJoin, + } + } + } + + // CloudKit doesn't include the owner in the participant list (it's + // implied). Always ensure we're in the member map so Beeper knows + // we belong to this conversation. + myUserID := makeUserID(c.handle) + if _, hasSelf := memberMap[myUserID]; !hasSelf { + memberMap[myUserID] = bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: myUserID, + }, + Membership: event.MembershipJoin, + } + } + chatInfo.Members = &bridgev2.ChatMemberList{ + IsFull: true, + MemberMap: memberMap, + PowerLevels: &bridgev2.PowerLevelOverrides{ + Invite: ptr.Ptr(95), // Prevent Matrix users from inviting — the bridge manages membership + }, + } + + // Only set the group name for NEW portals (no Matrix room yet). + // For existing portals, skip — the name is managed by handleRename + // (explicit renames) and makePortalKey's envelope-change detection. + // Setting the name here for existing portals would revert correct + // names to stale values from CloudKit or metadata, and produce + // unwanted bridge bot "name changed" events. + if portal.MXID == "" || portal.Name == "" { + groupName, _ := c.resolveGroupName(ctx, portalID) + chatInfo.Name = &groupName + } + + // Set group photo from cache or CloudKit. + // + // Primary path: MMCS bytes previously downloaded via an APNs IconChange + // message and persisted to group_photo_cache by handleIconChange. + // + // Fallback path: when the cache is empty (e.g. fresh bridge, or first + // sync before any IconChange has arrived), attempt a CloudKit download + // using the record_name stored during chat sync. Apple's iMessage + // clients may not write the CloudKit "gp" asset field (preferring MMCS + // delivery), so this fallback often returns MissingGroupPhoto — logged + // at debug level and silently ignored. When it does succeed the bytes + // are cached for future calls. + if c.cloudStore != nil { + photoLog := c.Main.Bridge.Log.With().Str("portal_id", portalID).Logger() + photoTS, photoData, gpErr := c.cloudStore.getGroupPhoto(ctx, portalID) + if gpErr != nil { + photoLog.Warn().Err(gpErr).Msg("group_photo: DB lookup error") + } else if len(photoData) == 0 { + photoLog.Debug().Msg("group_photo: no cached photo in DB, trying CloudKit") + photoData, photoTS = c.fetchAndCacheGroupPhoto(ctx, photoLog, portalID) + } + if len(photoData) > 0 { + avatarID := networkid.AvatarID(fmt.Sprintf("icon-change:%d", photoTS)) + photoLog.Info().Int64("ts", photoTS).Int("bytes", len(photoData)).Msg("group_photo: setting avatar from cache") + cachedData := photoData + chatInfo.Avatar = &bridgev2.Avatar{ + ID: avatarID, + Get: func(ctx context.Context) ([]byte, error) { return cachedData, nil }, + } + } + } + + // Persist sender_guid to portal metadata for gid: portals. + // Only persist iMessage protocol-level group names (cv_name) to + // metadata — NOT auto-generated contact-resolved names — since + // the metadata GroupName is used for outbound message routing. + if strings.HasPrefix(portalID, "gid:") { + // Prefer original-case sender_guid from cache (populated by + // makePortalKey synchronously before GetChatInfo runs). + gid := strings.TrimPrefix(portalID, "gid:") + c.imGroupGuidsMu.RLock() + if cached := c.imGroupGuids[portalID]; cached != "" { + gid = cached + } + c.imGroupGuidsMu.RUnlock() + c.imGroupNamesMu.RLock() + protocolName := c.imGroupNames[portalID] + c.imGroupNamesMu.RUnlock() + isSms := c.isPortalSMS(portalID) + chatInfo.ExtraUpdates = func(ctx context.Context, p *bridgev2.Portal) bool { + meta, ok := p.Metadata.(*PortalMetadata) + if !ok { + meta = &PortalMetadata{} + } + changed := false + if meta.SenderGuid != gid { + meta.SenderGuid = gid + changed = true + } + if protocolName != "" && meta.GroupName != protocolName { + meta.GroupName = protocolName + changed = true + } + if meta.IsSms != isSms { + meta.IsSms = isSms + changed = true + } + if changed { + p.Metadata = meta + } + return changed + } + } + } else { + chatInfo.Type = ptr.Ptr(database.RoomTypeDM) + otherUser := makeUserID(portalID) + isSelfChat := c.isMyHandle(portalID) + + memberMap := map[networkid.UserID]bridgev2.ChatMember{ + makeUserID(c.handle): { + EventSender: bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: makeUserID(c.handle), + }, + Membership: event.MembershipJoin, + }, + } + // Only add the other user if it's not a self-chat, to avoid + // overwriting the IsFromMe entry with a duplicate map key. + if !isSelfChat { + memberMap[otherUser] = bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{Sender: otherUser}, + Membership: event.MembershipJoin, + } + } + + members := &bridgev2.ChatMemberList{ + IsFull: true, + OtherUserID: otherUser, + MemberMap: memberMap, + } + + // For self-chats, set an explicit name and avatar from contacts since + // the framework can't derive them from the ghost when the "other user" + // is the logged-in user. Setting Name causes NameIsCustom=true in the + // framework, which blocks UpdateInfoFromGhost (it returns early when + // NameIsCustom is set), so we must also set the avatar explicitly here. + if isSelfChat { + selfName := c.resolveContactDisplayname(portalID) + chatInfo.Name = &selfName + + // Pull contact photo for self-chat room avatar. + localID := stripIdentifierPrefix(portalID) + if c.contacts != nil { + if contact, _ := c.contacts.GetContactInfo(localID); contact != nil && len(contact.Avatar) > 0 { + avatarHash := sha256.Sum256(contact.Avatar) + avatarData := contact.Avatar + chatInfo.Avatar = &bridgev2.Avatar{ + ID: networkid.AvatarID(fmt.Sprintf("contact:%s:%s", portalID, hex.EncodeToString(avatarHash[:8]))), + Get: func(ctx context.Context) ([]byte, error) { + return avatarData, nil + }, + } + } + } + } + // For regular DMs, don't set an explicit room name. With + // private_chat_portal_meta, the framework derives it from the ghost's + // display name, which auto-updates when contacts are edited. + chatInfo.Members = members + + // Persist IsSms so CloudKit-created portals (no suffix, no live APNs + // message yet) survive restarts. Mirrors the group ExtraUpdates pattern. + isSms := c.isPortalSMS(portalID) + chatInfo.ExtraUpdates = func(ctx context.Context, p *bridgev2.Portal) bool { + meta, ok := p.Metadata.(*PortalMetadata) + if !ok { + meta = &PortalMetadata{} + } + if meta.IsSms != isSms { + meta.IsSms = isSms + p.Metadata = meta + return true + } + return false + } + } + + return chatInfo, nil +} + +// resolveContactDisplayname returns a contact-resolved display name for the +// given identifier (e.g. "tel:+1234567890"). Falls back to formatting the +// raw identifier if no contact is found. +func (c *IMClient) resolveContactDisplayname(identifier string) string { + localID := stripIdentifierPrefix(identifier) + if c.contacts != nil { + contact, contactErr := c.contacts.GetContactInfo(localID) + if contactErr != nil { + c.Main.Bridge.Log.Debug().Err(contactErr).Str("id", localID).Msg("Failed to resolve contact info") + } + if contact != nil && contact.HasName() { + return c.Main.Config.FormatDisplayname(DisplaynameParams{ + FirstName: contact.FirstName, + LastName: contact.LastName, + Nickname: contact.Nickname, + ID: localID, + }) + } + } + return c.Main.Config.FormatDisplayname(identifierToDisplaynameParams(identifier)) +} + +func (c *IMClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { + identifier := string(ghost.ID) + if identifier == "" { + return nil, nil + } + + isBot := false + ui := &bridgev2.UserInfo{ + IsBot: &isBot, + Identifiers: []string{identifier}, + } + + // Try contact info from cloud contacts (iCloud CardDAV) + localID := stripIdentifierPrefix(identifier) + var contact *imessage.Contact + if c.contacts != nil { + var contactErr error + contact, contactErr = c.contacts.GetContactInfo(localID) + if contactErr != nil { + zerolog.Ctx(ctx).Debug().Err(contactErr).Str("id", localID).Msg("Failed to resolve contact info") + } + } + + // User-provided contacts (CardDAV / iCloud) always win. Any match from + // c.contacts fully overrides the shared iMessage profile, regardless of + // whether the contact has a name or photo: the user adding an entry is + // itself the signal that they want their own data used. Shared profiles + // only fill the gap when the identifier is unknown to the address book. + if contact != nil { + if contact.HasName() { + name := c.Main.Config.FormatDisplayname(DisplaynameParams{ + FirstName: contact.FirstName, + LastName: contact.LastName, + Nickname: contact.Nickname, + ID: localID, + }) + ui.Name = &name + } else { + name := c.Main.Config.FormatDisplayname(identifierToDisplaynameParams(identifier)) + ui.Name = &name + } + for _, phone := range contact.Phones { + ui.Identifiers = append(ui.Identifiers, "tel:"+phone) + } + for _, email := range contact.Emails { + ui.Identifiers = append(ui.Identifiers, "mailto:"+email) + } + if len(contact.Avatar) > 0 { + avatarHash := sha256.Sum256(contact.Avatar) + avatarData := contact.Avatar // capture for closure + ui.Avatar = &bridgev2.Avatar{ + ID: networkid.AvatarID(fmt.Sprintf("contact:%s:%s", identifier, hex.EncodeToString(avatarHash[:8]))), + Get: func(ctx context.Context) ([]byte, error) { + return avatarData, nil + }, + } + } + return ui, nil + } + + // No user-provided contact — fall back to a shared iMessage profile + // (Name & Photo Sharing / Me card) if one was received and cached. + if profile := c.lookupSharedProfile(identifier); profile != nil { + if profile.FirstName != "" || profile.LastName != "" || profile.DisplayName != "" { + name := c.Main.Config.FormatDisplayname(DisplaynameParams{ + FirstName: profile.FirstName, + LastName: profile.LastName, + ID: localID, + }) + ui.Name = &name + } + if profile.Avatar != nil && len(*profile.Avatar) > 0 { + avatarData := *profile.Avatar + avatarHash := sha256.Sum256(avatarData) + ui.Avatar = &bridgev2.Avatar{ + ID: networkid.AvatarID(fmt.Sprintf("improfile:%s:%s", identifier, hex.EncodeToString(avatarHash[:8]))), + Get: func(ctx context.Context) ([]byte, error) { + return avatarData, nil + }, + } + } + if ui.Name != nil { + return ui, nil + } + } + + // Final fallback: format from identifier + name := c.Main.Config.FormatDisplayname(identifierToDisplaynameParams(identifier)) + ui.Name = &name + return ui, nil +} + +func (c *IMClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (resp *bridgev2.ResolveIdentifierResponse, err error) { + if c.client == nil { + return nil, bridgev2.ErrNotLoggedIn + } + // ValidateTargets crosses into the identity-manager FFI path, which has + // reachable panic sites upstream (identity_manager.rs:249/335/542/555). + // This is a user-triggered call (start-chat flow), so a panic here + // would crash the bridge on a normal user action. Convert to error. + defer func() { + if r := recover(); r != nil { + c.UserLogin.Log.Error().Interface("panic", r).Str("identifier", identifier). + Msg("ResolveIdentifier panicked in FFI path") + resp = nil + err = fmt.Errorf("identity lookup panicked: %v", r) + } + }() + + valid := c.client.ValidateTargets([]string{identifier}, c.handle) + if len(valid) == 0 { + return nil, fmt.Errorf("user not found on iMessage: %s", identifier) + } + + userID := makeUserID(identifier) + portalID := networkid.PortalKey{ + ID: networkid.PortalID(identifier), + Receiver: c.UserLogin.ID, + } + + ghost, err := c.Main.Bridge.GetGhostByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get ghost: %w", err) + } + portal, err := c.Main.Bridge.GetPortalByKey(ctx, portalID) + if err != nil { + return nil, fmt.Errorf("failed to get portal: %w", err) + } + ghostInfo, err := c.GetUserInfo(ctx, ghost) + if err != nil { + return nil, err + } + + return &bridgev2.ResolveIdentifierResponse{ + Ghost: ghost, + UserID: userID, + UserInfo: ghostInfo, + Chat: &bridgev2.CreateChatResponse{ + Portal: portal, + PortalKey: portalID, + }, + }, nil +} + +// ============================================================================ +// Backfill (CloudKit cache-backed) +// ============================================================================ + +// GetBackfillMaxBatchCount returns -1 (unlimited) so backward backfill +// processes all available messages in cloud_message without a batch cap. +// When the user caps max_initial_messages, FetchMessages short-circuits +// backward requests to return empty immediately. +func (c *IMClient) GetBackfillMaxBatchCount(_ context.Context, _ *bridgev2.Portal, _ *database.BackfillTask) int { + return -1 +} + +type cloudBackfillCursor struct { + TimestampMS int64 `json:"ts"` + GUID string `json:"g"` +} + +func (c *IMClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { + fetchStart := time.Now() + log := zerolog.Ctx(ctx) + + // For forward backfill calls: ensure the bootstrap pending counter is + // decremented on every return path. The normal path (with messages) sets + // forwardDone=true and uses CompleteCallback to decrement AFTER bridgev2 + // delivers the batch to Matrix. All other paths (early return, empty + // result, error) decrement here via defer — there is nothing to wait for. + var forwardDone bool + if params.Forward { + defer func() { + if !forwardDone { + c.onForwardBackfillDone() + } + }() + } + + // When the user has capped max_initial_messages, skip backward backfill + // entirely. Forward backfill already delivered the capped N messages; + // returning empty here marks the backward task as done immediately. + // Applies to both chat.db and CloudKit paths. + if !params.Forward && c.Main.Bridge.Config.Backfill.MaxInitialMessages < math.MaxInt32 { + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: false}, nil + } + + // Chat.db backfill path + if c.Main.Config.UseChatDBBackfill() && c.chatDB != nil { + return c.chatDB.FetchMessages(ctx, params, c) + } + + if !c.Main.Config.UseCloudKitBackfill() || c.cloudStore == nil { + log.Debug().Bool("forward", params.Forward).Bool("backfill_enabled", c.Main.Config.CloudKitBackfill). + Msg("FetchMessages: backfill disabled or no cloud store, returning empty") + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: params.Forward}, nil + } + + count := params.Count + if count <= 0 { + count = 50 + } + + if params.Portal == nil || params.ThreadRoot != "" { + log.Debug().Bool("forward", params.Forward).Msg("FetchMessages: nil portal or thread root, returning empty") + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: params.Forward}, nil + } + portalID := string(params.Portal.ID) + if portalID == "" { + log.Debug().Bool("forward", params.Forward).Msg("FetchMessages: empty portal ID, returning empty") + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: params.Forward}, nil + } + + // Guard: if this portal was recently deleted, only backfill if there are + // live (non-deleted) messages. Prevents backfilling old messages into + // portals recreated by a genuinely new message. + c.recentlyDeletedPortalsMu.RLock() + _, isDeletedPortal := c.recentlyDeletedPortals[portalID] + c.recentlyDeletedPortalsMu.RUnlock() + if isDeletedPortal && c.cloudStore != nil { + rows, err := c.cloudStore.listLatestMessages(context.Background(), portalID, 1) + if err == nil && len(rows) == 0 { + log.Info().Str("portal_id", portalID).Msg("FetchMessages: deleted portal with no live messages, returning empty") + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: params.Forward}, nil + } + } + + // Look up the group display name for system message filtering. + // Group rename system messages have text == display name but no attributedBody; + // this lets cloudRowToBackfillMessages filter them with an AND condition. + var groupDisplayName string + if c.cloudStore != nil { + groupDisplayName, _ = c.cloudStore.getDisplayNameByPortalID(ctx, portalID) + } + + // Forward backfill: return messages for this portal in chronological order. + // bridgev2 doForwardBackfill calls FetchMessages exactly once (no external + // pagination), so we loop internally — fetching and converting in chunks of + // forwardChunkSize to bound per-iteration memory. All messages are + // accumulated and returned in a single response. + const forwardChunkSize = 5000 + if params.Forward { + // Acquire semaphore to limit concurrent forward backfills. + // This prevents overwhelming CloudKit/Matrix with simultaneous + // attachment downloads and uploads across many portals. + // Use a select with ctx.Done() so we don't block the portal event + // loop indefinitely when all slots are taken — that causes "Portal + // event channel is still full" errors and dropped events. + select { + case c.forwardBackfillSem <- struct{}{}: + case <-ctx.Done(): + log.Warn().Str("portal_id", portalID).Msg("Forward backfill: context cancelled while waiting for semaphore") + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: true}, nil + } + defer func() { <-c.forwardBackfillSem }() + log.Info(). + Str("portal_id", portalID). + Int("count", count). + Int("chunk_size", forwardChunkSize). + Str("trigger", "portal_creation"). + Bool("has_anchor", params.AnchorMessage != nil). + Msg("Forward backfill START") + + // Determine the starting anchor for the internal pagination loop. + var cursorTS int64 + var cursorGUID string + hasCursor := false + if params.AnchorMessage != nil { + cursorTS = params.AnchorMessage.Timestamp.UnixMilli() + cursorGUID = string(params.AnchorMessage.ID) + hasCursor = true + log.Debug(). + Str("portal_id", portalID). + Int64("anchor_ts", cursorTS). + Str("anchor_guid", cursorGUID). + Msg("Forward backfill: using anchor — fetching only newer messages") + } + + var allRows []cloudMessageRow + totalRows := 0 + chunk := 0 + remaining := count + + if !hasCursor { + // No anchor: fetch the N most recent messages. listLatestMessages + // selects the newest N (crash resilience — if interrupted, the + // delivered messages are recent, not ancient). We reverse to + // chronological (ASC) order before sending so the Matrix timeline + // displays correctly. + queryStart := time.Now() + rows, queryErr := c.cloudStore.listLatestMessages(ctx, portalID, count) + if queryErr != nil { + log.Err(queryErr).Str("portal_id", portalID).Msg("Forward backfill: listLatestMessages FAILED") + return nil, queryErr + } + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + c.preUploadChunkAttachments(ctx, rows, *log) + allRows = rows + totalRows = len(rows) + chunk = 1 + log.Debug(). + Str("portal_id", portalID). + Int("rows", totalRows). + Dur("query_ms", time.Since(queryStart)). + Msg("Forward backfill: fetched most recent messages") + } else { + // Anchor path (catchup): page forward from the anchor message. + for remaining > 0 { + chunkLimit := forwardChunkSize + if chunkLimit > remaining { + chunkLimit = remaining + } + + queryStart := time.Now() + rows, queryErr := c.cloudStore.listForwardMessages(ctx, portalID, cursorTS, cursorGUID, chunkLimit) + if queryErr != nil { + log.Err(queryErr).Str("portal_id", portalID).Int("chunk", chunk).Msg("Forward backfill: query FAILED") + return nil, queryErr + } + if len(rows) == 0 { + break + } + + // Pre-upload any uncached attachments in this chunk in parallel + // before conversion. This prevents the sequential conversion loop + // from doing live CloudKit downloads (90s timeout each), which + // would hang the portal event loop for hours on large portals. + c.preUploadChunkAttachments(ctx, rows, *log) + + allRows = append(allRows, rows...) + totalRows += len(rows) + chunk++ + + log.Debug(). + Str("portal_id", portalID). + Int("chunk", chunk). + Int("chunk_rows", len(rows)). + Int("total_rows", totalRows). + Dur("chunk_query_ms", time.Since(queryStart)). + Msg("Forward backfill: chunk fetched") + + // Advance cursor to the last row in this chunk for the next iteration. + lastRow := rows[len(rows)-1] + cursorTS = lastRow.TimestampMS + cursorGUID = lastRow.GUID + hasCursor = true + remaining -= len(rows) + + // If we got fewer rows than requested, there are no more. + if len(rows) < chunkLimit { + break + } + } + } + + // Convert all rows with two-pass tapback resolution: reactions + // targeting messages in this batch go into BackfillMessage.Reactions + // (correct DAG ordering) instead of QueueRemoteEvent (end of DAG). + allMessages := c.cloudRowsToBackfillMessages(ctx, allRows, groupDisplayName) + + if len(allMessages) == 0 { + log.Debug().Str("portal_id", portalID).Msg("Forward backfill: no rows to process") + // Use context.Background() — if the bridge is shutting down, ctx + // may be cancelled but we still need to persist the flag. + c.cloudStore.markForwardBackfillDone(context.Background(), portalID) + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: true}, nil + } + + log.Info(). + Str("portal_id", portalID). + Int("db_rows", totalRows). + Int("backfill_msgs", len(allMessages)). + Int("chunks", chunk). + Dur("total_ms", time.Since(fetchStart)). + Msg("Forward backfill COMPLETE — all messages returned for portal creation") + + // Mark done so the outer defer doesn't double-decrement; the + // CompleteCallback will call onForwardBackfillDone after bridgev2 + // delivers the messages to Matrix (preserving ordering: CloudKit + // backfill is fully in Matrix before APNs buffer is flushed). + forwardDone = true + cloudStoreDone := c.cloudStore + portalKey := params.Portal.PortalKey + + // Capture latest backfilled message for Receipt 2 target. + // This ensures we mark the conversation as read up to the very + // latest message, not just the most recently read outgoing message. + lastMsg := allMessages[len(allMessages)-1] + lastMsgID := lastMsg.ID + lastMsgTS := lastMsg.Timestamp + + return &bridgev2.FetchMessagesResponse{ + Messages: allMessages, + HasMore: false, + Forward: true, + // Don't set MarkRead here — MarkReadBy on the batch send creates + // a homeserver-side read receipt with server time (≈now), which + // Beeper clients display as "Read at [now]". Instead, rely on + // the synthetic receipts below for correct timestamps. + CompleteCallback: func() { + // Compute read state BEFORE markForwardBackfillDone, which may + // insert a synthetic cloud_chat row with is_filtered=0 default + // that would defeat the "no chat row → unread" safeguard. + readByMe, readErr := cloudStoreDone.getConversationReadByMe(context.Background(), portalID) + + cloudStoreDone.markForwardBackfillDone(context.Background(), portalID) + + // NOTE: Receipt 1 (ghost "they read my message") is intentionally + // NOT sent during backfill. CloudKit's date_read_ms is unreliable: + // real iPhone users have no data (shows "Sent" correctly), but + // bridge users get wrong timestamps (shows "Seen at [wrong time]"). + // Real-time read receipts via APNs still work after backfill. + + // --- Receipt 2: Double puppet receipt ("I read their message") --- + // Marks all non-filtered CloudKit conversations as read, since + // they exist on the user's Apple devices and the user receives + // push notifications for them. Filtered chats and portals + // without cloud_chat metadata are left unread. + // Targets the latest backfilled message to mark the entire + // conversation as read, overwriting forceMarkRead's server-time + // receipt via SetBeeperInboxState with correct BeeperReadExtra["ts"]. + if readErr == nil && readByMe { + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.Receipt{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventReadReceipt, + PortalKey: portalKey, + Sender: bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: makeUserID(c.handle), + }, + Timestamp: lastMsgTS, + }, + LastTarget: lastMsgID, + ReadUpTo: lastMsgTS, + }) + log.Info(). + Str("portal_id", portalID). + Str("last_msg_id", string(lastMsgID)). + Str("last_msg_ts", lastMsgTS.UTC().Format(time.RFC3339)). + Msg("Queued double puppet read receipt (I read their message)") + } else if readErr != nil { + log.Warn().Err(readErr).Str("portal_id", portalID).Msg("Failed to check if conversation is read by me") + } + + c.onForwardBackfillDone() + }, + }, nil + } + + // Backward backfill: triggered by the mautrix bridgev2 backfill queue + // for portals with CanBackfill=true (set in GetChatInfo when cloud store + // has messages for this portal). Paginates through older messages. + cursorDesc := "none (initial page)" + fetchCount := count + 1 + beforeTS := int64(0) + beforeGUID := "" + if params.Cursor != "" { + cursor, err := decodeCloudBackfillCursor(params.Cursor) + if err != nil { + return nil, fmt.Errorf("invalid backfill cursor: %w", err) + } + beforeTS = cursor.TimestampMS + beforeGUID = cursor.GUID + cursorDesc = fmt.Sprintf("before ts=%d guid=%s", beforeTS, beforeGUID) + } else if params.AnchorMessage != nil { + beforeTS = params.AnchorMessage.Timestamp.UnixMilli() + beforeGUID = string(params.AnchorMessage.ID) + cursorDesc = fmt.Sprintf("anchor ts=%d id=%s", beforeTS, beforeGUID) + } + + if beforeTS == 0 && beforeGUID == "" { + // If forward backfill hasn't completed yet, don't permanently mark backward + // as done — the anchor will appear once sendBatch finishes. + // But if the portal has no messages at all, stop waiting — there's + // nothing for forward backfill to anchor against, and deferring + // creates an infinite retry loop. + if !c.cloudStore.isForwardBackfillDone(ctx, portalID) { + hasMessages, _ := c.cloudStore.hasPortalMessages(ctx, portalID) + if hasMessages { + log.Info().Str("portal_id", portalID). + Msg("Backward backfill: no anchor yet, forward backfill still in progress — deferring") + // Sleep before returning HasMore=true so the bridgev2 backfill + // queue doesn't tight-loop on this task and steal scheduler + // time from forward backfill (which we're waiting on). Each + // tight-loop iteration was ~1s of pure no-op — with 30+ + // deferred portals, that's 30+ CPU-seconds per second burned + // waiting for a state change that takes minutes to happen. + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(30 * time.Second): + } + return &bridgev2.FetchMessagesResponse{HasMore: true, Forward: false}, nil + } + log.Info().Str("portal_id", portalID). + Msg("Backward backfill: no anchor and no messages — stopping (nothing to backfill)") + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: false}, nil + } + // No anchor but forward backfill is done — this happens for recovered + // portals where the room already exists but has no messages (e.g. chat + // was deleted and recovered). Fetch the latest messages forward-style + // so the portal gets populated. + if c.cloudStore != nil { + if hasMessages, _ := c.cloudStore.hasPortalMessages(ctx, portalID); hasMessages { + log.Info().Str("portal_id", portalID). + Msg("Backward backfill: no anchor but portal has messages — doing recovery backfill") + rows, queryErr := c.cloudStore.listLatestMessages(ctx, portalID, count) + if queryErr != nil { + return nil, queryErr + } + if len(rows) > 0 { + // Reverse to chronological order + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + allMessages := c.cloudRowsToBackfillMessages(ctx, rows, groupDisplayName) + log.Info(). + Str("portal_id", portalID). + Int("db_rows", len(rows)). + Int("backfill_msgs", len(allMessages)). + Msg("Recovery backfill COMPLETE") + return &bridgev2.FetchMessagesResponse{ + Messages: allMessages, + HasMore: false, + Forward: false, + }, nil + } + } + } + log.Debug().Str("portal_id", portalID). + Msg("Backward backfill: no anchor or cursor, nothing to paginate from") + return &bridgev2.FetchMessagesResponse{HasMore: false, Forward: false}, nil + } + + log.Info(). + Str("portal_id", portalID). + Int("count", count). + Str("cursor", cursorDesc). + Str("trigger", "backfill_queue"). + Msg("Backward backfill START — paginating older messages") + + queryStart := time.Now() + rows, err := c.cloudStore.listBackwardMessages(ctx, portalID, beforeTS, beforeGUID, fetchCount) + if err != nil { + log.Err(err).Str("portal_id", portalID).Dur("query_ms", time.Since(queryStart)).Msg("Backward backfill: query FAILED") + return nil, err + } + queryElapsed := time.Since(queryStart) + + hasMore := false + if len(rows) > count { + hasMore = true + rows = rows[:count] + } + reverseCloudMessageRows(rows) + + convertStart := time.Now() + messages := c.cloudRowsToBackfillMessages(ctx, rows, groupDisplayName) + convertElapsed := time.Since(convertStart) + + var nextCursor networkid.PaginationCursor + if hasMore && len(rows) > 0 { + cursor, cursorErr := encodeCloudBackfillCursor(cloudBackfillCursor{ + TimestampMS: rows[0].TimestampMS, + GUID: rows[0].GUID, + }) + if cursorErr != nil { + return nil, cursorErr + } + nextCursor = cursor + } + + log.Info(). + Str("portal_id", portalID). + Int("db_rows", len(rows)). + Int("backfill_msgs", len(messages)). + Bool("has_more", hasMore). + Dur("query_ms", queryElapsed). + Dur("convert_ms", convertElapsed). + Dur("total_ms", time.Since(fetchStart)). + Msg("Backward backfill COMPLETE — older messages returned") + + return &bridgev2.FetchMessagesResponse{ + Messages: messages, + Cursor: nextCursor, + HasMore: hasMore, + Forward: false, + }, nil +} + +// cloudRowsToBackfillMessages converts a batch of CloudKit rows into backfill +// messages, attaching tapback reactions to their target messages when possible. +// This two-pass approach ensures reactions appear in BackfillMessage.Reactions +// (correct DAG ordering) instead of being queued via QueueRemoteEvent (which +// places them at the end of the DAG, making the sidebar show old reactions). +// Tapback removes and tapbacks targeting messages outside this batch still +// fall back to QueueRemoteEvent. +func (c *IMClient) cloudRowsToBackfillMessages(ctx context.Context, rows []cloudMessageRow, groupDisplayName string) []*bridgev2.BackfillMessage { + // Pass 1: convert regular messages, defer tapback rows. + var messages []*bridgev2.BackfillMessage + var tapbackRows []cloudMessageRow + messageByGUID := make(map[string]*bridgev2.BackfillMessage) + + for _, row := range rows { + if row.TapbackType != nil && *row.TapbackType >= 2000 { + tapbackRows = append(tapbackRows, row) + continue + } + converted := c.cloudRowToBackfillMessages(ctx, row, groupDisplayName) + messages = append(messages, converted...) + // Key by row GUID so tapbacks can find their target. A row may + // produce multiple BackfillMessages (text + attachments); attach + // the reaction to the first one (text, or first attachment). + if len(converted) > 0 { + messageByGUID[row.GUID] = converted[0] + } + } + + // Pass 2: resolve tapbacks — attach to target if in this batch, + // otherwise fall back to QueueRemoteEvent. + for _, row := range tapbackRows { + sender := c.makeCloudSender(row) + if sender.Sender == "" && !sender.IsFromMe { + continue + } + sender = c.canonicalizeDMSender(networkid.PortalKey{ID: networkid.PortalID(row.PortalID)}, sender) + + tapbackType := *row.TapbackType + isRemove := tapbackType >= 3000 + + // Parse target GUID and balloon-part index from "p:N/GUID" format. + targetGUID := row.TapbackTargetGUID + bp := 0 + if parts := strings.SplitN(targetGUID, "/", 2); len(parts) == 2 { + bp = parseBalloonPart(parts[0], "p:%d") + targetGUID = parts[1] + } + if targetGUID == "" { + continue + } + + // Removes can't use BackfillReaction (framework only supports add). + // Tapbacks targeting messages outside this batch also fall back. + targetMsg, inBatch := messageByGUID[targetGUID] + if !isRemove && inBatch { + ts := time.UnixMilli(row.TimestampMS) + idx := tapbackType - 2000 + emoji := tapbackTypeToEmoji(&idx, &row.TapbackEmoji) + // Map balloon-part index to bridge part ID: + // bp 0 = text body (nil TargetPart → first part), + // bp >= 1 = attachment (att0, att1, …). + var targetPart *networkid.PartID + if bp >= 1 { + p := networkid.PartID(fmt.Sprintf("att%d", bp-1)) + targetPart = &p + } + targetMsg.Reactions = append(targetMsg.Reactions, &bridgev2.BackfillReaction{ + Sender: sender, + Emoji: emoji, + Timestamp: ts, + TargetPart: targetPart, + }) + } else { + // Fall back to QueueRemoteEvent for removes and out-of-batch targets. + c.cloudTapbackToBackfill(row, sender, time.UnixMilli(row.TimestampMS)) + } + } + + return messages +} + +func (c *IMClient) cloudRowToBackfillMessages(ctx context.Context, row cloudMessageRow, groupDisplayName string) []*bridgev2.BackfillMessage { + sender := c.makeCloudSender(row) + ts := time.UnixMilli(row.TimestampMS) + + // Skip messages with no resolvable sender. These are typically iMessage + // system/notification records (group renames, participant changes, etc.) + // stored in CloudKit without a sender field. Without this check, bridgev2 + // falls back to the bridge bot as the sender, producing spurious bot + // messages in the backfilled timeline. + if sender.Sender == "" && !sender.IsFromMe { + return nil + } + sender = c.canonicalizeDMSender(networkid.PortalKey{ID: networkid.PortalID(row.PortalID)}, sender) + + // Skip system/service messages (group renames, participant changes, etc.). + // Two complementary signals: + // !HasBody with no text/attachments: genuine system rows often have no + // attributedBody, no attachments, and an empty body. Restored Apple + // messages can legitimately arrive with has_body=FALSE but still carry + // text, so don't drop those. + // text==groupDisplayName: for older rows whose has_body defaulted to + // TRUE, the rename-notification text exactly matches the group name. + // The startup DB cleanup (ensureSchema) hard-deletes matching rows so + // this filter is a second line of defence for any that slipped through. + isSystemByHasBody := !row.HasBody && strings.TrimSpace(row.Text) == "" && row.AttachmentsJSON == "" && row.TapbackType == nil + isSystemByName := row.Text != "" && groupDisplayName != "" && row.Text == groupDisplayName && row.AttachmentsJSON == "" && row.TapbackType == nil + if isSystemByHasBody || isSystemByName { + return nil + } + + // Tapback/reaction: return as a reaction event, not a text message. + if row.TapbackType != nil && *row.TapbackType >= 2000 { + return c.cloudTapbackToBackfill(row, sender, ts) + } + + var messages []*bridgev2.BackfillMessage + + // Text message — trim OBJ placeholders before building body. + body := strings.Trim(row.Text, "\ufffc \n") + var formattedBody string + if row.Subject != "" { + if body != "" { + formattedBody = fmt.Sprintf("%s
%s", html.EscapeString(row.Subject), html.EscapeString(body)) + body = fmt.Sprintf("**%s**\n%s", row.Subject, body) + } else { + body = row.Subject + } + } + hasText := strings.TrimSpace(body) != "" + if hasText { + textContent := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: body, + } + if formattedBody != "" { + textContent.Format = event.FormatHTML + textContent.FormattedBody = formattedBody + } + if detectedURL := urlRegex.FindString(row.Text); detectedURL != "" { + textContent.BeeperLinkPreviews = []*event.BeeperLinkPreview{ + fetchURLPreview(ctx, c.Main.Bridge, c.Main.Bridge.Bot, "", detectedURL), + } + } + messages = append(messages, &bridgev2.BackfillMessage{ + Sender: sender, + ID: makeMessageID(row.GUID), + Timestamp: ts, + ConvertedMessage: &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: textContent, + }}, + }, + }) + } + + // Attachments: downloadAndUploadAttachment checks attachmentContentCache first, + // so this is a cheap cache lookup when preUploadCloudAttachments has run. + attMessages := c.cloudAttachmentsToBackfill(ctx, row, sender, ts, hasText) + messages = append(messages, attMessages...) + + // Attachment-only message where all downloads failed: produce a notice + // placeholder so the message isn't silently dropped. Without this, the + // message never enters the Matrix `message` table but stays in + // cloud_message, so it's permanently lost — retries hit the same failure + // and the user never knows a message existed. + if len(messages) == 0 && row.AttachmentsJSON != "" { + messages = append(messages, &bridgev2.BackfillMessage{ + Sender: sender, + ID: makeMessageID(row.GUID), + Timestamp: ts, + ConvertedMessage: &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: "Attachment could not be downloaded from iCloud.", + }, + }}, + }, + }) + } + + return messages +} + +func (c *IMClient) makeCloudSender(row cloudMessageRow) bridgev2.EventSender { + if row.IsFromMe { + return bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: makeUserID(c.handle), + } + } + normalizedSender := normalizeIdentifierForPortalID(row.Sender) + if normalizedSender == "" { + portalID := strings.TrimSpace(row.PortalID) + if portalID != "" && !strings.HasPrefix(portalID, "gid:") && !strings.Contains(portalID, ",") { + normalizedSender = portalID + } + } + if normalizedSender == "" { + normalizedSender = row.Sender + } + return bridgev2.EventSender{Sender: makeUserID(normalizedSender)} +} + +// cloudTapbackToBackfill converts a CloudKit reaction record to a backfill reaction event. +func (c *IMClient) cloudTapbackToBackfill(row cloudMessageRow, sender bridgev2.EventSender, ts time.Time) []*bridgev2.BackfillMessage { + tapbackType := *row.TapbackType + isRemove := tapbackType >= 3000 + idx := tapbackType - 2000 + if isRemove { + idx = tapbackType - 3000 + } + emoji := tapbackTypeToEmoji(&idx, &row.TapbackEmoji) + + // Parse target GUID from "p:N/GUID" format, preserving the part index. + targetGUID := row.TapbackTargetGUID + bp := 0 + if parts := strings.SplitN(targetGUID, "/", 2); len(parts) == 2 { + bp = parseBalloonPart(parts[0], "p:%d") + targetGUID = parts[1] + } + if targetGUID == "" { + return nil + } + targetMsgID := c.resolveTapbackTargetID(targetGUID, bp) + + evtType := bridgev2.RemoteEventReaction + if isRemove { + evtType = bridgev2.RemoteEventReactionRemove + } + + // Pre-filter: skip reactions already in the bridge DB to avoid flooding + // the portal event channel with no-op duplicates on every restart. + // CloudKit re-imports all reactions on bootstrap, but the bridge framework's + // dedup (handleRemoteReaction) processes them sequentially through the + // portal event channel — 2000+ duplicate reactions can block real messages + // for minutes. Checking the reaction table here is a cheap PK lookup that + // prevents the queue from filling with known duplicates. + if !isRemove { + existing, err := c.Main.Bridge.DB.Reaction.GetByIDWithoutMessagePart( + context.Background(), c.UserLogin.ID, targetMsgID, sender.Sender, "", + ) + if err == nil && existing != nil { + return nil + } + } + + // Reactions are sent as remote events, not backfill messages. + // We queue them so the bridge handles dedup and target resolution. + portalKey := networkid.PortalKey{ + ID: networkid.PortalID(row.PortalID), + Receiver: c.UserLogin.ID, + } + c.UserLogin.QueueRemoteEvent(&simplevent.Reaction{ + EventMeta: simplevent.EventMeta{ + Type: evtType, + PortalKey: portalKey, + Sender: sender, + Timestamp: ts, + }, + TargetMessage: targetMsgID, + Emoji: emoji, + }) + return nil +} + +// isPluginPayloadAttachment reports whether the row is a rich-link plugin +// payload sideband (the binary plist Apple stores alongside a URL-bubble +// message). Filename-based because that's the only signal CloudKit reliably +// preserves; the plist always uses the .pluginPayloadAttachment extension. +func isPluginPayloadAttachment(att cloudAttachmentRow) bool { + return strings.HasSuffix(att.Filename, ".pluginPayloadAttachment") +} + +// cloudAttachmentResult holds the result of a concurrent attachment download+upload. +type cloudAttachmentResult struct { + Index int + Message *bridgev2.BackfillMessage +} + +// cloudAttachmentsToBackfill downloads CloudKit attachments, uploads them to +// the Matrix media repo, and returns backfill messages with media URLs set. +// Downloads and uploads run concurrently (up to 4 at a time) for speed. +func (c *IMClient) cloudAttachmentsToBackfill(ctx context.Context, row cloudMessageRow, sender bridgev2.EventSender, ts time.Time, hasText bool) []*bridgev2.BackfillMessage { + if row.AttachmentsJSON == "" { + return nil + } + var atts []cloudAttachmentRow + if err := json.Unmarshal([]byte(row.AttachmentsJSON), &atts); err != nil { + c.Main.Bridge.Log.Warn().Err(err).Str("guid", row.GUID). + Msg("Failed to unmarshal attachment JSON, skipping attachments for this message") + return nil + } + + // Filter to downloadable attachments. + type indexedAtt struct { + index int + att cloudAttachmentRow + } + var downloadable []indexedAtt + for i, att := range atts { + if att.RecordName == "" { + continue + } + // Skip rich-link plugin payload sidebands. The URL itself is in the + // message text and the preview card renders from it; bridging the + // plist as a file is noise. We deliberately do NOT filter on + // HideAttachment broadly because Live Photo MOV companions also + // carry that flag and we intentionally bridge them. + if isPluginPayloadAttachment(att) { + continue + } + downloadable = append(downloadable, indexedAtt{index: i, att: att}) + } + + // Live Photo handling: when HasAvid is true on an attachment, the download + // step will fetch both the lqa (HEIC still) and avid (video) from the same + // CloudKit record and return two BackfillMessages. + if len(downloadable) == 0 { + return nil + } + // For a single attachment, skip goroutine overhead. + if len(downloadable) == 1 { + return c.downloadAndUploadAttachment(ctx, row, sender, ts, hasText, downloadable[0].index, downloadable[0].att) + } + + // Concurrent download+upload with bounded parallelism. + const maxParallel = 4 + sem := make(chan struct{}, maxParallel) + results := make(chan cloudAttachmentResult, len(downloadable)) + var wg sync.WaitGroup + + for _, da := range downloadable { + wg.Add(1) + go func(idx int, att cloudAttachmentRow) { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + log.Error().Any("panic", r).Str("stack", string(debug.Stack())).Msg("Recovered panic in attachment download goroutine") + } + }() + sem <- struct{}{} + defer func() { <-sem }() + msgs := c.downloadAndUploadAttachment(ctx, row, sender, ts, hasText, idx, att) + for _, m := range msgs { + results <- cloudAttachmentResult{Index: idx, Message: m} + } + }(da.index, da.att) + } + + go func() { + wg.Wait() + close(results) + }() + + // Collect results and sort by original index for deterministic ordering. + var collected []cloudAttachmentResult + for r := range results { + collected = append(collected, r) + } + sort.Slice(collected, func(i, j int) bool { + return collected[i].Index < collected[j].Index + }) + + messages := make([]*bridgev2.BackfillMessage, 0, len(collected)) + for _, r := range collected { + messages = append(messages, r.Message) + } + return messages +} + +// safeCloudDownloadAttachment wraps the FFI call with panic recovery and a +// 90-second timeout. When Rust stalls on a network hang or an unrecognised +// attachment format the goroutine would otherwise block forever; the timeout +// frees the semaphore slot so other downloads can proceed. The inner goroutine +// is leaked until Rust eventually unblocks, but that is bounded to at most 32 +// goroutines and is temporary. +func safeCloudDownloadAttachment(client *rustpushgo.Client, recordName string) ([]byte, error) { + type dlResult struct { + data []byte + err error + } + ch := make(chan dlResult, 1) + go func() { + var res dlResult + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + log.Error().Str("ffi_method", "CloudDownloadAttachment"). + Str("record_name", recordName). + Str("stack", stack). + Msgf("FFI panic recovered: %v", r) + res = dlResult{err: fmt.Errorf("FFI panic in CloudDownloadAttachment: %v", r)} + } + ch <- res + }() + d, e := client.CloudDownloadAttachment(recordName) + res = dlResult{data: d, err: e} + }() + select { + case res := <-ch: + return res.data, res.err + case <-time.After(90 * time.Second): + log.Error().Str("ffi_method", "CloudDownloadAttachment"). + Str("record_name", recordName). + Msg("CloudDownloadAttachment timed out after 90s — inner goroutine leaked until FFI unblocks") + return nil, fmt.Errorf("CloudDownloadAttachment timed out after 90s") + } +} + +// safeCloudDownloadAttachmentAvid wraps the avid FFI call with the same +// panic recovery and timeout as safeCloudDownloadAttachment. +func safeCloudDownloadAttachmentAvid(client *rustpushgo.Client, recordName string) ([]byte, error) { + type dlResult struct { + data []byte + err error + } + ch := make(chan dlResult, 1) + go func() { + var res dlResult + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + log.Error().Str("ffi_method", "CloudDownloadAttachmentAvid"). + Str("record_name", recordName). + Str("stack", stack). + Msgf("FFI panic recovered: %v", r) + res = dlResult{err: fmt.Errorf("FFI panic in CloudDownloadAttachmentAvid: %v", r)} + } + ch <- res + }() + d, e := client.CloudDownloadAttachmentAvid(recordName) + res = dlResult{data: d, err: e} + }() + select { + case res := <-ch: + return res.data, res.err + case <-time.After(90 * time.Second): + log.Error().Str("ffi_method", "CloudDownloadAttachmentAvid"). + Str("record_name", recordName). + Msg("CloudDownloadAttachmentAvid timed out after 90s") + return nil, fmt.Errorf("CloudDownloadAttachmentAvid timed out after 90s") + } +} + +// downloadAndUploadAttachment handles a single attachment: download from CloudKit, +// upload to Matrix, return as a backfill message. +func (c *IMClient) downloadAndUploadAttachment( + ctx context.Context, + row cloudMessageRow, + sender bridgev2.EventSender, + ts time.Time, + hasText bool, + i int, + att cloudAttachmentRow, +) []*bridgev2.BackfillMessage { + log := c.Main.Bridge.Log.With().Str("component", "cloud_backfill").Logger() + intent := c.Main.Bridge.Bot + + attID := row.GUID + if i > 0 || hasText { + attID = fmt.Sprintf("%s_att%d", row.GUID, i) + } + + // Cache hit: preUploadCloudAttachments already downloaded and uploaded this + // attachment in the cloud sync goroutine. Return immediately without touching + // CloudKit, keeping the portal event loop unblocked. + // NOTE: Skip cache for non-MP4 videos that need transcoding, and for + // Live Photo HasAvid records (old cache entries only have one part, not + // both still+video). Regular videos with HasAvid use the cache normally + // since we only bridge the lqa (the video itself), not the avid duplicate. + isLivePhoto := att.HasAvid && !strings.HasPrefix(att.MimeType, "video/") + if cached, ok := c.attachmentContentCache.Load(att.RecordName); ok && !isLivePhoto { + cachedContent := cached.(*event.MessageEventContent) + if cachedContent.Info != nil && cachedContent.Info.MimeType == "video/quicktime" { + // Stale cache entry — video needs transcoding. Fall through to re-download. + c.attachmentContentCache.Delete(att.RecordName) + } else { + return []*bridgev2.BackfillMessage{{ + Sender: sender, + ID: makeMessageID(attID), + Timestamp: ts, + ConvertedMessage: &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: cachedContent, + }}, + }, + }} + } + } + + // Download the lqa (still image) — this is always the baseline. + data, err := safeCloudDownloadAttachment(c.client, att.RecordName) + if err != nil { + fe := c.recordAttachmentFailure(att.RecordName, err.Error()) + log.Warn().Err(err). + Str("guid", row.GUID). + Str("att_guid", att.GUID). + Str("record_name", att.RecordName). + Int("attempt", fe.retries). + Msg("Failed to download CloudKit attachment, skipping") + return nil + } + if len(data) == 0 { + fe := c.recordAttachmentFailure(att.RecordName, "empty data") + log.Debug().Str("guid", row.GUID).Str("record_name", att.RecordName). + Int("attempt", fe.retries). + Msg("CloudKit attachment returned empty data") + return nil + } + + mimeType := att.MimeType + fileName := att.Filename + if mimeType == "" { + mimeType = utiToMIME(att.UTIType) + } + if mimeType == "" { + mimeType = "application/octet-stream" + } + if fileName == "" { + fileName = "attachment" + } + + // Convert CAF Opus voice messages to OGG Opus for Matrix clients + var durationMs int + if att.UTIType == "com.apple.coreaudio-format" || mimeType == "audio/x-caf" { + data, mimeType, fileName, durationMs = convertAudioForMatrix(data, mimeType, fileName) + } + + // Remux/transcode non-MP4 videos to MP4 for broad Matrix client compatibility. + if c.Main.Config.VideoTranscoding && ffmpeg.Supported() && strings.HasPrefix(mimeType, "video/") && mimeType != "video/mp4" { + origMime := mimeType + origSize := len(data) + method := "remux" + converted, convertErr := ffmpeg.ConvertBytes(ctx, data, ".mp4", nil, + []string{"-c", "copy", "-movflags", "+faststart"}, + mimeType) + if convertErr != nil { + // Remux failed — try full re-encode + method = "re-encode" + converted, convertErr = ffmpeg.ConvertBytes(ctx, data, ".mp4", nil, + []string{"-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-movflags", "+faststart"}, + mimeType) + } + if convertErr != nil { + log.Warn().Err(convertErr).Str("guid", row.GUID).Str("original_mime", origMime). + Msg("FFmpeg video conversion failed, uploading original") + } else { + log.Info().Str("guid", row.GUID).Str("original_mime", origMime). + Str("method", method).Int("original_bytes", origSize).Int("converted_bytes", len(converted)). + Msg("Video transcoded to MP4") + data = converted + mimeType = "video/mp4" + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".mp4" + } + } + + // Convert HEIC/HEIF images to JPEG since most Matrix clients can't display HEIC. + var heicImg image.Image + data, mimeType, fileName, heicImg = maybeConvertHEIC(&log, data, mimeType, fileName, c.Main.Config.HEICJPEGQuality, c.Main.Config.HEICConversion) + + // Convert non-JPEG images to JPEG and extract dimensions/thumbnail + var imgWidth, imgHeight int + var thumbData []byte + var thumbW, thumbH int + if heicImg != nil { + // Use the already-decoded image from HEIC conversion + b := heicImg.Bounds() + imgWidth, imgHeight = b.Dx(), b.Dy() + if imgWidth > 800 || imgHeight > 800 { + thumbData, thumbW, thumbH = scaleAndEncodeThumb(heicImg, imgWidth, imgHeight) + } + } else if strings.HasPrefix(mimeType, "image/") || looksLikeImage(data) { + if mimeType == "image/gif" { + if cfg, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + imgWidth, imgHeight = cfg.Width, cfg.Height + } + } else if img, fmtName, _ := decodeImageData(data); img != nil { + b := img.Bounds() + imgWidth, imgHeight = b.Dx(), b.Dy() + // Re-encode TIFF as JPEG for compatibility (PNG is fine as-is) + if fmtName == "tiff" { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err == nil { + data = buf.Bytes() + mimeType = "image/jpeg" + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".jpg" + } + } + if imgWidth > 800 || imgHeight > 800 { + thumbData, thumbW, thumbH = scaleAndEncodeThumb(img, imgWidth, imgHeight) + } + } + } + + msgType := mimeToMsgType(mimeType) + content := &event.MessageEventContent{ + MsgType: msgType, + Body: fileName, + Info: &event.FileInfo{ + MimeType: mimeType, + Size: len(data), + Width: imgWidth, + Height: imgHeight, + }, + } + + // Mark as voice message if this was a CAF voice recording + if durationMs > 0 { + content.MSC3245Voice = &event.MSC3245Voice{} + content.MSC1767Audio = &event.MSC1767Audio{ + Duration: durationMs, + } + } + + url, encFile, uploadErr := intent.UploadMedia(ctx, "", data, fileName, mimeType) + if uploadErr != nil { + fe := c.recordAttachmentFailure(att.RecordName, uploadErr.Error()) + log.Warn().Err(uploadErr). + Str("guid", row.GUID). + Str("att_guid", att.GUID). + Int("attempt", fe.retries). + Msg("Failed to upload attachment to Matrix, skipping") + return nil + } + if encFile != nil { + content.File = encFile + } else { + content.URL = url + } + + if thumbData != nil { + thumbURL, thumbEnc, thumbErr := intent.UploadMedia(ctx, "", thumbData, "thumbnail.jpg", "image/jpeg") + if thumbErr != nil { + log.Warn().Err(thumbErr).Str("record_name", att.RecordName). + Msg("Failed to upload attachment thumbnail") + } else { + if thumbEnc != nil { + content.Info.ThumbnailFile = thumbEnc + } else { + content.Info.ThumbnailURL = thumbURL + } + content.Info.ThumbnailInfo = &event.FileInfo{ + MimeType: "image/jpeg", + Size: len(thumbData), + Width: thumbW, + Height: thumbH, + } + } + } + + // Populate the in-memory cache so any future backfill call for the same + // attachment returns instantly without re-downloading from CloudKit. + c.attachmentContentCache.Store(att.RecordName, content) + // Clear any prior failure tracking — this attachment succeeded. + c.failedAttachments.Delete(att.RecordName) + // Persist the mxc URI to SQLite so the cache survives bridge restarts. + // Future pre-upload passes load this at startup and skip re-downloading. + if c.cloudStore != nil { + if jsonBytes, err := json.Marshal(content); err == nil { + c.cloudStore.saveAttachmentCacheEntry(ctx, att.RecordName, jsonBytes) + } + } + + parts := make([]*bridgev2.ConvertedMessagePart, 0, 2) + if isVCardAttachment(mimeType, fileName, att.UTIType) { + if vcardPreview := makeVCardPreviewContent(data); vcardPreview != nil { + parts = append(parts, &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: vcardPreview, + }) + } + } + parts = append(parts, &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: content, + }) + + messages := []*bridgev2.BackfillMessage{{ + Sender: sender, + ID: makeMessageID(attID), + Timestamp: ts, + ConvertedMessage: &bridgev2.ConvertedMessage{ + Parts: parts, + }, + }} + + // Live Photo: if this attachment has an avid (video) asset, also download + // and bridge the video so recipients see both the still and the motion. + // Skip when the lqa itself is already a video — that means this is a regular + // video attachment (not a Live Photo), and CloudKit stores the same video in + // both lqa and avid fields. Bridging both would produce duplicates. + if att.HasAvid && !strings.HasPrefix(mimeType, "video/") { + avidData, avidErr := safeCloudDownloadAttachmentAvid(c.client, att.RecordName) + if avidErr != nil || len(avidData) == 0 { + log.Warn().Err(avidErr).Str("guid", row.GUID).Str("record_name", att.RecordName). + Msg("Live Photo avid download failed, bridging still only") + return messages + } + avidMime := "video/quicktime" + avidFileName := "livephoto.MOV" + if fileName != "" { + base := filenameBase(fileName) + if base != "" { + avidFileName = base + ".MOV" + } + } + + // Remux/transcode the avid video if enabled. + if c.Main.Config.VideoTranscoding && ffmpeg.Supported() { + origSize := len(avidData) + method := "remux" + converted, convertErr := ffmpeg.ConvertBytes(ctx, avidData, ".mp4", nil, + []string{"-c", "copy", "-movflags", "+faststart"}, + avidMime) + if convertErr != nil { + method = "re-encode" + converted, convertErr = ffmpeg.ConvertBytes(ctx, avidData, ".mp4", nil, + []string{"-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-movflags", "+faststart"}, + avidMime) + } + if convertErr != nil { + log.Warn().Err(convertErr).Str("guid", row.GUID). + Msg("Live Photo avid ffmpeg conversion failed, uploading original") + } else { + log.Info().Str("guid", row.GUID). + Str("method", method).Int("original_bytes", origSize).Int("converted_bytes", len(converted)). + Msg("Live Photo avid transcoded to MP4") + avidData = converted + avidMime = "video/mp4" + avidFileName = strings.TrimSuffix(avidFileName, filepath.Ext(avidFileName)) + ".mp4" + } + } + + avidMsgType := mimeToMsgType(avidMime) + avidContent := &event.MessageEventContent{ + MsgType: avidMsgType, + Body: avidFileName, + Info: &event.FileInfo{ + MimeType: avidMime, + Size: len(avidData), + }, + } + avidURL, avidEnc, avidUploadErr := intent.UploadMedia(ctx, "", avidData, avidFileName, avidMime) + if avidUploadErr != nil { + log.Warn().Err(avidUploadErr).Str("guid", row.GUID). + Msg("Live Photo avid upload failed, bridging still only") + return messages + } + if avidEnc != nil { + avidContent.File = avidEnc + } else { + avidContent.URL = avidURL + } + + log.Info().Str("guid", row.GUID).Str("record_name", att.RecordName). + Int("bytes", len(avidData)). + Msg("Live Photo: bridging both HEIC still and avid video") + + messages = append(messages, &bridgev2.BackfillMessage{ + Sender: sender, + ID: makeMessageID(attID + "_avid"), + Timestamp: ts, + ConvertedMessage: &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: avidContent, + }}, + }, + }) + } + + return messages +} + +// preUploadCloudAttachments downloads every CloudKit attachment recorded in the +// cloud message store and uploads it to Matrix, caching the resulting +// *event.MessageEventContent in attachmentContentCache keyed by record_name. +// +// Call this in the cloud sync goroutine BEFORE createPortalsFromCloudSync. +// When bridgev2 subsequently calls FetchMessages inside the portal event loop, +// every downloadAndUploadAttachment invocation becomes an instant cache lookup +// instead of a multi-second CloudKit download — eliminating the 30+ minute +// portal event loop stall caused by image-heavy conversations (e.g. 486 pics). +func (c *IMClient) preUploadCloudAttachments(ctx context.Context) { + if c.cloudStore == nil { + return + } + // When the user has capped max_initial_messages, skip the bulk startup + // pre-upload. The per-chunk preUploadChunkAttachments in FetchMessages + // already handles attachments for the rows actually being backfilled. + // The startup pre-upload is an optimization for the unlimited case where + // thousands of attachments need warming before portal creation. + if c.Main.Bridge.Config.Backfill.MaxInitialMessages < math.MaxInt32 { + return + } + log := c.Main.Bridge.Log.With().Str("component", "cloud_preupload").Logger() + + rows, err := c.cloudStore.listAllAttachmentMessages(ctx) + if err != nil { + log.Warn().Err(err).Msg("Pre-upload: failed to list attachment messages, skipping") + return + } + + // Load previously persisted mxc URIs into the in-memory cache. + // This means attachments uploaded in any prior run are instant cache hits + // in the pending-list filter below — zero CloudKit downloads needed. + if cachedJSON, err := c.cloudStore.loadAttachmentCacheJSON(ctx); err != nil { + log.Warn().Err(err).Msg("Pre-upload: failed to load persistent attachment cache, will re-download") + } else { + loaded := 0 + for recordName, jsonBytes := range cachedJSON { + var content event.MessageEventContent + if err := json.Unmarshal(jsonBytes, &content); err != nil { + log.Debug().Err(err).Str("record_name", recordName). + Msg("Pre-upload: skipping corrupted attachment cache entry") + } else { + // Skip stale cache entries for non-MP4 videos that need transcoding + if content.Info != nil && content.Info.MimeType == "video/quicktime" { + continue + } + c.attachmentContentCache.Store(recordName, &content) + loaded++ + } + } + if loaded > 0 { + log.Debug().Int("loaded", loaded).Msg("Pre-upload: restored attachment cache from SQLite") + } + } + + // Build the set of portal IDs whose forward FetchMessages has already + // completed successfully. These portals don't need pre-upload on restart: + // - Normal restart: all portals are done → skip everything (no re-upload storm) + // - Interrupted backfill: that portal is NOT in the done set → still pre-uploads + // Using fwd_backfill_done (set by FetchMessages via CompleteCallback) rather than + // MXID-existence avoids the interrupted-backfill edge case. + donePortals, err := c.cloudStore.getForwardBackfillDonePortals(ctx) + if err != nil { + log.Warn().Err(err).Msg("Pre-upload: failed to query fwd_backfill_done, skipping pre-upload entirely") + return + } + + // Build the list of attachments that still need to be uploaded. + type pendingUpload struct { + row cloudMessageRow + idx int + att cloudAttachmentRow + sender bridgev2.EventSender + ts time.Time + hasText bool + } + var pending []pendingUpload + for _, row := range rows { + portalDone := donePortals[row.PortalID] + var atts []cloudAttachmentRow + if err := json.Unmarshal([]byte(row.AttachmentsJSON), &atts); err != nil { + log.Warn().Err(err).Str("guid", row.GUID). + Msg("Pre-upload: failed to unmarshal attachment JSON, skipping message") + continue + } + sender := c.makeCloudSender(row) + ts := time.UnixMilli(row.TimestampMS) + hasText := strings.TrimSpace(strings.Trim(row.Text, "\ufffc \n")) != "" + for i, att := range atts { + if att.RecordName == "" { + continue + } + // Mirror the filter in cloudAttachmentsToBackfill — don't waste a + // CloudKit download on rich-link plugin payloads we'll never bridge. + if isPluginPayloadAttachment(att) { + continue + } + if _, ok := c.attachmentContentCache.Load(att.RecordName); ok { + continue // already cached from a previous pass + } + // Check if this attachment has failed before and whether + // it has exceeded the retry limit. + prev, isFailed := c.failedAttachments.Load(att.RecordName) + if isFailed { + fe := prev.(*failedAttachmentEntry) + if fe.retries >= maxAttachmentRetries { + log.Warn(). + Str("record_name", att.RecordName). + Str("portal_id", row.PortalID). + Str("last_error", fe.lastError). + Int("retries", fe.retries). + Msg("Pre-upload: abandoning attachment after max retries") + c.failedAttachments.Delete(att.RecordName) + continue + } + } + // For done portals, only retry attachments that previously + // failed (transient). New uncached attachments in done portals + // were already handled by FetchMessages — no re-upload needed. + if portalDone { + if !isFailed { + continue + } + fe := prev.(*failedAttachmentEntry) + log.Info(). + Str("record_name", att.RecordName). + Str("portal_id", row.PortalID). + Int("attempt", fe.retries+1). + Msg("Pre-upload: retrying previously failed attachment for completed portal") + } + pending = append(pending, pendingUpload{ + row: row, idx: i, att: att, + sender: sender, ts: ts, hasText: hasText, + }) + } + } + + if len(pending) == 0 { + log.Debug().Int("message_rows", len(rows)).Msg("Pre-upload: all attachments already cached") + return + } + + log.Info(). + Int("attachments", len(pending)). + Int("message_rows", len(rows)). + Msg("Pre-upload: starting CloudKit→Matrix attachment pre-upload before portal creation") + + start := time.Now() + const maxParallel = 32 + sem := make(chan struct{}, maxParallel) + var wg sync.WaitGroup + var uploaded atomic.Int64 + + for _, p := range pending { + wg.Add(1) + go func(p pendingUpload) { + defer wg.Done() + // Check context before acquiring semaphore so shutdown doesn't + // queue up behind 32 in-flight downloads (each up to 90s). + select { + case sem <- struct{}{}: + case <-ctx.Done(): + return + } + defer func() { <-sem }() + defer func() { + if r := recover(); r != nil { + log.Error().Any("panic", r). + Str("record_name", p.att.RecordName). + Msg("Pre-upload: recovered panic in attachment goroutine") + } + }() + // downloadAndUploadAttachment stores the result in attachmentContentCache + // as a side effect; we discard the returned BackfillMessage here. + result := c.downloadAndUploadAttachment(ctx, p.row, p.sender, p.ts, p.hasText, p.idx, p.att) + if result != nil { + uploaded.Add(1) + } + }(p) + } + + // Wait for all uploads to finish. No overall timeout here: the 90s + // per-download cap in safeCloudDownloadAttachment already bounds the + // worst case, and the persistent SQLite cache means this only runs for + // genuinely new/uncached attachments — so it completes fully every time + // and FetchMessages always gets 100% cache hits. + wg.Wait() + failed := int64(len(pending)) - uploaded.Load() + log.Info(). + Int64("uploaded", uploaded.Load()). + Int64("failed", failed). + Int("total", len(pending)). + Dur("elapsed", time.Since(start)). + Msg("Pre-upload: CloudKit→Matrix attachment pre-upload complete") +} + +// preUploadChunkAttachments downloads and uploads any uncached attachments in +// the given rows in parallel (up to 32 concurrent). Called inline during +// forward backfill before the sequential conversion loop, so that +// downloadAndUploadAttachment gets instant cache hits instead of doing +// sequential 90s CloudKit downloads that hang the portal event loop. +func (c *IMClient) preUploadChunkAttachments(ctx context.Context, rows []cloudMessageRow, log zerolog.Logger) { + type pendingAtt struct { + row cloudMessageRow + idx int + att cloudAttachmentRow + sender bridgev2.EventSender + ts time.Time + hasText bool + } + var pending []pendingAtt + for _, row := range rows { + if row.AttachmentsJSON == "" { + continue + } + var atts []cloudAttachmentRow + if err := json.Unmarshal([]byte(row.AttachmentsJSON), &atts); err != nil { + log.Warn().Err(err).Str("guid", row.GUID). + Msg("Forward backfill: failed to unmarshal attachment JSON, skipping message") + continue + } + sender := c.makeCloudSender(row) + ts := time.UnixMilli(row.TimestampMS) + hasText := strings.TrimSpace(strings.Trim(row.Text, "\ufffc \n")) != "" + for i, att := range atts { + if att.RecordName == "" { + continue + } + if _, ok := c.attachmentContentCache.Load(att.RecordName); ok { + continue + } + pending = append(pending, pendingAtt{ + row: row, idx: i, att: att, + sender: sender, ts: ts, hasText: hasText, + }) + } + } + if len(pending) == 0 { + return + } + log.Info().Int("uncached", len(pending)).Msg("Forward backfill: pre-uploading uncached attachments in parallel") + const maxParallel = 32 + sem := make(chan struct{}, maxParallel) + var wg sync.WaitGroup + for _, p := range pending { + wg.Add(1) + go func(p pendingAtt) { + defer wg.Done() + select { + case sem <- struct{}{}: + case <-ctx.Done(): + return + } + defer func() { <-sem }() + defer func() { + if r := recover(); r != nil { + log.Error().Any("panic", r). + Str("record_name", p.att.RecordName). + Msg("Forward backfill pre-upload: recovered panic") + } + }() + c.downloadAndUploadAttachment(ctx, p.row, p.sender, p.ts, p.hasText, p.idx, p.att) + }(p) + } + wg.Wait() + log.Info().Int("processed", len(pending)).Msg("Forward backfill: pre-upload complete") +} + +func reverseCloudMessageRows(rows []cloudMessageRow) { + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } +} + +func encodeCloudBackfillCursor(cursor cloudBackfillCursor) (networkid.PaginationCursor, error) { + data, err := json.Marshal(cursor) + if err != nil { + return "", err + } + encoded := base64.RawURLEncoding.EncodeToString(data) + return networkid.PaginationCursor(encoded), nil +} + +func decodeCloudBackfillCursor(cursor networkid.PaginationCursor) (*cloudBackfillCursor, error) { + decoded, err := base64.RawURLEncoding.DecodeString(string(cursor)) + if err != nil { + return nil, err + } + var parsed cloudBackfillCursor + if err = json.Unmarshal(decoded, &parsed); err != nil { + return nil, err + } + if parsed.GUID == "" { + return nil, fmt.Errorf("empty guid in cursor") + } + return &parsed, nil +} + +// ============================================================================ +// State persistence +// ============================================================================ + +func (c *IMClient) persistState(log zerolog.Logger) { + // Guard against panics crossing the FFI boundary from any of the four + // rustpushgo calls below. A panic here would otherwise kill the bridge + // on a non-essential periodic persist; skipping one cycle is strictly + // safer than crashing the process. + defer func() { + if r := recover(); r != nil { + log.Warn().Interface("panic", r).Msg("persistState panicked — skipped this cycle") + } + }() + meta := c.UserLogin.Metadata.(*UserLoginMetadata) + if c.connection != nil { + meta.APSState = c.connection.State().ToString() + } + if c.users != nil { + meta.IDSUsers = c.users.ToString() + } + if c.identity != nil { + meta.IDSIdentity = c.identity.ToString() + } + if c.config != nil { + meta.DeviceID = c.config.GetDeviceId() + } + if err := c.UserLogin.Save(context.Background()); err != nil { + log.Err(err).Msg("Failed to persist state") + } +} + +func (c *IMClient) periodicStateSave(log zerolog.Logger) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + c.persistState(log) + log.Debug().Msg("Periodic state save completed") + case <-c.stopChan: + c.persistState(log) + log.Debug().Msg("Final state save on disconnect") + return + } + } +} + +// periodicStatusSharingReinvite re-invites ghosts whose devices haven't +// responded to our StatusKit key invite. Tick cadence (1h) is deliberately +// short, but the per-ghost backoff inside reinvitePendingStatusSharingGhosts +// keeps worst-case IDS keysharing load bounded. First tick fires after the +// full interval so we don't pile on top of the startup invite. +func (c *IMClient) periodicStatusSharingReinvite(log zerolog.Logger) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + c.reinvitePendingStatusSharingGhosts(log) + case <-c.stopChan: + return + } + } +} + +// periodicCloudContactSync re-fetches contacts from iCloud CardDAV every +// 15 minutes. Also re-runs the shared iMessage profile re-fetch on the +// same tick so we keep one ticker but don't gate the share-profile path +// behind CardDAV success — refreshAllSharedProfiles only needs CloudKit +// (ProfilesClient) and runs independently even if SyncContacts errors. +func (c *IMClient) periodicCloudContactSync(log zerolog.Logger) { + ticker := time.NewTicker(15 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := c.contacts.SyncContacts(log); err != nil { + log.Warn().Err(err).Msg("Periodic CardDAV sync failed") + } else { + c.setContactsReady(log) + c.persistMmeDelegate(log) + } + c.refreshAllSharedProfiles(log) + case <-c.stopChan: + return + } + } +} + +// persistMmeDelegate saves the current MobileMe delegate to user_login metadata +// so it can be seeded on restore without needing a fresh PET-based auth. +func (c *IMClient) persistMmeDelegate(log zerolog.Logger) { + if c.tokenProvider == nil || *c.tokenProvider == nil { + return + } + tp := *c.tokenProvider + delegateJSON, err := tp.GetMmeDelegateJson() + if err != nil { + log.Warn().Err(err).Msg("Failed to get MobileMe delegate for persistence") + return + } + if delegateJSON == nil || *delegateJSON == "" { + return + } + meta := c.UserLogin.Metadata.(*UserLoginMetadata) + if meta.MmeDelegateJSON == *delegateJSON { + return // unchanged + } + meta.MmeDelegateJSON = *delegateJSON + if err = c.UserLogin.Save(context.Background()); err != nil { + log.Warn().Err(err).Msg("Failed to persist MobileMe delegate") + } else { + log.Info().Msg("Persisted MobileMe delegate to user_login metadata") + } +} + +// retryCloudContacts retries the cloud contacts initialization periodically +// when it fails on startup (e.g., expired MobileMe delegate). Once contacts +// succeed, the readiness gate opens and cloud sync begins. +func (c *IMClient) retryCloudContacts(log zerolog.Logger) { + ticker := time.NewTicker(2 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + log.Info().Msg("Retrying cloud contacts initialization...") + c.contacts = newCloudContactsClient(c.client, log) + if c.contacts != nil { + if syncErr := c.contacts.SyncContacts(log); syncErr != nil { + log.Warn().Err(syncErr).Msg("Cloud contacts retry: sync failed") + } else { + c.setContactsReady(log) + c.persistMmeDelegate(log) + log.Info().Msg("Cloud contacts retry succeeded, starting periodic sync") + go c.periodicCloudContactSync(log) + return + } + } else { + log.Warn().Msg("Cloud contacts retry: still unavailable") + } + case <-c.stopChan: + return + } + } +} + +// ============================================================================ +// Contact change watcher +// ============================================================================ + +// refreshAllGhosts re-resolves contact info for every known ghost and pushes +// any changes (name, avatar, identifiers) to Matrix. +func (c *IMClient) refreshAllGhosts(log zerolog.Logger) { + ctx := log.WithContext(context.Background()) + + // Query all ghost IDs from the bridge database. + rows, err := c.Main.Bridge.DB.Database.Query(ctx, + "SELECT id FROM ghost WHERE bridge_id=$1", + c.Main.Bridge.ID, + ) + if err != nil { + log.Err(err).Msg("Contact refresh: failed to query ghost IDs") + return + } + defer rows.Close() + var ghostIDs []networkid.UserID + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + log.Err(err).Msg("Contact refresh: failed to scan ghost ID") + continue + } + ghostIDs = append(ghostIDs, networkid.UserID(id)) + } + if err := rows.Err(); err != nil { + log.Err(err).Msg("Contact refresh: row iteration error") + } + + updated := 0 + for _, ghostID := range ghostIDs { + ghost, err := c.Main.Bridge.GetGhostByID(ctx, ghostID) + if err != nil { + log.Warn().Err(err).Str("ghost_id", string(ghostID)).Msg("Contact refresh: failed to load ghost") + continue + } + info, err := c.GetUserInfo(ctx, ghost) + if err != nil || info == nil { + continue + } + ghost.UpdateInfo(ctx, info) + updated++ + } + + log.Info().Int("ghosts_checked", len(ghostIDs)).Int("updated", updated). + Msg("Contact change detected — refreshed ghost profiles") +} + +// ============================================================================ +// Helpers +// ============================================================================ + +func (c *IMClient) isMyHandle(handle string) bool { + normalizedHandle := normalizeIdentifierForPortalID(handle) + for _, h := range c.allHandles { + if normalizedHandle == normalizeIdentifierForPortalID(h) { + return true + } + } + return false +} + +// normalizeIdentifierForPortalID canonicalizes user/chat identifiers so portal +// routing is stable across formatting variants (notably SMS numbers with and +// without leading "+1"). +func normalizeIdentifierForPortalID(identifier string) string { + id := strings.TrimSpace(identifier) + if id == "" { + return "" + } + // Strip Apple SMS service suffixes: "+12155167207(smsft)" → "+12155167207", + // "787473(smsft)" → "787473". Must happen before any other processing so + // the suffix never reaches isNumeric / normalizePhone checks. + id = stripSmsSuffix(id) + + if strings.HasPrefix(id, "mailto:") { + return "mailto:" + strings.ToLower(strings.TrimPrefix(id, "mailto:")) + } + if strings.Contains(id, "@") && !strings.HasPrefix(id, "tel:") { + return "mailto:" + strings.ToLower(strings.TrimPrefix(id, "mailto:")) + } + + if strings.HasPrefix(id, "tel:") || strings.HasPrefix(id, "+") || isNumeric(id) { + local := stripIdentifierPrefix(id) + normalized := normalizePhoneIdentifierForPortalID(local) + if normalized != "" { + return "tel:" + normalized + } + return addIdentifierPrefix(local) + } + + return id +} + +// buildCanonicalParticipantList normalizes, deduplicates, and sorts a +// participant list into the canonical form used for comma-based portal IDs. +// Any self handles are filtered out and replaced with the single canonical +// c.handle. Accepts both raw and pre-normalized inputs (normalization is +// idempotent). +func (c *IMClient) buildCanonicalParticipantList(participants []string) []string { + sorted := make([]string, 0, len(participants)) + for _, p := range participants { + normalized := normalizeIdentifierForPortalID(p) + if normalized == "" || c.isMyHandle(normalized) { + continue + } + sorted = append(sorted, normalized) + } + sorted = append(sorted, normalizeIdentifierForPortalID(c.handle)) + sort.Strings(sorted) + deduped := sorted[:0] + for i, s := range sorted { + if i == 0 || s != sorted[i-1] { + deduped = append(deduped, s) + } + } + return deduped +} + +// normalizePhoneIdentifierForPortalID canonicalizes phone-like identifiers while +// preserving short-code semantics (e.g. "242733" stays "242733", not "+242733"). +func normalizePhoneIdentifierForPortalID(local string) string { + cleaned := normalizePhone(local) + if cleaned == "" { + return "" + } + if strings.HasPrefix(cleaned, "+") { + return cleaned + } + if len(cleaned) == 10 { + return "+1" + cleaned + } + if len(cleaned) == 11 && cleaned[0] == '1' { + return "+" + cleaned + } + if len(cleaned) >= 11 { + return "+" + cleaned + } + return cleaned +} + +func (c *IMClient) makeEventSender(sender *string) bridgev2.EventSender { + if sender == nil || *sender == "" || c.isMyHandle(*sender) { + c.ensureDoublePuppet() + return bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: makeUserID(c.handle), + } + } + normalizedSender := normalizeIdentifierForPortalID(*sender) + return bridgev2.EventSender{ + IsFromMe: false, + Sender: makeUserID(normalizedSender), + } +} + +// ensureDoublePuppet retries double puppet setup if it previously failed. +// +// The mautrix bridgev2 framework permanently caches a nil DoublePuppet() on +// first failure (user.go sets doublePuppetInitialized=true BEFORE calling +// NewUserIntent). On macOS Ventura, transient IDS registration issues can +// cause the initial setup to fail, and without a retry the nil is cached +// forever — making all IsFromMe messages fall through to the ghost intent, +// which flips their direction (sent appears as received). +// +// This workaround detects the cached nil and re-attempts login using the +// saved access token, which succeeds once IDS registration stabilizes. +func (c *IMClient) ensureDoublePuppet() { + ctx := context.Background() + user := c.UserLogin.User + if user.DoublePuppet(ctx) != nil { + return // already working + } + token := user.AccessToken + if token == "" { + return // no token to retry with + } + user.LogoutDoublePuppet(ctx) + if err := user.LoginDoublePuppet(ctx, token); err != nil { + c.UserLogin.Log.Warn().Err(err).Msg("Failed to re-establish double puppet") + } else { + c.UserLogin.Log.Info().Msg("Re-established double puppet after previous failure") + } +} + +// resolveExistingDMPortalID prefers an already-created DM portal key variant +// (e.g. legacy tel:1415... vs canonical tel:+1415...) to avoid splitting rooms +// when normalization rules change. For mailto: identifiers, it also tries +// the contact's phone-based portal IDs (since StatusKit may report the email +// handle while the DM portal was created under the phone handle). +func (c *IMClient) resolveExistingDMPortalID(identifier string) networkid.PortalID { + defaultID := networkid.PortalID(identifier) + if identifier == "" || strings.Contains(identifier, ",") { + return defaultID + } + + // For mailto: identifiers, try the contact's other handles (phone numbers) + // since the DM portal may have been created under a tel: handle. + if strings.HasPrefix(identifier, "mailto:") { + contact := c.lookupContact(identifier) + if contact != nil { + ctx := context.Background() + for _, altID := range contactPortalIDs(contact) { + if altID == identifier { + continue + } + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, networkid.PortalKey{ + ID: networkid.PortalID(altID), + Receiver: c.UserLogin.ID, + }) + if err == nil && portal != nil && portal.MXID != "" { + c.UserLogin.Log.Debug(). + Str("original", identifier). + Str("resolved", altID). + Msg("Resolved mailto: DM portal to existing contact portal") + return networkid.PortalID(altID) + } + } + } + return defaultID + } + + if !strings.HasPrefix(identifier, "tel:") { + return defaultID + } + + local := strings.TrimPrefix(identifier, "tel:") + candidates := make([]string, 0, 3) + seen := map[string]bool{identifier: true} + add := func(id string) { + if id == "" || seen[id] { + return + } + seen[id] = true + candidates = append(candidates, id) + } + + if strings.HasPrefix(local, "+") { + withoutPlus := strings.TrimPrefix(local, "+") + add("tel:" + withoutPlus) + if strings.HasPrefix(local, "+1") && len(local) == 12 { + add("tel:" + strings.TrimPrefix(local, "+1")) + } + } else if isNumeric(local) { + if len(local) == 10 { + add("tel:1" + local) + } + if len(local) == 11 && strings.HasPrefix(local, "1") { + add("tel:" + local[1:]) + } + } + + ctx := context.Background() + for _, candidate := range candidates { + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, networkid.PortalKey{ + ID: networkid.PortalID(candidate), + Receiver: c.UserLogin.ID, + }) + if err == nil && portal != nil && portal.MXID != "" { + c.UserLogin.Log.Debug(). + Str("normalized", identifier). + Str("resolved", candidate). + Msg("Resolved DM portal to existing legacy identifier") + return networkid.PortalID(candidate) + } + } + + return defaultID +} + +// ensureGroupPortalIndex lazily loads all existing group portals from the DB +// and builds an in-memory index mapping each member to its group portal IDs. +func (c *IMClient) ensureGroupPortalIndex() { + c.groupPortalMu.Lock() + defer c.groupPortalMu.Unlock() + if c.groupPortalIndex != nil { + return // already loaded + } + + idx := make(map[string]map[string]bool) + ctx := context.Background() + portals, err := c.Main.Bridge.DB.Portal.GetAllWithMXID(ctx) + if err != nil { + c.UserLogin.Log.Err(err).Msg("Failed to load portals for group index") + return // leave c.groupPortalIndex nil so next call retries + } + for _, p := range portals { + portalID := string(p.ID) + if !strings.Contains(portalID, ",") { + continue // skip DMs + } + if p.Receiver != c.UserLogin.ID { + continue // skip other users' portals + } + for _, member := range strings.Split(portalID, ",") { + if idx[member] == nil { + idx[member] = make(map[string]bool) + } + idx[member][portalID] = true + } + } + c.groupPortalIndex = idx + c.UserLogin.Log.Debug(). + Int("portals_indexed", len(c.groupPortalIndex)). + Msg("Built group portal fuzzy-match index") +} + +// indexGroupPortalLocked adds a group portal ID to the in-memory index. +// Caller must hold groupPortalMu write lock. +// Safe to call before ensureGroupPortalIndex — returns early when the index +// has not been built yet (nil map), since the full rebuild will pick it up. +func (c *IMClient) indexGroupPortalLocked(portalID string) { + if c.groupPortalIndex == nil { + return + } + for _, member := range strings.Split(portalID, ",") { + if c.groupPortalIndex[member] == nil { + c.groupPortalIndex[member] = make(map[string]bool) + } + c.groupPortalIndex[member][portalID] = true + } +} + +// registerGroupPortal thread-safely indexes a new group portal. +func (c *IMClient) registerGroupPortal(portalID string) { + c.groupPortalMu.Lock() + defer c.groupPortalMu.Unlock() + c.indexGroupPortalLocked(portalID) +} + +// reIDPortalWithCacheUpdate atomically re-keys a portal in the DB and updates +// all in-memory caches. Holding all group cache write locks during the entire +// operation prevents concurrent handlers (read receipts, typing indicators) +// from observing a state where the DB key changed but caches still reference +// the old portal ID. +func (c *IMClient) reIDPortalWithCacheUpdate(ctx context.Context, oldKey, newKey networkid.PortalKey) (bridgev2.ReIDResult, *bridgev2.Portal, error) { + oldID := string(oldKey.ID) + newID := string(newKey.ID) + + c.imGroupNamesMu.Lock() + c.imGroupGuidsMu.Lock() + c.imGroupParticipantsMu.Lock() + c.groupPortalMu.Lock() + c.lastGroupForMemberMu.Lock() + c.gidAliasesMu.Lock() + c.smsPortalsLock.Lock() + defer c.smsPortalsLock.Unlock() + defer c.gidAliasesMu.Unlock() + defer c.lastGroupForMemberMu.Unlock() + defer c.groupPortalMu.Unlock() + defer c.imGroupParticipantsMu.Unlock() + defer c.imGroupGuidsMu.Unlock() + defer c.imGroupNamesMu.Unlock() + + result, portal, err := c.Main.Bridge.ReIDPortal(ctx, oldKey, newKey) + if err != nil { + return result, portal, err + } + + // Move group name cache + if name, ok := c.imGroupNames[oldID]; ok { + c.imGroupNames[newID] = name + delete(c.imGroupNames, oldID) + } + // Move group guid cache + if guid, ok := c.imGroupGuids[oldID]; ok { + c.imGroupGuids[newID] = guid + delete(c.imGroupGuids, oldID) + } + // Move group participants cache + if parts, ok := c.imGroupParticipants[oldID]; ok { + c.imGroupParticipants[newID] = parts + delete(c.imGroupParticipants, oldID) + } + // Update group portal index: remove old members, add new + for _, member := range strings.Split(oldID, ",") { + if portals, ok := c.groupPortalIndex[member]; ok { + delete(portals, oldID) + if len(portals) == 0 { + delete(c.groupPortalIndex, member) + } + } + } + c.indexGroupPortalLocked(newID) + // Update lastGroupForMember entries pointing to old portal + for member, key := range c.lastGroupForMember { + if key == oldKey { + c.lastGroupForMember[member] = newKey + } + } + // Update gidAliases entries pointing to old portal + for alias, target := range c.gidAliases { + if target == oldID { + c.gidAliases[alias] = newID + } + } + // Move SMS portal flag + if isSms, ok := c.smsPortals[oldID]; ok { + c.smsPortals[newID] = isSms + delete(c.smsPortals, oldID) + } + + return result, portal, nil +} + +// resolveExistingGroupPortalID checks whether an existing group portal matches +// the computed portal ID via fuzzy matching (differs by at most 1 member). +// If senderGuid is provided, fuzzy matches are validated against the cached +// sender_guid — a mismatch means a different group even if members overlap. +// If a match is found, returns the existing portal ID; otherwise registers the +// new ID and returns it as-is. +func (c *IMClient) resolveExistingGroupPortalID(computedID string, senderGuid *string) networkid.PortalID { + c.ensureGroupPortalIndex() + + // Fast path: exact match in DB + ctx := context.Background() + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, networkid.PortalKey{ + ID: networkid.PortalID(computedID), + Receiver: c.UserLogin.ID, + }) + if err == nil && portal != nil && portal.MXID != "" { + return networkid.PortalID(computedID) + } + + // Fuzzy match: find existing portals that share members with the candidate. + candidateMembers := strings.Split(computedID, ",") + candidateSize := len(candidateMembers) + + // Count how many members each existing portal shares with the candidate. + overlap := make(map[string]int) // existing portal ID -> shared member count + c.groupPortalMu.RLock() + for _, member := range candidateMembers { + for existingID := range c.groupPortalIndex[member] { + overlap[existingID]++ + } + } + c.groupPortalMu.RUnlock() + + for existingID, sharedCount := range overlap { + existingMembers := strings.Split(existingID, ",") + existingSize := len(existingMembers) + diff := (candidateSize - sharedCount) + (existingSize - sharedCount) + if diff > 1 { + continue + } + if diff == 1 && !participantSetsMatch(candidateMembers, existingMembers, c.isMyHandle) { + continue // diff=1 for a non-self member → different group + } + + // If we have a sender_guid, reject fuzzy matches with a different + // sender_guid — they are genuinely different group conversations + // that happen to share most members. + if senderGuid != nil && *senderGuid != "" { + c.imGroupGuidsMu.RLock() + existingGuid := c.imGroupGuids[existingID] + c.imGroupGuidsMu.RUnlock() + if existingGuid != "" && existingGuid != *senderGuid { + continue + } + } + + // Verify the match actually exists in DB with a Matrix room. + existing, err := c.Main.Bridge.GetExistingPortalByKey(ctx, networkid.PortalKey{ + ID: networkid.PortalID(existingID), + Receiver: c.UserLogin.ID, + }) + if err != nil || existing == nil || existing.MXID == "" { + continue + } + + c.UserLogin.Log.Info(). + Str("computed", computedID). + Str("resolved", existingID). + Int("diff", diff). + Msg("Fuzzy-matched group portal to existing room") + return networkid.PortalID(existingID) + } + + // No match — register this as a new group portal. + c.registerGroupPortal(computedID) + return networkid.PortalID(computedID) +} + +// findGroupPortalForMember returns the most likely group portal for a member. +// Prefers the group where the member last sent a message; falls back to the +// sole group containing them. Used when typing/read receipts lack full +// participant lists. +func isComputedDMPortalID(id networkid.PortalID) bool { + s := string(id) + return !strings.HasPrefix(s, "gid:") && !strings.Contains(s, ",") +} + +func (c *IMClient) resolveSMSGroupRedirectPortal(msg rustpushgo.WrappedMessage) (networkid.PortalKey, bool) { + if len(msg.Participants) != 0 || msg.Sender == nil || *msg.Sender == "" { + return networkid.PortalKey{}, false + } + + candidates := make(map[string]struct{}, 3) + addCandidate := func(portalID string) { + if portalID == "" { + return + } + candidates[portalID] = struct{}{} + } + + if msg.SenderGuid != nil && *msg.SenderGuid != "" { + // Apple group GUIDs are stable across messages; this bridge stores them + // as portal IDs prefixed with "gid:". + addCandidate("gid:" + strings.ToLower(*msg.SenderGuid)) + + c.imGroupGuidsMu.RLock() + matches := make([]string, 0, 1) + for portalID, guid := range c.imGroupGuids { + if strings.EqualFold(guid, *msg.SenderGuid) { + matches = append(matches, portalID) + } + } + c.imGroupGuidsMu.RUnlock() + if len(matches) == 1 { + addCandidate(matches[0]) + } + } + + // Fallback: unique group membership match only (no "last active" heuristic). + normalized := normalizeIdentifierForPortalID(*msg.Sender) + if normalized != "" { + c.ensureGroupPortalIndex() + c.groupPortalMu.RLock() + portals := c.groupPortalIndex[normalized] + c.groupPortalMu.RUnlock() + if len(portals) == 1 { + for portalID := range portals { + addCandidate(portalID) + } + } + } + + if len(candidates) != 1 { + return networkid.PortalKey{}, false + } + + ctx := context.Background() + for portalID := range candidates { + groupKey := networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: c.UserLogin.ID} + if existing, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, groupKey); existing != nil && existing.MXID != "" { + return groupKey, true + } + } + return networkid.PortalKey{}, false +} + +func (c *IMClient) findGroupPortalForMember(member string) (networkid.PortalKey, bool) { + normalized := normalizeIdentifierForPortalID(member) + if normalized == "" { + return networkid.PortalKey{}, false + } + + // Prefer last active group for this member. + c.lastGroupForMemberMu.RLock() + lastGroup, ok := c.lastGroupForMember[normalized] + c.lastGroupForMemberMu.RUnlock() + if ok { + return lastGroup, true + } + + // Fall back to group portal index — works if they're in exactly one group. + c.ensureGroupPortalIndex() + c.groupPortalMu.RLock() + portals := c.groupPortalIndex[normalized] + c.groupPortalMu.RUnlock() + + if len(portals) != 1 { + return networkid.PortalKey{}, false + } + + for portalID := range portals { + return networkid.PortalKey{ + ID: networkid.PortalID(portalID), + Receiver: c.UserLogin.ID, + }, true + } + return networkid.PortalKey{}, false +} + +// guidCacheMatchIsStale returns true if a guid cache entry for a comma-based +// portal is provably stale — the incoming participants contain members not +// present in the cached portal. Returns false (not provably stale) for +// non-comma portals, when no participant info is available, or when all +// incoming participants are present in the portal's member set. +// +// This intentionally uses a subset check rather than a symmetric match: +// typing and read-receipt payloads may only include [sender, target] without +// the full group roster, so missing portal members are expected and do not +// indicate staleness. +func (c *IMClient) guidCacheMatchIsStale(portalIDStr string, rawParticipants []string) bool { + if len(rawParticipants) == 0 { + return false + } + // For gid: portal IDs (no comma), resolve participants from the cache + // so gid->gid aliases can be validated and self-heal when stale. + var portalParts []string + if !strings.Contains(portalIDStr, ",") { + c.imGroupParticipantsMu.RLock() + cached := c.imGroupParticipants[portalIDStr] + c.imGroupParticipantsMu.RUnlock() + if len(cached) == 0 { + return false // no participant info to compare against + } + portalParts = cached + } else { + portalParts = strings.Split(portalIDStr, ",") + } + portalSet := make(map[string]bool, len(portalParts)) + for _, p := range portalParts { + portalSet[p] = true + } + // Canonicalize incoming participants (collapses alternate self handles + // to c.handle, deduplicates, sorts) so all callsites get consistent + // behavior regardless of whether they pre-canonicalize. + canonical := c.buildCanonicalParticipantList(rawParticipants) + if len(canonical) == 0 { + return false + } + // Only flag as stale if incoming participants contain members NOT in + // the portal's set (and not self handles). This correctly handles + // partial payloads where the incoming list is a subset of the portal. + for _, p := range canonical { + if !portalSet[p] && !c.isMyHandle(p) { + return true + } + } + return false +} + +// resolveExistingGroupByGid tries to find an existing group portal that matches +// the incoming message when the gid (sender_guid) doesn't match any known portal. +// This handles the case where another rustpush client (like OpenBubbles) uses a +// different UUID for the same group conversation. +// +// Resolution order: +// 1. imGroupGuids cache — any existing portal with a matching guid value +// 2. imGroupParticipants cache — portals with overlapping participant sets +// 3. groupPortalIndex — comma-based portals via fuzzy participant matching +// 4. cloud_chat DB — participant matching against all persisted groups +func (c *IMClient) resolveExistingGroupByGid(gidPortalID string, senderGuid string, participants []string) networkid.PortalID { + ctx := context.Background() + normalizedGuid := strings.ToLower(senderGuid) + + // 1. Check imGroupGuids cache: does any existing portal already have + // this guid cached? (covers comma-based portals that previously + // received messages with this same guid) + c.imGroupGuidsMu.RLock() + for portalIDStr, guid := range c.imGroupGuids { + if strings.ToLower(guid) == normalizedGuid { + c.imGroupGuidsMu.RUnlock() + if c.guidCacheMatchIsStale(portalIDStr, participants) { + c.UserLogin.Log.Warn(). + Str("gid_portal_id", gidPortalID). + Str("candidate_portal", portalIDStr). + Str("stale_guid", guid). + Msg("Skipping stale guid cache entry: participant mismatch — clearing") + // Self-heal: remove from in-memory cache + c.imGroupGuidsMu.Lock() + if c.imGroupGuids[portalIDStr] == guid { + delete(c.imGroupGuids, portalIDStr) + } + c.imGroupGuidsMu.Unlock() + // Self-heal: clear stale SenderGuid from DB metadata. + // MUST be synchronous — portalToConversation (Site E) lazily + // loads metadata into cache and would re-poison it. + staleKey := networkid.PortalKey{ID: networkid.PortalID(portalIDStr), Receiver: c.UserLogin.ID} + if stalePortal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, staleKey); err != nil { + c.UserLogin.Log.Warn().Err(err). + Str("portal_id", portalIDStr). + Msg("Failed to look up portal for stale guid self-heal") + } else if stalePortal != nil { + if meta, ok := stalePortal.Metadata.(*PortalMetadata); ok && meta.SenderGuid == guid { + meta.SenderGuid = "" + stalePortal.Metadata = meta + if err := stalePortal.Save(ctx); err != nil { + c.UserLogin.Log.Warn().Err(err). + Str("portal_id", portalIDStr). + Msg("Failed to clear stale SenderGuid from DB metadata") + } else { + c.UserLogin.Log.Info(). + Str("portal_id", portalIDStr). + Str("cleared_guid", guid). + Msg("Cleared stale SenderGuid from DB metadata") + } + } + } + c.imGroupGuidsMu.RLock() + continue + } + key := networkid.PortalKey{ID: networkid.PortalID(portalIDStr), Receiver: c.UserLogin.ID} + if p, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, key); p != nil && p.MXID != "" { + c.UserLogin.Log.Info(). + Str("gid_portal_id", gidPortalID). + Str("resolved_portal", portalIDStr). + Msg("Resolved unknown gid to existing portal via guid cache") + return networkid.PortalID(portalIDStr) + } + c.imGroupGuidsMu.RLock() + } + } + c.imGroupGuidsMu.RUnlock() + + // Build normalized participant set for matching. + if len(participants) == 0 { + return networkid.PortalID(gidPortalID) + } + normalizedParts := make([]string, 0, len(participants)) + for _, p := range participants { + n := normalizeIdentifierForPortalID(p) + if n != "" { + normalizedParts = append(normalizedParts, n) + } + } + if len(normalizedParts) == 0 { + return networkid.PortalID(gidPortalID) + } + + // 2. Check imGroupParticipants cache: find gid: portals with matching + // participant sets. Comma-based portals are intentionally excluded here + // because step 3 (groupPortalIndex) handles them using the authoritative + // portal ID rather than the potentially-stale in-memory cache. + c.imGroupParticipantsMu.RLock() + for portalIDStr, parts := range c.imGroupParticipants { + if portalIDStr == gidPortalID { + continue + } + if !strings.HasPrefix(portalIDStr, "gid:") { + continue + } + if participantSetsMatch(parts, normalizedParts, c.isMyHandle) { + c.imGroupParticipantsMu.RUnlock() + key := networkid.PortalKey{ID: networkid.PortalID(portalIDStr), Receiver: c.UserLogin.ID} + if p, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, key); p != nil && p.MXID != "" { + c.UserLogin.Log.Info(). + Str("gid_portal_id", gidPortalID). + Str("resolved_portal", portalIDStr). + Msg("Resolved unknown gid to existing portal via participant cache") + return networkid.PortalID(portalIDStr) + } + c.imGroupParticipantsMu.RLock() + } + } + c.imGroupParticipantsMu.RUnlock() + + // 3. Check comma-based portals via groupPortalIndex fuzzy matching. + // We intentionally skip the guid mismatch rejection here because the + // whole point is to find portals where the guid differs (another client + // using a different gid for the same group). + c.ensureGroupPortalIndex() + deduped := c.buildCanonicalParticipantList(normalizedParts) + + overlap := make(map[string]int) + c.groupPortalMu.RLock() + for _, member := range deduped { + for existingID := range c.groupPortalIndex[member] { + overlap[existingID]++ + } + } + c.groupPortalMu.RUnlock() + + candidateSize := len(deduped) + var exactMatch, fuzzyMatch string + for existingID, sharedCount := range overlap { + existingMembers := strings.Split(existingID, ",") + existingSize := len(existingMembers) + diff := (candidateSize - sharedCount) + (existingSize - sharedCount) + if diff > 1 { + continue + } + if diff == 1 && !participantSetsMatch(deduped, existingMembers, c.isMyHandle) { + continue // diff=1 for a non-self member → different group + } + key := networkid.PortalKey{ID: networkid.PortalID(existingID), Receiver: c.UserLogin.ID} + if p, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, key); p != nil && p.MXID != "" { + if diff == 0 { + exactMatch = existingID + break // Can't do better than exact + } + if fuzzyMatch == "" { + fuzzyMatch = existingID + } + } + } + if exactMatch != "" { + c.UserLogin.Log.Info(). + Str("gid_portal_id", gidPortalID). + Str("resolved_portal", exactMatch). + Int("participant_diff", 0). + Msg("Resolved unknown gid to existing comma-based portal via fuzzy match") + return networkid.PortalID(exactMatch) + } + if fuzzyMatch != "" { + c.UserLogin.Log.Info(). + Str("gid_portal_id", gidPortalID). + Str("resolved_portal", fuzzyMatch). + Int("participant_diff", 1). + Msg("Resolved unknown gid to existing comma-based portal via fuzzy match") + return networkid.PortalID(fuzzyMatch) + } + + // 4. Fall back to cloud_chat DB for portals not in memory caches + // (e.g., gid: portals from CloudKit sync that haven't received + // live messages yet, so imGroupParticipants is empty). + // Only consider group portals (gid: or comma-based). DM portals + // can accidentally match via ±1 participant tolerance because a + // DM's [self, A] is one member short of a group's [self, A, B]. + if c.cloudStore != nil { + matches, err := c.cloudStore.findPortalIDsByParticipants(ctx, normalizedParts, c.isMyHandle) + if err == nil { + for _, matchPortalID := range matches { + if matchPortalID == gidPortalID { + continue + } + // Skip DM portals — they can't be the same group. + if !strings.HasPrefix(matchPortalID, "gid:") && !strings.Contains(matchPortalID, ",") { + continue + } + key := networkid.PortalKey{ID: networkid.PortalID(matchPortalID), Receiver: c.UserLogin.ID} + if p, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, key); p != nil && p.MXID != "" { + c.UserLogin.Log.Info(). + Str("gid_portal_id", gidPortalID). + Str("resolved_portal", matchPortalID). + Msg("Resolved unknown gid to existing portal via cloud_chat DB") + return networkid.PortalID(matchPortalID) + } + } + } + } + + // No existing portal found — this is genuinely a new group. + return networkid.PortalID(gidPortalID) +} + +func (c *IMClient) makePortalKey(participants []string, groupName *string, sender *string, senderGuid *string) networkid.PortalKey { + isGroup := c.getUniqueParticipantCount(participants) > 2 || (groupName != nil && *groupName != "") + + if isGroup { + // When a persistent group UUID (sender_guid / gid) is available, + // use "gid:" as the stable portal ID. This avoids the + // fragility of participant-based IDs that break when membership + // changes or participants normalize differently. + var portalID networkid.PortalID + if senderGuid != nil && *senderGuid != "" { + gidID := "gid:" + strings.ToLower(*senderGuid) + + // Fast path: check if we've previously resolved this gid to + // a different portal (cached from a prior resolution). + c.gidAliasesMu.RLock() + aliasedID, hasAlias := c.gidAliases[gidID] + c.gidAliasesMu.RUnlock() + if hasAlias { + // Canonicalize participants (collapses alternate self handles to + // c.handle) before staleness check so a valid alias is never + // evicted just because APNs reported self with a different identifier. + // Guard on raw participants first: buildCanonicalParticipantList always + // injects c.handle, so canonical is never empty even when the message + // carried no participant info — a [self]-only list must not trigger eviction. + canonical := c.buildCanonicalParticipantList(participants) + if len(participants) > 0 && len(canonical) > 0 && c.guidCacheMatchIsStale(aliasedID, canonical) { + c.gidAliasesMu.Lock() + // Compare-before-delete: another handler may have repaired + // the alias between our RLock read and this write lock. + if c.gidAliases[gidID] == aliasedID { + delete(c.gidAliases, gidID) + } + c.gidAliasesMu.Unlock() + c.UserLogin.Log.Warn(). + Str("gid_id", gidID). + Str("stale_alias", aliasedID). + Msg("Cleared stale gid alias: participant mismatch") + // Fall through to normal resolution below + } else { + portalID = networkid.PortalID(aliasedID) + } + } + if portalID == "" { + // Check if a portal with this exact gid already exists. + ctx := context.Background() + gidKey := networkid.PortalKey{ID: networkid.PortalID(gidID), Receiver: c.UserLogin.ID} + if existing, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, gidKey); existing != nil && existing.MXID != "" { + portalID = networkid.PortalID(gidID) + } else { + // This gid doesn't match any existing portal. Another + // rustpush client (like OpenBubbles) may use a different + // gid for the same group. Try to resolve by participants. + portalID = c.resolveExistingGroupByGid(gidID, *senderGuid, participants) + // Cache the alias so subsequent messages with this gid + // resolve instantly without repeated participant matching. + if string(portalID) != gidID { + c.gidAliasesMu.Lock() + c.gidAliases[gidID] = string(portalID) + c.gidAliasesMu.Unlock() + } + } + } + } else { + // Fallback: build a participant-based ID for groups without a UUID. + deduped := c.buildCanonicalParticipantList(participants) + computedID := strings.Join(deduped, ",") + portalID = c.resolveExistingGroupPortalID(computedID, senderGuid) + } + // Cache the actual iMessage group name (cv_name) so outbound + // messages can route to the correct conversation. Also push a + // room name update when the envelope name differs from what's + // cached OR on the first message after restart (old is empty). + // After restart, imGroupNames is empty and the portal may have + // a stale name from CloudKit. Pushing on first message ensures + // the correct cv_name from APNs overrides any stale data. + // bridgev2's updateName deduplicates — no state event if the + // name is already correct. + if groupName != nil && *groupName != "" { + c.imGroupNamesMu.Lock() + old := c.imGroupNames[string(portalID)] + c.imGroupNames[string(portalID)] = *groupName + c.imGroupNamesMu.Unlock() + if old != *groupName { + newName := *groupName + pid := string(portalID) + go func() { + c.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatInfoChange, + PortalKey: networkid.PortalKey{ + ID: portalID, + Receiver: c.UserLogin.ID, + }, + LogContext: func(lc zerolog.Context) zerolog.Context { + return lc.Str("portal_id", pid).Str("source", "envelope_name_change") + }, + }, + ChatInfoChange: &bridgev2.ChatInfoChange{ + ChatInfo: &bridgev2.ChatInfo{ + Name: &newName, + ExcludeChangesFromTimeline: true, + }, + }, + }) + }() + } + } + // Cache normalized participants in memory so resolveGroupMembers + // can find them immediately. The cloud_chat DB write happens async + // and may not complete before GetChatInfo is called during portal + // creation, which would cause the group name to resolve as "Group Chat". + if len(participants) > 0 { + normalized := make([]string, 0, len(participants)) + for _, p := range participants { + n := normalizeIdentifierForPortalID(p) + if n != "" { + normalized = append(normalized, n) + } + } + if len(normalized) > 0 { + c.imGroupParticipantsMu.Lock() + c.imGroupParticipants[string(portalID)] = normalized + c.imGroupParticipantsMu.Unlock() + } + } + portalKey := networkid.PortalKey{ID: portalID, Receiver: c.UserLogin.ID} + + // Cache the original-case sender_guid so outbound messages reuse the + // same UUID casing and Apple Messages recipients match them to the + // existing group thread (Apple matches case-sensitively). + if senderGuid != nil && *senderGuid != "" { + // Cache for legacy comma portals and for gid: portals where the + // portal ID directly corresponds to this sender_guid. Skip aliased + // portals (where resolveExistingGroupByGid mapped this gid to a + // different existing portal) to avoid overwriting the original + // portal's sender_guid. + isOwnGidPortal := string(portalID) == "gid:"+strings.ToLower(*senderGuid) + if strings.Contains(string(portalID), ",") || isOwnGidPortal { + c.imGroupGuidsMu.Lock() + c.imGroupGuids[string(portalID)] = *senderGuid + c.imGroupGuidsMu.Unlock() + } + } + + // Persist sender_guid and group name to database so they survive restarts + persistGuid := "" + if senderGuid != nil { + persistGuid = *senderGuid + } + persistName := "" + if groupName != nil { + persistName = *groupName + } + // Persist participants to cloud_chat so portalToConversation can + // find them for outbound messages (even if CloudKit never synced this group). + if strings.HasPrefix(string(portalID), "gid:") && len(participants) > 0 { + go func(pk networkid.PortalKey, parts []string, guid string) { + if c.cloudStore == nil { + return + } + ctx := context.Background() + // Only insert if no cloud_chat record exists yet + existing, err := c.cloudStore.getChatParticipantsByPortalID(ctx, string(pk.ID)) + if err == nil && len(existing) > 0 { + return // already have participants + } + if upsertErr := c.cloudStore.upsertChat(ctx, guid, "", guid, string(pk.ID), "iMessage", nil, nil, parts, 0); upsertErr != nil { + c.Main.Bridge.Log.Warn().Err(upsertErr).Str("portal_id", string(pk.ID)).Msg("Failed to persist real-time group participants") + } else { + c.Main.Bridge.Log.Info().Str("portal_id", string(pk.ID)).Int("participants", len(parts)).Msg("Persisted real-time group participants to cloud_chat") + } + }(portalKey, participants, persistGuid) + } + + if persistGuid != "" || persistName != "" { + go func(pk networkid.PortalKey, guid, gname string) { + ctx := context.Background() + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, pk) + if err == nil && portal != nil { + meta := &PortalMetadata{} + if existing, ok := portal.Metadata.(*PortalMetadata); ok { + *meta = *existing + } + changed := false + if guid != "" && meta.SenderGuid != guid { + meta.SenderGuid = guid + changed = true + } + if gname != "" && meta.GroupName != gname { + meta.GroupName = gname + changed = true + } + if changed { + portal.Metadata = meta + _ = portal.Save(ctx) + } + } + }(portalKey, persistGuid, persistName) + } + // Track which group each member last sent a message in, so typing + // indicators (which lack full participant lists) can be routed. + if sender != nil && *sender != "" { + normalized := normalizeIdentifierForPortalID(*sender) + if normalized != "" && !c.isMyHandle(normalized) { + c.lastGroupForMemberMu.Lock() + c.lastGroupForMember[normalized] = portalKey + c.lastGroupForMemberMu.Unlock() + } + } + return portalKey + } + + for _, p := range participants { + normalized := normalizeIdentifierForPortalID(p) + if normalized != "" && !c.isMyHandle(normalized) { + // Resolve to an existing portal if the contact has multiple phone numbers. + // This ensures messages from any of a contact's numbers land in one room. + portalID := c.resolveContactPortalID(normalized) + portalID = c.resolveExistingDMPortalID(string(portalID)) + return networkid.PortalKey{ + ID: portalID, + Receiver: c.UserLogin.ID, + } + } + } + + // SMS edge case: some payloads include only the local forwarding number in + // participants. When that happens, use sender as the DM portal identifier. + if sender != nil && *sender != "" { + normalizedSender := normalizeIdentifierForPortalID(*sender) + if normalizedSender != "" && !c.isMyHandle(normalizedSender) { + portalID := c.resolveContactPortalID(normalizedSender) + portalID = c.resolveExistingDMPortalID(string(portalID)) + return networkid.PortalKey{ + ID: portalID, + Receiver: c.UserLogin.ID, + } + } + } + + if len(participants) > 0 { + normalized := normalizeIdentifierForPortalID(participants[0]) + if normalized == "" { + normalized = participants[0] + } + portalID := c.resolveExistingDMPortalID(normalized) + return networkid.PortalKey{ + ID: portalID, + Receiver: c.UserLogin.ID, + } + } + + return networkid.PortalKey{ID: "unknown", Receiver: c.UserLogin.ID} +} + +// makeReceiptPortalKey handles receipt messages where participants may be empty. +// When participants is empty (rustpush sets conversation: None for receipts), +// use the sender field to identify the DM portal. +func (c *IMClient) makeReceiptPortalKey(participants []string, groupName *string, sender *string, senderGuid *string) networkid.PortalKey { + if len(participants) > 0 { + return c.makePortalKey(participants, groupName, sender, senderGuid) + } + if sender != nil && *sender != "" { + // Resolve to existing portal for contacts with multiple numbers + normalizedSender := normalizeIdentifierForPortalID(*sender) + if normalizedSender == "" { + return networkid.PortalKey{ID: "unknown", Receiver: c.UserLogin.ID} + } + portalID := c.resolveContactPortalID(normalizedSender) + portalID = c.resolveExistingDMPortalID(string(portalID)) + return networkid.PortalKey{ + ID: portalID, + Receiver: c.UserLogin.ID, + } + } + return networkid.PortalKey{ID: "unknown", Receiver: c.UserLogin.ID} +} + +func (c *IMClient) makeConversation(participants []string, groupName *string) rustpushgo.WrappedConversation { + return rustpushgo.WrappedConversation{ + Participants: participants, + GroupName: groupName, + } +} + +func (c *IMClient) portalToConversation(portal *bridgev2.Portal) rustpushgo.WrappedConversation { + portalID := string(portal.ID) + isSms := c.isPortalSMS(portalID) + + isGroup := strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") + if isGroup { + var participants []string + var guid string + if strings.HasPrefix(portalID, "gid:") { + // Prefer original-case sender_guid from cache (populated by + // makePortalKey on incoming messages and loadSenderGuidsFromDB + // on restart). Falls back to metadata, then portal ID (lossy). + c.imGroupGuidsMu.RLock() + guid = c.imGroupGuids[portalID] + c.imGroupGuidsMu.RUnlock() + if guid == "" { + if meta, ok := portal.Metadata.(*PortalMetadata); ok && meta.SenderGuid != "" { + guid = meta.SenderGuid + } + } + if guid == "" { + // Last resort: extract from portal ID (lowercase, lossy) + guid = strings.TrimPrefix(portalID, "gid:") + } + // Look up participants from cloud store + if c.cloudStore != nil { + ctx := context.Background() + if parts, err := c.cloudStore.getChatParticipantsByPortalID(ctx, portalID); err == nil && len(parts) > 0 { + participants = parts + } + } + } else { + participants = strings.Split(portalID, ",") + } + + // Use the actual iMessage group name (cv_name) from the protocol, + // NOT the bridge-generated display name (portal.Name). Using the + // bridge display name causes Messages.app to split conversations. + c.imGroupNamesMu.RLock() + name := c.imGroupNames[portalID] + c.imGroupNamesMu.RUnlock() + if name == "" { + // Not in memory cache - try loading from portal metadata + if meta, ok := portal.Metadata.(*PortalMetadata); ok && meta.GroupName != "" { + name = meta.GroupName + c.imGroupNamesMu.Lock() + c.imGroupNames[portalID] = name + c.imGroupNamesMu.Unlock() + } + } + var groupName *string + if name != "" { + groupName = &name + } + + // For gid: portals, guid is already set above (cache/metadata/portal ID). + // For legacy comma-separated portals, look up from cache/metadata. + if guid == "" { + c.imGroupGuidsMu.RLock() + guid = c.imGroupGuids[portalID] + c.imGroupGuidsMu.RUnlock() + if guid == "" { + if meta, ok := portal.Metadata.(*PortalMetadata); ok && meta.SenderGuid != "" { + guid = meta.SenderGuid + c.imGroupGuidsMu.Lock() + c.imGroupGuids[portalID] = guid + c.imGroupGuidsMu.Unlock() + } + } + } + var senderGuid *string + if guid != "" { + senderGuid = &guid + } + return rustpushgo.WrappedConversation{ + Participants: participants, + GroupName: groupName, + SenderGuid: senderGuid, + IsSms: isSms, + } + } + + // For DMs, resolve the best sendable identifier. For merged contacts, + // the portal ID might be an inactive number that rustpush can't send to. + // Strip any legacy (sms...) suffix before resolution — resolveSendTarget + // calls lookupContact → stripIdentifierPrefix, which would pass the suffix + // through to contact lookup and break alternate-handle resolution. + cleanPortalID := stripSmsSuffix(portalID) + sendTo := c.resolveSendTarget(cleanPortalID) + + // For self-chats, only include one participant. Duplicating our own + // handle (e.g. [self, self]) causes rustpush to reject the message + // with NoValidTargets because all targets belong to the sender. + participants := []string{c.handle, sendTo} + if c.isMyHandle(sendTo) { + participants = []string{sendTo} + } + + return rustpushgo.WrappedConversation{ + Participants: participants, + IsSms: isSms, + } +} + +// resolveGroupMembers returns the participant list for a group portal. +// For gid: portals it checks the in-memory cache first (populated synchronously +// by makePortalKey), then the cloud store DB; for legacy comma-separated +// portal IDs it splits the ID string. +func (c *IMClient) resolveGroupMembers(ctx context.Context, portalID string) []string { + if strings.HasPrefix(portalID, "gid:") { + // 1) Check cloud store DB (persisted from CloudKit sync or previous messages) + if c.cloudStore != nil { + if participants, err := c.cloudStore.getChatParticipantsByPortalID(ctx, portalID); err == nil && len(participants) > 0 { + return participants + } + } + // 2) Fallback to in-memory cache (populated synchronously by makePortalKey). + // The cloud_chat DB write is async and may not have completed yet when + // GetChatInfo is called during portal creation from a real-time message. + c.imGroupParticipantsMu.RLock() + cached := c.imGroupParticipants[portalID] + c.imGroupParticipantsMu.RUnlock() + if len(cached) > 0 { + return cached + } + return nil + } + return strings.Split(portalID, ",") +} + +// resolveGroupName determines the best display name for a group portal. +// Returns the name and whether it came from an authoritative source +// (imGroupNames cache or CloudKit display_name). When authoritative is +// false, the name was generated from contact-resolved participant names. +// Priority: 1) in-memory cache (user-set iMessage group name from real-time +// +// protocol cv_name, e.g. when someone explicitly renames a group) +// 2) CloudKit display_name (user-set group name persisted to iCloud, +// the "name" field on CKChatRecord = cv_name from chat.db) +// 3) contact-resolved member names via buildGroupName (non-authoritative) +func (c *IMClient) resolveGroupName(ctx context.Context, portalID string) (name string, authoritative bool) { + // 1) In-memory cache (populated from real-time iMessage rename messages) + c.imGroupNamesMu.RLock() + cached := c.imGroupNames[portalID] + c.imGroupNamesMu.RUnlock() + if cached != "" { + return cached, true + } + + // 2) CloudKit display_name (user-set group name from iCloud). + if c.cloudStore != nil { + if dn, err := c.cloudStore.getDisplayNameByPortalID(ctx, portalID); err == nil && dn != "" { + return dn, true + } + } + + // 3) Build from contact-resolved member names (fallback, non-authoritative) + members := c.resolveGroupMembers(ctx, portalID) + if len(members) == 0 { + return "Group Chat", false + } + return c.buildGroupName(members), false +} + +// buildGroupName creates a human-readable group name from member identifiers +// by resolving contact names where possible, falling back to phone/email. +func (c *IMClient) buildGroupName(members []string) string { + var names []string + for _, memberID := range members { + if c.isMyHandle(memberID) { + continue // skip self + } + // Strip tel:/mailto: prefix for contact lookup + lookupID := stripIdentifierPrefix(memberID) + name := "" + var contact *imessage.Contact + if c.contacts != nil { + var contactErr error + contact, contactErr = c.contacts.GetContactInfo(lookupID) + if contactErr != nil { + c.Main.Bridge.Log.Debug().Err(contactErr).Str("id", lookupID).Msg("Failed to resolve contact info") + } + } + + if contact != nil && contact.HasName() { + name = c.Main.Config.FormatDisplayname(DisplaynameParams{ + FirstName: contact.FirstName, + LastName: contact.LastName, + Nickname: contact.Nickname, + ID: lookupID, + }) + } + if name == "" { + name = lookupID // raw phone/email without prefix + } + names = append(names, name) + } + if len(names) == 0 { + return "Group Chat" + } + if len(names) <= 4 { + return strings.Join(names, ", ") + } + return fmt.Sprintf("%s, %s, %s +%d more", names[0], names[1], names[2], len(names)-3) +} + +// ============================================================================ +// Message conversion +// ============================================================================ + +type attachmentMessage struct { + *rustpushgo.WrappedMessage + Attachment *rustpushgo.WrappedAttachment + Index int +} + +// stickerTapbackData carries the image bytes for a sticker placed on a +// message bubble (iMessage sticker tapback, type 7). Bridged as an image +// message replying to the target since Matrix reactions are text-only. +type stickerTapbackData struct { + ImageData []byte + MimeType string + TargetID string // UUID of the message the sticker was placed on +} + +func convertStickerTapback(ctx context.Context, intent bridgev2.MatrixAPI, data *stickerTapbackData) (*bridgev2.ConvertedMessage, error) { + content := &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: "sticker.png", + Info: &event.FileInfo{ + MimeType: data.MimeType, + Size: len(data.ImageData), + }, + } + if intent != nil { + url, encFile, err := intent.UploadMedia(ctx, "", data.ImageData, "sticker.png", data.MimeType) + if err != nil { + return nil, fmt.Errorf("failed to upload sticker: %w", err) + } + if encFile != nil { + content.File = encFile + } else { + content.URL = url + } + } + cm := &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: content, + }}, + } + if data.TargetID != "" { + cm.ReplyTo = &networkid.MessageOptionalPartID{MessageID: makeMessageID(data.TargetID)} + } + return cm, nil +} + +// convertURLPreviewToBeeper parses rich link sideband attachments from an +// inbound iMessage and returns Beeper link previews. Follows the pattern +// from mautrix-whatsapp's urlpreview.go. +func convertURLPreviewToBeeper(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *rustpushgo.WrappedMessage, bodyText string) []*event.BeeperLinkPreview { + log := zerolog.Ctx(ctx) + + // Find sideband attachments encoded by Rust + var rlMeta, rlImage *rustpushgo.WrappedAttachment + for i := range msg.Attachments { + switch msg.Attachments[i].MimeType { + case "x-richlink/meta": + rlMeta = &msg.Attachments[i] + case "x-richlink/image": + rlImage = &msg.Attachments[i] + } + } + + if rlMeta != nil && rlMeta.InlineData != nil { + fields := bytes.SplitN(*rlMeta.InlineData, []byte{0x01}, 5) + originalURL := string(fields[0]) + canonicalURL := originalURL + if len(fields) > 1 && len(fields[1]) > 0 { + canonicalURL = string(fields[1]) + } + title := "" + if len(fields) > 2 && len(fields[2]) > 0 { + title = string(fields[2]) + } + description := "" + if len(fields) > 3 && len(fields[3]) > 0 { + description = string(fields[3]) + } + imageMime := "" + if len(fields) > 4 && len(fields[4]) > 0 { + imageMime = string(fields[4]) + } + + log.Debug(). + Str("original_url", originalURL). + Str("canonical_url", canonicalURL). + Str("title", title). + Str("description", description). + Str("image_mime", imageMime). + Msg("Parsed rich link sideband data from iMessage") + + // MatchedURL must exactly match a URL in the body text so Beeper + // can associate the preview with the inline URL. Use regex to find + // the URL in the body rather than trusting the NSURL-converted value. + matchedURL := originalURL + if bodyURL := urlRegex.FindString(bodyText); bodyURL != "" { + matchedURL = bodyURL + } + + preview := &event.BeeperLinkPreview{ + MatchedURL: matchedURL, + LinkPreview: event.LinkPreview{ + CanonicalURL: canonicalURL, + Title: title, + Description: description, + }, + } + + // Upload preview image if available + if rlImage != nil && rlImage.InlineData != nil && intent != nil { + if imageMime == "" { + imageMime = "image/jpeg" + } + log.Debug().Int("image_bytes", len(*rlImage.InlineData)).Str("mime", imageMime).Msg("Uploading rich link preview image") + url, encFile, err := intent.UploadMedia(ctx, portal.MXID, *rlImage.InlineData, "preview", imageMime) + if err == nil { + if encFile != nil { + preview.ImageEncryption = encFile + preview.ImageURL = encFile.URL + } else { + preview.ImageURL = url + } + preview.ImageType = imageMime + } else { + log.Warn().Err(err).Msg("Failed to upload rich link preview image") + } + } + + log.Debug().Str("matched_url", matchedURL).Str("title", title).Msg("Inbound rich link preview ready") + return []*event.BeeperLinkPreview{preview} + } + + // No rich link from iMessage — auto-detect URL and fetch og: metadata + image + if detectedURL := urlRegex.FindString(bodyText); detectedURL != "" { + log.Debug().Str("detected_url", detectedURL).Msg("No iMessage rich link, fetching URL preview") + return []*event.BeeperLinkPreview{fetchURLPreview(ctx, portal.Bridge, intent, portal.MXID, detectedURL)} + } + + return nil +} + +func convertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *rustpushgo.WrappedMessage) (*bridgev2.ConvertedMessage, error) { + text := strings.TrimSpace(strings.ReplaceAll(ptrStringOr(msg.Text, ""), "\uFFFC", "")) + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: text, + } + if msg.Subject != nil && *msg.Subject != "" { + if text != "" { + content.Body = fmt.Sprintf("**%s**\n%s", *msg.Subject, text) + content.Format = event.FormatHTML + content.FormattedBody = fmt.Sprintf("%s
%s", *msg.Subject, text) + } else { + content.Body = *msg.Subject + } + } + + content.BeeperLinkPreviews = convertURLPreviewToBeeper(ctx, portal, intent, msg, text) + + cm := &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: content, + }}, + } + + if msg.ReplyGuid != nil && *msg.ReplyGuid != "" { + bp := 0 + if msg.ReplyPart != nil { + bp = parseBalloonPart(*msg.ReplyPart, "%d:") + } + cm.ReplyTo = chatDBReplyTarget(*msg.ReplyGuid, bp) + } + + return cm, nil +} + +func isVCardAttachment(mimeType, fileName, utiType string) bool { + if strings.EqualFold(utiType, "public.vcard") { + return true + } + if strings.EqualFold(mimeType, "text/vcard") || + strings.EqualFold(mimeType, "text/x-vcard") || + strings.EqualFold(mimeType, "text/directory") { + return true + } + return strings.HasSuffix(strings.ToLower(fileName), ".vcf") +} + +func makeVCardPreviewContent(data []byte) *event.MessageEventContent { + contact := parseVCard(string(data)) + if contact == nil { + return nil + } + name := strings.TrimSpace(contact.Name()) + if name == "" && len(contact.Phones) == 0 && len(contact.Emails) == 0 { + return nil + } + + bodyLines := []string{"Shared contact"} + htmlLines := []string{"Shared contact"} + if name != "" { + bodyLines = append(bodyLines, name) + htmlLines = append(htmlLines, html.EscapeString(name)) + } + for i, phone := range contact.Phones { + if i >= 3 { + break + } + phone = strings.TrimSpace(phone) + if phone == "" { + continue + } + bodyLines = append(bodyLines, "Phone: "+phone) + htmlLines = append(htmlLines, "Phone: "+html.EscapeString(phone)) + } + for i, email := range contact.Emails { + if i >= 3 { + break + } + email = strings.TrimSpace(email) + if email == "" { + continue + } + bodyLines = append(bodyLines, "Email: "+email) + htmlLines = append(htmlLines, "Email: "+html.EscapeString(email)) + } + + return &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: strings.Join(bodyLines, "\n"), + Format: event.FormatHTML, + FormattedBody: strings.Join(htmlLines, "
"), + Mentions: &event.Mentions{}, + } +} + +func convertAttachment(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, attMsg *attachmentMessage, videoTranscoding, heicConversion bool, heicQuality int) (*bridgev2.ConvertedMessage, error) { + att := attMsg.Attachment + mimeType := att.MimeType + fileName := att.Filename + var durationMs int + + // Convert CAF Opus voice messages to OGG Opus for Matrix clients + var inlineData []byte + zerolog.Ctx(ctx).Debug().Bool("is_inline", att.IsInline).Bool("has_data", att.InlineData != nil).Str("mime", mimeType).Str("file", fileName).Uint64("size", att.Size).Msg("convertAttachment called") + if att.IsInline && att.InlineData != nil { + inlineData = *att.InlineData + if att.UtiType == "com.apple.coreaudio-format" || mimeType == "audio/x-caf" { + inlineData, mimeType, fileName, durationMs = convertAudioForMatrix(inlineData, mimeType, fileName) + } + } + + var vcardPreview *event.MessageEventContent + if inlineData != nil && isVCardAttachment(mimeType, fileName, att.UtiType) { + vcardPreview = makeVCardPreviewContent(inlineData) + } + + // Remux/transcode non-MP4 videos to MP4 for broad Matrix client compatibility. + if inlineData != nil && videoTranscoding && ffmpeg.Supported() && strings.HasPrefix(mimeType, "video/") && mimeType != "video/mp4" { + log := zerolog.Ctx(ctx) + origMime := mimeType + origSize := len(inlineData) + method := "remux" + converted, convertErr := ffmpeg.ConvertBytes(ctx, inlineData, ".mp4", nil, + []string{"-c", "copy", "-movflags", "+faststart"}, + mimeType) + if convertErr != nil { + // Remux failed — try full re-encode + method = "re-encode" + converted, convertErr = ffmpeg.ConvertBytes(ctx, inlineData, ".mp4", nil, + []string{"-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-movflags", "+faststart"}, + mimeType) + } + if convertErr != nil { + log.Warn().Err(convertErr).Str("original_mime", origMime). + Msg("FFmpeg video conversion failed, uploading original") + } else { + log.Info().Str("original_mime", origMime). + Str("method", method).Int("original_bytes", origSize).Int("converted_bytes", len(converted)). + Msg("Video transcoded to MP4") + inlineData = converted + mimeType = "video/mp4" + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".mp4" + } + } + + // Convert HEIC/HEIF images to JPEG since most Matrix clients can't display HEIC. + var heicImg image.Image + if inlineData != nil { + log := zerolog.Ctx(ctx) + inlineData, mimeType, fileName, heicImg = maybeConvertHEIC(log, inlineData, mimeType, fileName, heicQuality, heicConversion) + } + + // Process images: extract dimensions, convert non-JPEG to JPEG, generate thumbnail + var imgWidth, imgHeight int + var thumbData []byte + var thumbW, thumbH int + if heicImg != nil { + // Use the already-decoded image from HEIC conversion + b := heicImg.Bounds() + imgWidth, imgHeight = b.Dx(), b.Dy() + if imgWidth > 800 || imgHeight > 800 { + thumbData, thumbW, thumbH = scaleAndEncodeThumb(heicImg, imgWidth, imgHeight) + } + } else if inlineData != nil && (strings.HasPrefix(mimeType, "image/") || looksLikeImage(inlineData)) { + log := zerolog.Ctx(ctx) + log.Debug().Str("mime_type", mimeType).Str("file_name", fileName).Int("data_len", len(inlineData)).Msg("Processing image attachment") + if mimeType == "image/gif" { + cfg, _, err := image.DecodeConfig(bytes.NewReader(inlineData)) + if err == nil { + imgWidth, imgHeight = cfg.Width, cfg.Height + } + } else if img, fmtName, _ := decodeImageData(inlineData); img != nil { + b := img.Bounds() + imgWidth, imgHeight = b.Dx(), b.Dy() + log.Debug().Str("decoded_format", fmtName).Int("width", imgWidth).Int("height", imgHeight).Msg("Image decoded successfully") + // Re-encode TIFF as JPEG for compatibility (PNG is fine as-is) + if fmtName == "tiff" { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err == nil { + inlineData = buf.Bytes() + mimeType = "image/jpeg" + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".jpg" + log.Debug().Int("jpeg_size", len(inlineData)).Msg("Re-encoded TIFF as JPEG") + } else { + log.Warn().Err(err).Msg("Failed to re-encode TIFF as JPEG") + } + } + if imgWidth > 800 || imgHeight > 800 { + thumbData, thumbW, thumbH = scaleAndEncodeThumb(img, imgWidth, imgHeight) + } + } else { + log.Warn().Str("mime_type", mimeType).Msg("Failed to decode image data") + // Log first few bytes for debugging + if len(inlineData) >= 4 { + log.Debug().Hex("magic_bytes", inlineData[:4]).Msg("Image magic bytes") + } + } + } + + msgType := mimeToMsgType(mimeType) + + fileSize := int(att.Size) + if inlineData != nil { + fileSize = len(inlineData) + } + content := &event.MessageEventContent{ + MsgType: msgType, + Body: fileName, + Info: &event.FileInfo{ + MimeType: mimeType, + Size: fileSize, + Width: imgWidth, + Height: imgHeight, + }, + } + + // Mark as voice message if this was a CAF voice recording + if durationMs > 0 { + content.MSC3245Voice = &event.MSC3245Voice{} + content.MSC1767Audio = &event.MSC1767Audio{ + Duration: durationMs, + } + content.Info.Size = len(inlineData) + } + + if inlineData != nil && intent != nil { + url, encFile, err := intent.UploadMedia(ctx, "", inlineData, fileName, mimeType) + if err != nil { + return nil, fmt.Errorf("failed to upload attachment: %w", err) + } + if encFile != nil { + content.File = encFile + } else { + content.URL = url + } + + // Upload image thumbnail + if thumbData != nil { + thumbURL, thumbEnc, err := intent.UploadMedia(ctx, "", thumbData, "thumbnail.jpg", "image/jpeg") + if err == nil { + if thumbEnc != nil { + content.Info.ThumbnailFile = thumbEnc + } else { + content.Info.ThumbnailURL = thumbURL + } + content.Info.ThumbnailInfo = &event.FileInfo{ + MimeType: "image/jpeg", + Size: len(thumbData), + Width: thumbW, + Height: thumbH, + } + } else { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to upload image thumbnail") + } + } + } + + parts := make([]*bridgev2.ConvertedMessagePart, 0, 2) + if vcardPreview != nil { + parts = append(parts, &bridgev2.ConvertedMessagePart{ + ID: networkid.PartID(fmt.Sprintf("att%d-preview", attMsg.Index)), + Type: event.EventMessage, + Content: vcardPreview, + }) + } + parts = append(parts, &bridgev2.ConvertedMessagePart{ + ID: networkid.PartID(fmt.Sprintf("att%d", attMsg.Index)), + Type: event.EventMessage, + Content: content, + }) + + cm := &bridgev2.ConvertedMessage{ + Parts: parts, + } + + if attMsg.WrappedMessage.ReplyGuid != nil && *attMsg.WrappedMessage.ReplyGuid != "" { + bp := 0 + if attMsg.WrappedMessage.ReplyPart != nil { + bp = parseBalloonPart(*attMsg.WrappedMessage.ReplyPart, "%d:") + } + cm.ReplyTo = chatDBReplyTarget(*attMsg.WrappedMessage.ReplyGuid, bp) + } + + return cm, nil +} + +// resolveTapbackTargetID constructs the message ID for a tapback target, +// handling backward compatibility with messages stored before part-targeting +// was introduced. For bp >= 1 it tries the suffixed ID (e.g. "uuid_att0") +// first; if that message doesn't exist in the bridge DB it falls back to the +// bare UUID, which is how pre-existing messages were stored. +func (c *IMClient) resolveTapbackTargetID(targetGUID string, bp int) networkid.MessageID { + if bp >= 1 { + suffixed := fmt.Sprintf("%s_att%d", targetGUID, bp-1) + suffixedID := makeMessageID(suffixed) + msg, err := c.Main.Bridge.DB.Message.GetFirstPartByID( + context.Background(), c.UserLogin.ID, suffixedID, + ) + if err == nil && msg != nil { + return suffixedID + } + // Suffixed ID not found — fall back to bare UUID for old messages. + } + return makeMessageID(targetGUID) +} + +// ============================================================================ +// Static helpers +// ============================================================================ + +// parseBalloonPart extracts an integer balloon-part index from s using the +// given fmt.Sscanf format string. Returns 0 if s is empty or parsing fails. +func parseBalloonPart(s, format string) int { + var bp int + fmt.Sscanf(s, format, &bp) + return bp +} + +// extractTapbackTarget splits a message ID that may contain an _attN suffix into +// the bare UUID and the iMessage tapback part index (0 = text body, ≥1 = attachment). +// The _attN index is 0-based (att0 = first attachment = part 1 in iMessage). +func extractTapbackTarget(messageID string) (string, uint64) { + if idx := strings.Index(messageID, "_att"); idx > 0 { + attIndex := parseBalloonPart(messageID[idx+4:], "%d") + return messageID[:idx], uint64(attIndex) + 1 + } + return messageID, 0 +} + +// extractReplyInfo converts a bridgev2 reply-to database message into the +// iMessage reply_guid and reply_part strings expected by rustpush. +// reply_guid is the message UUID; reply_part uses the iMessage format "bp:type:length". +// We don't have the original text length, so we use 0 as a placeholder. +func extractReplyInfo(replyTo *database.Message) (*string, *string) { + if replyTo == nil { + return nil, nil + } + guid := string(replyTo.ID) + // Strip attachment suffixes like _att0, _att1 — iMessage expects a pure UUID, + // but we derive the balloon-part index from the suffix to set reply_part correctly. + bp := 0 + if idx := strings.Index(guid, "_att"); idx > 0 { + bp = parseBalloonPart(guid[idx+4:], "%d") + 1 + guid = guid[:idx] + } + // iMessage thread_originator_part format is "bp:type:length" where: + // bp = balloon part index (0 for text body, ≥1 for attachments) + // type = part type (0 for text) + // length = character count of the original message text + // We use 0 as the length since we don't have the original text available. + part := fmt.Sprintf("%d:0:0", bp) + return &guid, &part +} + +// scaleAndEncodeThumb generates a JPEG thumbnail capped at 800px on the +// longest side using nearest-neighbor scaling (no external dependencies). +func scaleAndEncodeThumb(img image.Image, origW, origH int) ([]byte, int, int) { + scale := min(800.0/float64(origW), 800.0/float64(origH)) + thumbW := int(float64(origW) * scale) + thumbH := int(float64(origH) * scale) + if thumbW < 1 { + thumbW = 1 + } + if thumbH < 1 { + thumbH = 1 + } + + srcBounds := img.Bounds() + dst := image.NewRGBA(image.Rect(0, 0, thumbW, thumbH)) + for y := range thumbH { + srcY := srcBounds.Min.Y + y*srcBounds.Dy()/thumbH + for x := range thumbW { + srcX := srcBounds.Min.X + x*srcBounds.Dx()/thumbW + dst.Set(x, y, img.At(srcX, srcY)) + } + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 75}); err != nil { + return nil, 0, 0 + } + return buf.Bytes(), thumbW, thumbH +} + +// detectImageMIME returns the correct MIME type based on magic bytes. +func detectImageMIME(data []byte) string { + if len(data) < 8 { + return "" + } + if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return "image/jpeg" + } + if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + return "image/png" + } + if string(data[:4]) == "GIF8" { + return "image/gif" + } + if (data[0] == 'I' && data[1] == 'I' && data[2] == 0x2a && data[3] == 0x00) || + (data[0] == 'M' && data[1] == 'M' && data[2] == 0x00 && data[3] == 0x2a) { + return "image/tiff" + } + return "" +} + +// looksLikeImage checks magic bytes to detect images even when MIME type is wrong. +func looksLikeImage(data []byte) bool { + if len(data) < 8 { + return false + } + // JPEG: FF D8 FF + if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return true + } + // PNG: 89 50 4E 47 + if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + return true + } + // GIF: GIF8 + if string(data[:4]) == "GIF8" { + return true + } + // TIFF: II*\0 or MM\0* + if (data[0] == 'I' && data[1] == 'I' && data[2] == 0x2a && data[3] == 0x00) || + (data[0] == 'M' && data[1] == 'M' && data[2] == 0x00 && data[3] == 0x2a) { + return true + } + return false +} + +// decodeImageData tries to decode image bytes using stdlib decoders (PNG, +// JPEG, GIF) and falls back to a minimal TIFF parser. Returns the decoded +// image, detected format name, and whether the data is already JPEG (so +// callers can skip re-encoding). +func decodeImageData(data []byte) (image.Image, string, bool) { + // Handles PNG, JPEG, GIF (stdlib) and TIFF (golang.org/x/image/tiff) + if img, fmtName, err := image.Decode(bytes.NewReader(data)); err == nil { + return img, fmtName, fmtName == "jpeg" + } + return nil, "", false +} + +func tapbackTypeToEmoji(tapbackType *uint32, tapbackEmoji *string) string { + if tapbackType == nil { + return "❤️" + } + switch *tapbackType { + case 0: + return "❤️" + case 1: + return "👍" + case 2: + return "👎" + case 3: + return "😂" + case 4: + return "‼️" + case 5: + return "❓" + case 6: + if tapbackEmoji != nil { + return *tapbackEmoji + } + return "👍" + default: + return "❤️" + } +} + +func emojiToTapbackType(emoji string) (uint32, *string) { + switch emoji { + case "❤️", "♥️": + return 0, nil + case "👍": + return 1, nil + case "👎": + return 2, nil + case "😂": + return 3, nil + case "❗", "‼️": + return 4, nil + case "❓": + return 5, nil + default: + return 6, &emoji + } +} + +// formatSMSReactionText returns the SMS/RCS reaction text for a tapback with an +// empty quoted body (when the original message text is not available). The result +// matches Apple's SMS relay format exactly so the iPhone can thread it correctly. +func formatSMSReactionText(tapbackType uint32, customEmoji *string, isRemove bool) string { + return formatSMSReactionTextWithBody(tapbackType, customEmoji, "", isRemove) +} + +// formatSMSReactionTextWithBody returns the SMS/RCS reaction text with the +// original message body quoted inside the curly-quote delimiters. +// Mirrors the Rust ReactMessageType::get_text() output so SMS contacts see the +// same reaction text they would receive from a native iMessage client. +func formatSMSReactionTextWithBody(tapbackType uint32, customEmoji *string, body string, isRemove bool) string { + var word string + if isRemove { + switch tapbackType { + case 0: + word = "Removed a heart from" + case 1: + word = "Removed a like from" + case 2: + word = "Removed a dislike from" + case 3: + word = "Removed a laugh from" + case 4: + word = "Removed an exclamation from" + case 5: + word = "Removed a question mark from" + case 6: + if customEmoji != nil { + word = "Removed a " + *customEmoji + " from" + } else { + word = "Removed a like from" + } + default: + word = "Removed a heart from" + } + return word + " \u201c" + body + "\u201d" + } + switch tapbackType { + case 0: + word = "Loved" + case 1: + word = "Liked" + case 2: + word = "Disliked" + case 3: + word = "Laughed at" + case 4: + word = "Emphasized" + case 5: + word = "Questioned" + case 6: + if customEmoji != nil { + return "Reacted " + *customEmoji + " to \u201c" + body + "\u201d" + } + word = "Liked" + default: + word = "Loved" + } + return word + " \u201c" + body + "\u201d" +} + +func mimeToUTI(mime string) string { + switch { + case mime == "image/jpeg": + return "public.jpeg" + case mime == "image/png": + return "public.png" + case mime == "image/gif": + return "com.compuserve.gif" + case mime == "image/heic": + return "public.heic" + case mime == "video/mp4": + return "public.mpeg-4" + case mime == "video/quicktime": + return "com.apple.quicktime-movie" + case mime == "audio/mpeg", mime == "audio/mp3": + return "public.mp3" + case mime == "audio/aac", mime == "audio/mp4": + return "public.aac-audio" + case mime == "audio/x-caf": + return "com.apple.coreaudio-format" + case mime == "text/vcard", mime == "text/x-vcard", mime == "text/directory": + return "public.vcard" + case strings.HasPrefix(mime, "image/"): + return "public.image" + case strings.HasPrefix(mime, "video/"): + return "public.movie" + case strings.HasPrefix(mime, "audio/"): + return "public.audio" + default: + return "public.data" + } +} + +// utiToMIME converts an Apple UTI type to its MIME equivalent. +// Used as a fallback when CloudKit attachment records have a UTI but no MIME type. +func utiToMIME(uti string) string { + switch uti { + case "public.jpeg": + return "image/jpeg" + case "public.png": + return "image/png" + case "com.compuserve.gif": + return "image/gif" + case "public.tiff": + return "image/tiff" + case "public.heic": + return "image/heic" + case "public.heif": + return "image/heif" + case "public.webp": + return "image/webp" + case "public.mpeg-4": + return "video/mp4" + case "com.apple.quicktime-movie": + return "video/quicktime" + case "public.mp3": + return "audio/mpeg" + case "public.aac-audio": + return "audio/aac" + case "com.apple.coreaudio-format": + return "audio/x-caf" + case "public.vcard": + return "text/vcard" + default: + return "" + } +} + +func mimeToMsgType(mime string) event.MessageType { + switch { + case strings.HasPrefix(mime, "image/"): + return event.MsgImage + case strings.HasPrefix(mime, "video/"): + return event.MsgVideo + case strings.HasPrefix(mime, "audio/"): + return event.MsgAudio + default: + return event.MsgFile + } +} + +func (c *IMClient) updatePortalSMS(portalID string, isSms bool) bool { + c.smsPortalsLock.Lock() + defer c.smsPortalsLock.Unlock() + prev, existed := c.smsPortals[portalID] + c.smsPortals[portalID] = isSms + return !existed || prev != isSms +} + +func (c *IMClient) isPortalSMS(portalID string) bool { + c.smsPortalsLock.RLock() + defer c.smsPortalsLock.RUnlock() + if val, ok := c.smsPortals[portalID]; ok { + return val + } + // Fallback for legacy portals that still have the raw suffix in their ID + // (pre-fix DB entries that survive without a full reset). Such portals can + // never transition to iMessage — their IDs are malformed by definition. + return portalID != stripSmsSuffix(portalID) +} + +func (c *IMClient) trackUnsend(uuid string) { + c.recentUnsendsLock.Lock() + defer c.recentUnsendsLock.Unlock() + c.recentUnsends[uuid] = time.Now() + for k, t := range c.recentUnsends { + if time.Since(t) > 5*time.Minute { + delete(c.recentUnsends, k) + } + } +} + +func (c *IMClient) wasUnsent(uuid string) bool { + c.recentUnsendsLock.Lock() + defer c.recentUnsendsLock.Unlock() + if t, ok := c.recentUnsends[uuid]; ok { + return time.Since(t) < 5*time.Minute + } + return false +} + +func (c *IMClient) trackSmsReactionEcho(uuid string) { + if uuid == "" { + return + } + c.recentSmsReactionEchoesLock.Lock() + defer c.recentSmsReactionEchoesLock.Unlock() + c.recentSmsReactionEchoes[strings.ToUpper(uuid)] = time.Now() + for k, t := range c.recentSmsReactionEchoes { + if time.Since(t) > 5*time.Minute { + delete(c.recentSmsReactionEchoes, k) + } + } +} + +func (c *IMClient) wasSmsReactionEcho(uuid string) bool { + if uuid == "" { + return false + } + c.recentSmsReactionEchoesLock.Lock() + defer c.recentSmsReactionEchoesLock.Unlock() + key := strings.ToUpper(uuid) + if t, ok := c.recentSmsReactionEchoes[key]; ok { + delete(c.recentSmsReactionEchoes, key) + return time.Since(t) < 5*time.Minute + } + return false +} + +// resolvePortalByTargetMessage looks up a message by UUID in the bridge database +// and returns the portal key it belongs to. This is critical for unsends and edits +// that arrive as self-reflections from the user's other Apple devices: the APNs +// envelope has participants=[self, self], so makePortalKey can't determine the +// correct DM portal. Returns an empty PortalKey if not found. +func (c *IMClient) resolvePortalByTargetMessage(log zerolog.Logger, targetUUID string) networkid.PortalKey { + if targetUUID == "" { + return networkid.PortalKey{} + } + msgID := makeMessageID(targetUUID) + dbMessages, err := c.Main.Bridge.DB.Message.GetAllPartsByID( + context.Background(), c.UserLogin.ID, msgID) + if err != nil || len(dbMessages) == 0 { + return networkid.PortalKey{} + } + log.Debug(). + Str("target_uuid", targetUUID). + Str("resolved_portal", string(dbMessages[0].Room.ID)). + Msg("Resolved portal via target message UUID lookup") + return dbMessages[0].Room +} + +func (c *IMClient) trackOutboundUnsend(uuid string) { + c.recentOutboundUnsendsLock.Lock() + defer c.recentOutboundUnsendsLock.Unlock() + c.recentOutboundUnsends[uuid] = time.Now() + for k, t := range c.recentOutboundUnsends { + if time.Since(t) > 5*time.Minute { + delete(c.recentOutboundUnsends, k) + } + } +} + +func (c *IMClient) wasOutboundUnsend(uuid string) bool { + c.recentOutboundUnsendsLock.Lock() + defer c.recentOutboundUnsendsLock.Unlock() + if t, ok := c.recentOutboundUnsends[uuid]; ok { + if time.Since(t) < 5*time.Minute { + delete(c.recentOutboundUnsends, uuid) + return true + } + delete(c.recentOutboundUnsends, uuid) + } + return false +} + +// urlRegex matches URLs in message text for rich link matching. +// Matches explicit schemes (https://...) and bare domains (example.com, example.com/path). +var urlRegex = regexp.MustCompile(`(?:https?://\S+|(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:/\S*)?)`) + +// isLikelyURL validates that a regex-matched string is actually a URL and not +// a filename or other dotted identifier. Uses url.Parse for validation, +// mirroring mautrix-whatsapp's approach. Bare domains (google.com, www.example.com) +// are accepted as long as they parse as valid URLs with a real host. +func isLikelyURL(s string) bool { + normalized := s + if !strings.HasPrefix(normalized, "http://") && !strings.HasPrefix(normalized, "https://") { + normalized = "https://" + normalized + } + parsed, err := url.Parse(normalized) + if err != nil { + return false + } + host := parsed.Hostname() + if host == "" { + return false + } + // Must have at least one dot (rules out bare words). + dot := strings.LastIndex(host, ".") + if dot < 0 { + return false + } + tld := strings.ToLower(host[dot+1:]) + if tld == "" { + return false + } + // Reject common file extensions that the greedy bare-domain regex matches + // (e.g. "config.yaml", "main.go", "notes.txt"). Only applied when the + // original string had no scheme — explicit http(s):// URLs are always trusted. + if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") && parsed.Path == "" { + switch tld { + case "go", "rs", "py", "rb", "js", "ts", "tsx", "jsx", "sh", "bash", "zsh", + "c", "h", "cc", "cpp", "hpp", "cs", "java", "kt", "scala", "swift", + "m", "mm", "pl", "pm", "r", "lua", "zig", "v", "d", "ex", "exs", + "md", "txt", "log", "csv", "tsv", "diff", "patch", + "json", "yaml", "yml", "toml", "xml", "ini", "cfg", "conf", + "html", "css", "scss", "less", "sass", + "png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico", "tiff", + "mp3", "mp4", "wav", "flac", "ogg", "avi", "mkv", "mov", "webm", + "zip", "tar", "gz", "bz2", "xz", "rar", "7z", + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", + "lock", "sum", "mod", "bak", "tmp", "swp", "o", "so", "dylib", "a", + "wasm", "bin", "exe", "dll", "dmg", "iso", "img", "apk", "ipa": + return false + } + } + return true +} + +// normalizeURL ensures a URL has a scheme for HTTP fetching. +func normalizeURL(u string) string { + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + return "https://" + u + } + return u +} + +func ptrStringOr(s *string, def string) string { + if s != nil { + return *s + } + return def +} + +func ptrUint64Or(v *uint64, def uint64) uint64 { + if v != nil { + return *v + } + return def +} + +// ============================================================================ +// Chat.db initial sync +// ============================================================================ + +// runChatDBInitialSync creates portals and backfills messages for all recent +// chats found in chat.db. Runs once on first login, then marks ChatsSynced. +func (c *IMClient) runChatDBInitialSync(log zerolog.Logger) { + ctx := log.WithContext(context.Background()) + meta := c.UserLogin.Metadata.(*UserLoginMetadata) + if meta.ChatsSynced { + log.Info().Msg("Initial sync already completed, skipping") + return + } + + chats, err := c.chatDB.api.GetChatsWithMessagesAfter(time.Time{}) + if err != nil { + log.Err(err).Msg("Failed to get chat list for initial sync") + return + } + + type chatEntry struct { + chatGUID string + portalKey networkid.PortalKey + info *imessage.ChatInfo + isSms bool + } + var entries []chatEntry + for _, chat := range chats { + info, err := c.chatDB.api.GetChatInfo(chat.ChatGUID, chat.ThreadID) + if err != nil || info == nil { + continue + } + parsed := imessage.ParseIdentifier(chat.ChatGUID) + var portalKey networkid.PortalKey + isSms := parsed.Service == "SMS" + if parsed.IsGroup { + members := make([]string, 0, len(info.Members)+1) + members = append(members, addIdentifierPrefix(c.handle)) + for _, m := range info.Members { + members = append(members, addIdentifierPrefix(stripSmsSuffix(m))) + } + sort.Strings(members) + portalKey = networkid.PortalKey{ + ID: networkid.PortalID(strings.Join(members, ",")), + Receiver: c.UserLogin.ID, + } + } else { + portalKey = networkid.PortalKey{ + ID: identifierToPortalID(parsed), + Receiver: c.UserLogin.ID, + } + } + if isSms { + c.updatePortalSMS(string(portalKey.ID), true) + } + entries = append(entries, chatEntry{ + chatGUID: chat.ChatGUID, + portalKey: portalKey, + info: info, + isSms: isSms, + }) + } + + // Deduplicate DM entries for contacts with multiple phone numbers. + { + type contactGroup struct { + indices []int + } + groups := make(map[string]*contactGroup) + for i, entry := range entries { + portalID := string(entry.portalKey.ID) + if strings.Contains(portalID, ",") { + continue + } + contact := c.lookupContact(portalID) + key := contactKeyFromContact(contact) + if key == "" { + continue + } + if g, ok := groups[key]; ok { + g.indices = append(g.indices, i) + } else { + groups[key] = &contactGroup{indices: []int{i}} + } + } + + skip := make(map[int]bool) + for _, group := range groups { + if len(group.indices) <= 1 { + continue + } + primaryIdx := group.indices[0] + for _, idx := range group.indices[1:] { + skip[idx] = true + log.Info(). + Str("skip_portal", string(entries[idx].portalKey.ID)). + Str("primary_portal", string(entries[primaryIdx].portalKey.ID)). + Msg("Merging DM portal for contact with multiple phone numbers") + } + } + + if len(skip) > 0 { + var merged []chatEntry + for i, entry := range entries { + if !skip[i] { + merged = append(merged, entry) + } + } + log.Info().Int("before", len(entries)).Int("after", len(merged)).Msg("Deduplicated DM entries by contact") + entries = merged + } + } + + // Reverse to process oldest-activity first, so the most recent chat + // gets the highest stream_ordering. + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + + log.Info(). + Int("chat_count", len(entries)). + Msg("Initial sync: processing chats sequentially (oldest activity first)") + + synced := 0 + for _, entry := range entries { + done := make(chan struct{}) + chatInfo := c.chatDBInfoToBridgev2(entry.info) + chatGUID := entry.chatGUID + isSms := entry.isSms + c.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + PortalKey: entry.portalKey, + CreatePortal: true, + PostHandleFunc: func(ctx context.Context, portal *bridgev2.Portal) { + if isSms { + meta := &PortalMetadata{} + if existing, ok := portal.Metadata.(*PortalMetadata); ok { + *meta = *existing + } + if !meta.IsSms { + meta.IsSms = true + portal.Metadata = meta + if err := portal.Save(ctx); err != nil { + zerolog.Ctx(ctx).Warn().Err(err). + Str("portal_id", string(portal.ID)). + Msg("Failed to persist IsSms metadata during initial sync") + } + } + } + close(done) + }, + LogContext: func(lc zerolog.Context) zerolog.Context { + return lc.Str("chat_guid", chatGUID).Str("source", "initial_sync") + }, + }, + ChatInfo: chatInfo, + LatestMessageTS: time.Now(), + }) + + select { + case <-done: + synced++ + if synced%10 == 0 || synced == len(entries) { + log.Info(). + Int("progress", synced). + Int("total", len(entries)). + Msg("Initial sync progress") + } + case <-time.After(30 * time.Minute): + synced++ + log.Warn(). + Str("chat_guid", entry.chatGUID). + Msg("Initial sync: timeout waiting for chat, continuing") + case <-c.stopChan: + log.Info().Msg("Initial sync stopped") + return + } + } + + meta.ChatsSynced = true + if err := c.UserLogin.Save(ctx); err != nil { + log.Err(err).Msg("Failed to save metadata after initial sync") + } + log.Info(). + Int("synced_chats", synced). + Int("total_chats", len(entries)). + Msg("Initial sync complete") +} + +// chatDBInfoToBridgev2 converts a chat.db ChatInfo to a bridgev2 ChatInfo. +func (c *IMClient) chatDBInfoToBridgev2(info *imessage.ChatInfo) *bridgev2.ChatInfo { + parsed := imessage.ParseIdentifier(info.JSONChatGUID) + if parsed.LocalID == "" { + parsed = info.Identifier + } + + chatInfo := &bridgev2.ChatInfo{ + CanBackfill: true, + } + + if parsed.IsGroup { + displayName := info.DisplayName + if displayName == "" { + displayName = c.buildGroupName(info.Members) + } + chatInfo.Name = &displayName + } + + if parsed.IsGroup { + chatInfo.Type = ptr.Ptr(database.RoomTypeDefault) + members := &bridgev2.ChatMemberList{ + IsFull: true, + MemberMap: make(map[networkid.UserID]bridgev2.ChatMember), + } + members.MemberMap[makeUserID(c.handle)] = bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: makeUserID(c.handle), + }, + Membership: event.MembershipJoin, + } + for _, memberID := range info.Members { + userID := makeUserID(addIdentifierPrefix(stripSmsSuffix(memberID))) + members.MemberMap[userID] = bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{Sender: userID}, + Membership: event.MembershipJoin, + } + } + chatInfo.Members = members + } else { + chatInfo.Type = ptr.Ptr(database.RoomTypeDM) + portalID := addIdentifierPrefix(stripSmsSuffix(parsed.LocalID)) + otherUser := makeUserID(portalID) + isSelfChat := c.isMyHandle(portalID) + + memberMap := map[networkid.UserID]bridgev2.ChatMember{ + makeUserID(c.handle): { + EventSender: bridgev2.EventSender{ + IsFromMe: true, + SenderLogin: c.UserLogin.ID, + Sender: makeUserID(c.handle), + }, + Membership: event.MembershipJoin, + }, + } + // Only add the other user if it's not a self-chat, to avoid + // overwriting the IsFromMe entry with a duplicate map key. + if !isSelfChat { + memberMap[otherUser] = bridgev2.ChatMember{ + EventSender: bridgev2.EventSender{Sender: otherUser}, + Membership: event.MembershipJoin, + } + } + + members := &bridgev2.ChatMemberList{ + IsFull: true, + OtherUserID: otherUser, + MemberMap: memberMap, + } + + // For self-chats, set an explicit name and avatar from contacts since + // the framework can't derive them from the ghost when the "other user" + // is the logged-in user. Setting Name causes NameIsCustom=true in the + // framework, which blocks UpdateInfoFromGhost (it returns early when + // NameIsCustom is set), so we must also set the avatar explicitly here. + if isSelfChat { + selfName := c.resolveContactDisplayname(portalID) + chatInfo.Name = &selfName + + // Pull contact photo for self-chat room avatar. + localID := stripIdentifierPrefix(portalID) + if c.contacts != nil { + if contact, _ := c.contacts.GetContactInfo(localID); contact != nil && len(contact.Avatar) > 0 { + avatarHash := sha256.Sum256(contact.Avatar) + avatarData := contact.Avatar + chatInfo.Avatar = &bridgev2.Avatar{ + ID: networkid.AvatarID(fmt.Sprintf("contact:%s:%s", portalID, hex.EncodeToString(avatarHash[:8]))), + Get: func(ctx context.Context) ([]byte, error) { + return avatarData, nil + }, + } + } + } + } + + chatInfo.Members = members + } + + return chatInfo +} diff --git a/pkg/connector/cloud_backfill_store.go b/pkg/connector/cloud_backfill_store.go new file mode 100644 index 00000000..7164a606 --- /dev/null +++ b/pkg/connector/cloud_backfill_store.go @@ -0,0 +1,3101 @@ +package connector + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + "maunium.net/go/mautrix/bridgev2/networkid" +) + +type cloudBackfillStore struct { + db *dbutil.Database + loginID networkid.UserLoginID +} + +type cloudMessageRow struct { + GUID string + RecordName string + CloudChatID string + PortalID string + TimestampMS int64 + Sender string + IsFromMe bool + Text string + Subject string + Service string + Deleted bool + + // Tapback/reaction fields + TapbackType *uint32 + TapbackTargetGUID string + TapbackEmoji string + + // Attachment metadata JSON (serialized []cloudAttachmentRow) + AttachmentsJSON string + + // When the recipient read this message (Unix ms). Only for is_from_me messages. + DateReadMS int64 + + // Whether the CloudKit record has an attributedBody (rich text payload). + // Regular user messages always have this; system messages (group renames, + // participant changes) do not. + HasBody bool +} + +// cloudAttachmentRow holds CloudKit attachment metadata for a single attachment. +type cloudAttachmentRow struct { + GUID string `json:"guid"` + MimeType string `json:"mime_type,omitempty"` + UTIType string `json:"uti_type,omitempty"` + Filename string `json:"filename,omitempty"` + FileSize int64 `json:"file_size"` + RecordName string `json:"record_name"` + HideAttachment bool `json:"hide_attachment,omitempty"` + HasAvid bool `json:"has_avid,omitempty"` +} + +const ( + cloudZoneChats = "chatManateeZone" + cloudZoneMessages = "messageManateeZone" + cloudZoneAttachments = "attachmentManateeZone" +) + +func newCloudBackfillStore(db *dbutil.Database, loginID networkid.UserLoginID) *cloudBackfillStore { + return &cloudBackfillStore{db: db, loginID: loginID} +} + +func (s *cloudBackfillStore) ensureSchema(ctx context.Context) error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS cloud_sync_state ( + login_id TEXT NOT NULL, + zone TEXT NOT NULL, + continuation_token TEXT, + last_success_ts BIGINT, + last_error TEXT, + updated_ts BIGINT NOT NULL, + PRIMARY KEY (login_id, zone) + )`, + `CREATE TABLE IF NOT EXISTS cloud_chat ( + login_id TEXT NOT NULL, + cloud_chat_id TEXT NOT NULL, + record_name TEXT NOT NULL DEFAULT '', + group_id TEXT NOT NULL DEFAULT '', + portal_id TEXT NOT NULL, + service TEXT, + display_name TEXT, + group_photo_guid TEXT, + participants_json TEXT, + updated_ts BIGINT, + created_ts BIGINT NOT NULL, + PRIMARY KEY (login_id, cloud_chat_id) + )`, + `CREATE TABLE IF NOT EXISTS cloud_message ( + login_id TEXT NOT NULL, + guid TEXT NOT NULL, + chat_id TEXT, + portal_id TEXT, + timestamp_ms BIGINT NOT NULL, + sender TEXT, + is_from_me BOOLEAN NOT NULL, + text TEXT, + subject TEXT, + service TEXT, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + tapback_type INTEGER, + tapback_target_guid TEXT, + tapback_emoji TEXT, + attachments_json TEXT, + created_ts BIGINT NOT NULL, + updated_ts BIGINT NOT NULL, + PRIMARY KEY (login_id, guid) + )`, + `CREATE TABLE IF NOT EXISTS group_photo_cache ( + login_id TEXT NOT NULL, + portal_id TEXT NOT NULL, + ts BIGINT NOT NULL, + data BLOB NOT NULL, + PRIMARY KEY (login_id, portal_id) + )`, + `CREATE TABLE IF NOT EXISTS restore_override ( + login_id TEXT NOT NULL, + portal_id TEXT NOT NULL, + updated_ts BIGINT NOT NULL, + PRIMARY KEY (login_id, portal_id) + )`, + `CREATE INDEX IF NOT EXISTS cloud_chat_portal_idx + ON cloud_chat (login_id, portal_id, cloud_chat_id)`, + `CREATE INDEX IF NOT EXISTS cloud_message_portal_ts_idx + ON cloud_message (login_id, portal_id, timestamp_ms, guid)`, + `CREATE INDEX IF NOT EXISTS cloud_message_chat_ts_idx + ON cloud_message (login_id, chat_id, timestamp_ms, guid)`, + } + + // Run table creation queries first (without indexes that depend on migrations) + for _, query := range queries { + if _, err := s.db.Exec(ctx, query); err != nil { + return fmt.Errorf("failed to ensure cloud backfill schema: %w", err) + } + } + + // Migrations: add missing columns to cloud_chat (SQLite doesn't support IF NOT EXISTS on ALTER). + // fwd_backfill_done: set to 1 when FetchMessages(forward) completes for a portal so that + // preUploadCloudAttachments skips those portals on restart. Default 0 means "not yet done". + // deleted: soft-deletes cloud_chat rows alongside cloud_message rows so restore-chat can + // recover group name and participants. + for _, col := range []struct{ name, def string }{ + {"record_name", "TEXT NOT NULL DEFAULT ''"}, + {"group_id", "TEXT NOT NULL DEFAULT ''"}, + {"group_photo_guid", "TEXT"}, + {"deleted", "BOOLEAN NOT NULL DEFAULT FALSE"}, + {"is_filtered", "INTEGER NOT NULL DEFAULT 0"}, + {"fwd_backfill_done", "BOOLEAN NOT NULL DEFAULT 0"}, + } { + var exists int + _ = s.db.QueryRow(ctx, `SELECT COUNT(*) FROM pragma_table_info('cloud_chat') WHERE name=$1`, col.name).Scan(&exists) + if exists == 0 { + if _, err := s.db.Exec(ctx, fmt.Sprintf(`ALTER TABLE cloud_chat ADD COLUMN %s %s`, col.name, col.def)); err != nil { + return fmt.Errorf("failed to add %s column to cloud_chat: %w", col.name, err) + } + } + } + + // Migrations: add missing columns to cloud_message. + for _, col := range []struct{ name, def string }{ + {"subject", "TEXT"}, + {"tapback_type", "INTEGER"}, + {"tapback_target_guid", "TEXT"}, + {"tapback_emoji", "TEXT"}, + {"attachments_json", "TEXT"}, + {"date_read_ms", "BIGINT NOT NULL DEFAULT 0"}, + {"record_name", "TEXT NOT NULL DEFAULT ''"}, + {"has_body", "BOOLEAN NOT NULL DEFAULT TRUE"}, + } { + var exists int + _ = s.db.QueryRow(ctx, `SELECT COUNT(*) FROM pragma_table_info('cloud_message') WHERE name=$1`, col.name).Scan(&exists) + if exists == 0 { + if _, err := s.db.Exec(ctx, fmt.Sprintf(`ALTER TABLE cloud_message ADD COLUMN %s %s`, col.name, col.def)); err != nil { + return fmt.Errorf("failed to add %s column to cloud_message: %w", col.name, err) + } + } + } + + // Cleanup: permanently delete system/rename message rows that slipped into + // the DB before the MsgType==0 ingest filter was added. Two conditions + // catch different eras of the DB: + // 1. has_body=FALSE + no attachments + no tapback — rows that were stored + // after the has_body column was added but before the MsgType filter; + // their has_body is correctly FALSE (no attributedBody). + // 2. text matches the portal's display_name + no attachments + no tapback + // — older rows whose has_body defaulted to TRUE but whose content + // reveals them as group-rename notifications. + // This runs every startup and is idempotent: after the first pass it + // matches zero rows. + if _, err := s.db.Exec(ctx, ` + DELETE FROM cloud_message + WHERE login_id = $1 + AND COALESCE(attachments_json, '') = '' + AND tapback_type IS NULL + AND ( + has_body = FALSE + OR ( + text IS NOT NULL AND text <> '' + AND portal_id IN ( + SELECT portal_id FROM cloud_chat c + WHERE c.login_id = $1 + AND c.display_name IS NOT NULL AND c.display_name <> '' + AND c.display_name = cloud_message.text + ) + ) + ) + `, s.loginID); err != nil { + return fmt.Errorf("failed to delete system messages: %w", err) + } + + // Migration: add cloud_attachment_cache table if missing. + // Persists record_name → MessageEventContent JSON so mxc URIs survive + // bridge restarts. Pre-upload loads this at startup and skips already-cached + // attachments, so a 27k-message thread never re-downloads across restarts. + if _, err := s.db.Exec(ctx, `CREATE TABLE IF NOT EXISTS cloud_attachment_cache ( + login_id TEXT NOT NULL, + record_name TEXT NOT NULL, + content_json BLOB NOT NULL, + created_ts BIGINT NOT NULL, + PRIMARY KEY (login_id, record_name) + )`); err != nil { + return fmt.Errorf("failed to create cloud_attachment_cache table: %w", err) + } + + // Create index that depends on record_name column (must be after migration) + if _, err := s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS cloud_chat_record_name_idx + ON cloud_chat (login_id, record_name) WHERE record_name <> ''`); err != nil { + return fmt.Errorf("failed to create record_name index: %w", err) + } + + // Create index for group_id lookups (messages reference chats by group_id UUID) + if _, err := s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS cloud_chat_group_id_idx + ON cloud_chat (login_id, group_id) WHERE group_id <> ''`); err != nil { + return fmt.Errorf("failed to create group_id index: %w", err) + } + + return nil +} + +func (s *cloudBackfillStore) getSyncState(ctx context.Context, zone string) (*string, error) { + var token sql.NullString + err := s.db.QueryRow(ctx, + `SELECT continuation_token FROM cloud_sync_state WHERE login_id=$1 AND zone=$2`, + s.loginID, zone, + ).Scan(&token) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + if !token.Valid { + return nil, nil + } + return &token.String, nil +} + +func (s *cloudBackfillStore) setSyncStateSuccess(ctx context.Context, zone string, token *string) error { + nowMS := time.Now().UnixMilli() + _, err := s.db.Exec(ctx, ` + INSERT INTO cloud_sync_state (login_id, zone, continuation_token, last_success_ts, last_error, updated_ts) + VALUES ($1, $2, $3, $4, NULL, $5) + ON CONFLICT (login_id, zone) DO UPDATE SET + continuation_token=excluded.continuation_token, + last_success_ts=excluded.last_success_ts, + last_error=NULL, + updated_ts=excluded.updated_ts + `, s.loginID, zone, nullableString(token), nowMS, nowMS) + return err +} + +// clearSyncTokens removes only the sync continuation tokens for this login, +// forcing the next sync to re-download all records from CloudKit. +// Preserves cloud_chat, cloud_message, and the _version row. +func (s *cloudBackfillStore) clearSyncTokens(ctx context.Context) error { + _, err := s.db.Exec(ctx, + `DELETE FROM cloud_sync_state WHERE login_id=$1 AND zone != '_version'`, + s.loginID, + ) + return err +} + +// clearZoneToken removes the continuation token for a specific zone, +// forcing the next sync for that zone to start from scratch. +func (s *cloudBackfillStore) clearZoneToken(ctx context.Context, zone string) error { + _, err := s.db.Exec(ctx, + `DELETE FROM cloud_sync_state WHERE login_id=$1 AND zone=$2`, + s.loginID, zone, + ) + return err +} + +// getSyncVersion returns the stored sync schema version (0 if never set). +func (s *cloudBackfillStore) getSyncVersion(ctx context.Context) (int, error) { + var token sql.NullString + err := s.db.QueryRow(ctx, + `SELECT continuation_token FROM cloud_sync_state WHERE login_id=$1 AND zone='_version'`, + s.loginID, + ).Scan(&token) + if err != nil { + if err == sql.ErrNoRows { + return 0, nil + } + return 0, err + } + if !token.Valid { + return 0, nil + } + v := 0 + fmt.Sscanf(token.String, "%d", &v) + return v, nil +} + +// setSyncVersion stores the sync schema version. +func (s *cloudBackfillStore) setSyncVersion(ctx context.Context, version int) error { + nowMS := time.Now().UnixMilli() + vStr := fmt.Sprintf("%d", version) + _, err := s.db.Exec(ctx, ` + INSERT INTO cloud_sync_state (login_id, zone, continuation_token, updated_ts) + VALUES ($1, '_version', $2, $3) + ON CONFLICT (login_id, zone) DO UPDATE SET + continuation_token=excluded.continuation_token, + updated_ts=excluded.updated_ts + `, s.loginID, vStr, nowMS) + return err +} + +// getChatSyncVersion returns the stored chat-specific sync version (0 if never set). +// This tracks chat-zone-only re-syncs independently of the full cloudSyncVersion, +// so we can force a targeted chat re-fetch (e.g. to populate group_photo_guid) +// without also re-downloading all messages. +func (s *cloudBackfillStore) getChatSyncVersion(ctx context.Context) (int, error) { + var token sql.NullString + err := s.db.QueryRow(ctx, + `SELECT continuation_token FROM cloud_sync_state WHERE login_id=$1 AND zone='_chat_version'`, + s.loginID, + ).Scan(&token) + if err != nil { + if err == sql.ErrNoRows { + return 0, nil + } + return 0, err + } + if !token.Valid { + return 0, nil + } + v := 0 + fmt.Sscanf(token.String, "%d", &v) + return v, nil +} + +// setChatSyncVersion stores the chat-specific sync schema version. +func (s *cloudBackfillStore) setChatSyncVersion(ctx context.Context, version int) error { + nowMS := time.Now().UnixMilli() + vStr := fmt.Sprintf("%d", version) + _, err := s.db.Exec(ctx, ` + INSERT INTO cloud_sync_state (login_id, zone, continuation_token, updated_ts) + VALUES ($1, '_chat_version', $2, $3) + ON CONFLICT (login_id, zone) DO UPDATE SET + continuation_token=excluded.continuation_token, + updated_ts=excluded.updated_ts + `, s.loginID, vStr, nowMS) + return err +} + +// clearAllData removes cloud cache data for this login: sync tokens, +// cached chats, and cached messages. Used on fresh bootstrap when the bridge +// DB was reset but the cloud tables survived. +func (s *cloudBackfillStore) clearAllData(ctx context.Context) error { + for _, table := range []string{"cloud_sync_state", "cloud_chat", "cloud_message", "cloud_attachment_cache"} { + if _, err := s.db.Exec(ctx, + fmt.Sprintf(`DELETE FROM %s WHERE login_id=$1`, table), + s.loginID, + ); err != nil { + return fmt.Errorf("failed to clear %s: %w", table, err) + } + } + return nil +} + +// hasAnySyncState checks whether any sync state rows exist for this login. +// Used to detect an interrupted sync — if tokens exist but no portals were +// created yet, the sync was interrupted mid-flight and should resume, NOT restart. +func (s *cloudBackfillStore) hasAnySyncState(ctx context.Context) (bool, error) { + var count int + err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM cloud_sync_state WHERE login_id=$1`, + s.loginID, + ).Scan(&count) + return count > 0, err +} + +func (s *cloudBackfillStore) setSyncStateError(ctx context.Context, zone, errMsg string) error { + nowMS := time.Now().UnixMilli() + _, err := s.db.Exec(ctx, ` + INSERT INTO cloud_sync_state (login_id, zone, continuation_token, last_error, updated_ts) + VALUES ($1, $2, NULL, $3, $4) + ON CONFLICT (login_id, zone) DO UPDATE SET + last_error=excluded.last_error, + updated_ts=excluded.updated_ts + `, s.loginID, zone, errMsg, nowMS) + return err +} + +func (s *cloudBackfillStore) upsertChat( + ctx context.Context, + cloudChatID, recordName, groupID, portalID, service string, + displayName, groupPhotoGuid *string, + participants []string, + updatedTS int64, +) error { + participantsJSON, err := json.Marshal(participants) + if err != nil { + return err + } + nowMS := time.Now().UnixMilli() + _, err = s.db.Exec(ctx, ` + INSERT INTO cloud_chat ( + login_id, cloud_chat_id, record_name, group_id, portal_id, service, display_name, + group_photo_guid, participants_json, updated_ts, created_ts + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (login_id, cloud_chat_id) DO UPDATE SET + record_name=excluded.record_name, + group_id=excluded.group_id, + portal_id=excluded.portal_id, + service=excluded.service, + display_name=CASE + WHEN excluded.updated_ts >= COALESCE(cloud_chat.updated_ts, 0) + THEN excluded.display_name + ELSE cloud_chat.display_name + END, + group_photo_guid=CASE + WHEN excluded.updated_ts >= COALESCE(cloud_chat.updated_ts, 0) + THEN excluded.group_photo_guid + ELSE cloud_chat.group_photo_guid + END, + participants_json=excluded.participants_json, + updated_ts=CASE + WHEN excluded.updated_ts >= COALESCE(cloud_chat.updated_ts, 0) + THEN excluded.updated_ts + ELSE cloud_chat.updated_ts + END + `, s.loginID, cloudChatID, recordName, groupID, portalID, service, nullableString(displayName), nullableString(groupPhotoGuid), string(participantsJSON), updatedTS, nowMS) + return err +} + +// beginTx starts a database transaction for batch operations. +func (s *cloudBackfillStore) beginTx(ctx context.Context) (*sql.Tx, error) { + return s.db.RawDB.BeginTx(ctx, nil) +} + +// upsertMessageBatch inserts multiple messages in a single transaction. +func (s *cloudBackfillStore) upsertMessageBatch(ctx context.Context, rows []cloudMessageRow) error { + if len(rows) == 0 { + return nil + } + // CloudKit can occasionally return duplicate GUID entries within a fetch + // window; keep only the newest row per GUID to make batch inserts idempotent. + rowsByGUID := make(map[string]cloudMessageRow, len(rows)) + order := make([]string, 0, len(rows)) + for _, row := range rows { + if row.GUID == "" { + continue + } + if prev, ok := rowsByGUID[row.GUID]; !ok { + rowsByGUID[row.GUID] = row + order = append(order, row.GUID) + } else if row.TimestampMS >= prev.TimestampMS { + rowsByGUID[row.GUID] = row + } + } + tx, err := s.beginTx(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO cloud_message ( + login_id, guid, record_name, chat_id, portal_id, timestamp_ms, + sender, is_from_me, text, subject, service, deleted, + tapback_type, tapback_target_guid, tapback_emoji, + attachments_json, date_read_ms, has_body, + created_ts, updated_ts + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (login_id, guid) DO UPDATE SET + record_name=excluded.record_name, + chat_id=excluded.chat_id, + portal_id=excluded.portal_id, + timestamp_ms=excluded.timestamp_ms, + sender=excluded.sender, + is_from_me=excluded.is_from_me, + text=excluded.text, + subject=excluded.subject, + service=excluded.service, + deleted=CASE WHEN cloud_message.deleted THEN cloud_message.deleted ELSE excluded.deleted END, + tapback_type=excluded.tapback_type, + tapback_target_guid=excluded.tapback_target_guid, + tapback_emoji=excluded.tapback_emoji, + attachments_json=excluded.attachments_json, + date_read_ms=CASE WHEN excluded.date_read_ms > cloud_message.date_read_ms THEN excluded.date_read_ms ELSE cloud_message.date_read_ms END, + has_body=excluded.has_body, + updated_ts=excluded.updated_ts + `) + if err != nil { + return fmt.Errorf("failed to prepare batch statement: %w", err) + } + defer stmt.Close() + + nowMS := time.Now().UnixMilli() + for _, guid := range order { + row := rowsByGUID[guid] + _, err = stmt.ExecContext(ctx, + s.loginID, row.GUID, row.RecordName, row.CloudChatID, row.PortalID, row.TimestampMS, + row.Sender, row.IsFromMe, row.Text, row.Subject, row.Service, row.Deleted, + row.TapbackType, row.TapbackTargetGUID, row.TapbackEmoji, + row.AttachmentsJSON, row.DateReadMS, row.HasBody, + nowMS, nowMS, + ) + if err != nil { + if isUniqueConstraintErr(err) { + continue + } + return fmt.Errorf("failed to insert message %s: %w", row.GUID, err) + } + } + + return tx.Commit() +} + +func isUniqueConstraintErr(err error) bool { + if err == nil { + return false + } + errText := strings.ToLower(err.Error()) + return strings.Contains(errText, "unique constraint") || strings.Contains(errText, "primary key") +} + +// deleteMessageBatch soft-deletes individual messages by GUID (sets deleted=TRUE). +// This is for messages that CloudKit itself marks as deleted (individual message +// deletions), NOT for portal-level deletion (see deleteLocalChatByPortalID). +// +// Soft-delete is used here because: +// 1. Echo detection: hasMessageUUID checks cloud_message without filtering +// by deleted. Keeping the row means re-delivered APNs echoes of the same +// UUID are still recognised as known and suppressed. +// 2. Re-sync safety: a full CloudKit re-sync would re-import the message as +// live. With a hard delete, the upsert inserts it with deleted=FALSE. +// With a soft delete, the upsert conflict resolution preserves deleted=TRUE. +// +// The upsert conflict resolution (`deleted=CASE WHEN cloud_message.deleted +// THEN cloud_message.deleted ELSE excluded.deleted END`) ensures that a live +// re-delivery of the same GUID can never flip a soft-deleted row back to live. +func (s *cloudBackfillStore) deleteMessageBatch(ctx context.Context, guids []string) error { + if len(guids) == 0 { + return nil + } + nowMS := time.Now().UnixMilli() + const chunkSize = 500 + for i := 0; i < len(guids); i += chunkSize { + end := i + chunkSize + if end > len(guids) { + end = len(guids) + } + chunk := guids[i:end] + + placeholders := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)+2) + args = append(args, s.loginID, nowMS) + for j, g := range chunk { + placeholders[j] = fmt.Sprintf("$%d", j+3) + args = append(args, g) + } + + query := fmt.Sprintf( + `UPDATE cloud_message SET deleted=TRUE, updated_ts=$2 WHERE login_id=$1 AND guid IN (%s) AND deleted=FALSE`, + strings.Join(placeholders, ","), + ) + if _, err := s.db.Exec(ctx, query, args...); err != nil { + return fmt.Errorf("failed to soft-delete message batch: %w", err) + } + } + return nil +} + +// deleteChatBatch removes chats by cloud_chat_id in a single transaction. +func (s *cloudBackfillStore) deleteChatBatch(ctx context.Context, chatIDs []string) error { + if len(chatIDs) == 0 { + return nil + } + const chunkSize = 500 + for i := 0; i < len(chatIDs); i += chunkSize { + end := i + chunkSize + if end > len(chatIDs) { + end = len(chatIDs) + } + chunk := chatIDs[i:end] + + placeholders := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)+1) + args = append(args, s.loginID) + for j, id := range chunk { + placeholders[j] = fmt.Sprintf("$%d", j+2) + args = append(args, id) + } + + query := fmt.Sprintf( + `DELETE FROM cloud_chat WHERE login_id=$1 AND cloud_chat_id IN (%s)`, + strings.Join(placeholders, ","), + ) + if _, err := s.db.Exec(ctx, query, args...); err != nil { + return fmt.Errorf("failed to delete chat batch: %w", err) + } + } + return nil +} + +// lookupPortalIDsByRecordNames finds portal_ids for cloud_chat records matching +// the given CloudKit record_names. Used to resolve tombstoned (deleted) chats +// whose only identifier is the record_name. +func (s *cloudBackfillStore) lookupPortalIDsByRecordNames(ctx context.Context, recordNames []string) (map[string]string, error) { + result := make(map[string]string, len(recordNames)) + if len(recordNames) == 0 { + return result, nil + } + const chunkSize = 500 + for i := 0; i < len(recordNames); i += chunkSize { + end := i + chunkSize + if end > len(recordNames) { + end = len(recordNames) + } + chunk := recordNames[i:end] + + placeholders := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)+1) + args = append(args, s.loginID) + for j, rn := range chunk { + placeholders[j] = fmt.Sprintf("$%d", j+2) + args = append(args, rn) + } + + query := fmt.Sprintf( + `SELECT record_name, portal_id FROM cloud_chat WHERE login_id=$1 AND record_name IN (%s)`, + strings.Join(placeholders, ","), + ) + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to lookup portal IDs by record names: %w", err) + } + for rows.Next() { + var rn, pid string + if err := rows.Scan(&rn, &pid); err != nil { + rows.Close() + return nil, err + } + result[rn] = pid + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + } + return result, nil +} + +// deleteChatsByRecordNames removes cloud_chat entries by their CloudKit +// record_name (not cloud_chat_id). Needed for tombstoned records where the +// only identifier is the record_name. +func (s *cloudBackfillStore) deleteChatsByRecordNames(ctx context.Context, recordNames []string) error { + if len(recordNames) == 0 { + return nil + } + const chunkSize = 500 + for i := 0; i < len(recordNames); i += chunkSize { + end := i + chunkSize + if end > len(recordNames) { + end = len(recordNames) + } + chunk := recordNames[i:end] + + placeholders := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)+1) + args = append(args, s.loginID) + for j, rn := range chunk { + placeholders[j] = fmt.Sprintf("$%d", j+2) + args = append(args, rn) + } + + // Bump updated_ts so tail-timestamp gating uses the deletion time, + // not the last CloudKit sync time. + nowMS := time.Now().UnixMilli() + args = append(args, nowMS) + tsPlaceholder := fmt.Sprintf("$%d", len(args)) + query := fmt.Sprintf( + `UPDATE cloud_chat SET deleted=TRUE, updated_ts=%s WHERE login_id=$1 AND record_name IN (%s) AND deleted=FALSE`, + tsPlaceholder, strings.Join(placeholders, ","), + ) + if _, err := s.db.Exec(ctx, query, args...); err != nil { + return fmt.Errorf("failed to soft-delete chats by record name: %w", err) + } + } + return nil +} + +// deleteMessagesByChatIDs removes messages whose chat_id matches any of the +// given cloud_chat_ids. This prevents orphaned messages from keeping portals +// alive after their parent chat is deleted from CloudKit. +func (s *cloudBackfillStore) deleteMessagesByChatIDs(ctx context.Context, chatIDs []string) error { + if len(chatIDs) == 0 { + return nil + } + const chunkSize = 500 + for i := 0; i < len(chatIDs); i += chunkSize { + end := i + chunkSize + if end > len(chatIDs) { + end = len(chatIDs) + } + chunk := chatIDs[i:end] + + placeholders := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)+1) + args = append(args, s.loginID) + for j, id := range chunk { + placeholders[j] = fmt.Sprintf("$%d", j+2) + args = append(args, id) + } + + query := fmt.Sprintf( + `DELETE FROM cloud_message WHERE login_id=$1 AND chat_id IN (%s)`, + strings.Join(placeholders, ","), + ) + if _, err := s.db.Exec(ctx, query, args...); err != nil { + return fmt.Errorf("failed to delete messages by chat ID: %w", err) + } + } + return nil +} + +// upsertChatBatch inserts multiple chats in a single transaction. +func (s *cloudBackfillStore) upsertChatBatch(ctx context.Context, chats []cloudChatUpsertRow) error { + if len(chats) == 0 { + return nil + } + tx, err := s.beginTx(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO cloud_chat ( + login_id, cloud_chat_id, record_name, group_id, portal_id, service, display_name, + group_photo_guid, participants_json, updated_ts, created_ts, is_filtered + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (login_id, cloud_chat_id) DO UPDATE SET + record_name=excluded.record_name, + group_id=excluded.group_id, + portal_id=excluded.portal_id, + service=excluded.service, + display_name=CASE + WHEN excluded.updated_ts >= COALESCE(cloud_chat.updated_ts, 0) + THEN excluded.display_name + ELSE cloud_chat.display_name + END, + group_photo_guid=CASE + WHEN excluded.updated_ts >= COALESCE(cloud_chat.updated_ts, 0) + THEN excluded.group_photo_guid + ELSE cloud_chat.group_photo_guid + END, + participants_json=excluded.participants_json, + updated_ts=CASE + WHEN excluded.updated_ts >= COALESCE(cloud_chat.updated_ts, 0) + THEN excluded.updated_ts + ELSE cloud_chat.updated_ts + END, + is_filtered=excluded.is_filtered, + deleted=cloud_chat.deleted + `) + if err != nil { + return fmt.Errorf("failed to prepare batch statement: %w", err) + } + defer stmt.Close() + + nowMS := time.Now().UnixMilli() + for _, chat := range chats { + _, err = stmt.ExecContext(ctx, + s.loginID, chat.CloudChatID, chat.RecordName, chat.GroupID, + chat.PortalID, chat.Service, chat.DisplayName, + chat.GroupPhotoGuid, chat.ParticipantsJSON, chat.UpdatedTS, nowMS, chat.IsFiltered, + ) + if err != nil { + return fmt.Errorf("failed to insert chat %s: %w", chat.CloudChatID, err) + } + } + + return tx.Commit() +} + +// hasMessageBatch checks existence of multiple GUIDs in a single query and +// returns the set of GUIDs that already exist. +func (s *cloudBackfillStore) hasMessageBatch(ctx context.Context, guids []string) (map[string]bool, error) { + if len(guids) == 0 { + return nil, nil + } + existing := make(map[string]bool, len(guids)) + // SQLite has a limit on the number of variables. Process in chunks. + const chunkSize = 500 + for i := 0; i < len(guids); i += chunkSize { + end := i + chunkSize + if end > len(guids) { + end = len(guids) + } + chunk := guids[i:end] + + placeholders := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)+1) + args = append(args, s.loginID) + for j, g := range chunk { + placeholders[j] = fmt.Sprintf("$%d", j+2) + args = append(args, g) + } + + query := fmt.Sprintf( + `SELECT guid FROM cloud_message WHERE login_id=$1 AND guid IN (%s)`, + strings.Join(placeholders, ","), + ) + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var guid string + if err := rows.Scan(&guid); err != nil { + rows.Close() + return nil, err + } + existing[guid] = true + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + } + return existing, nil +} + +// cloudChatUpsertRow holds the pre-serialized data for a batch chat upsert. +type cloudChatUpsertRow struct { + CloudChatID string + RecordName string + GroupID string + PortalID string + Service string + DisplayName any // nil or string + GroupPhotoGuid any // nil or string + ParticipantsJSON string + UpdatedTS int64 + IsFiltered int64 +} + +type recycleBinPortalState struct { + PortalID string + Total int + Recoverable int + RecoverableSuffix int + NewestTS int64 + HasLiveMessages bool + HasDeletedMessages bool +} + +// Chat-level deletes move a short tail of the newest messages to Apple's +// recycle bin. Requiring at least a few consecutive newest messages helps +// distinguish a deleted chat from single-message deletes or stale recycle-bin +// entries from a chat that has since been restored. +func recycleBinDeleteThreshold(total int) int { + if total <= 0 { + return 0 + } + if total < 3 { + return total + } + return 3 +} + +func (s recycleBinPortalState) LooksDeleted() bool { + threshold := recycleBinDeleteThreshold(s.Total) + return threshold > 0 && s.RecoverableSuffix >= threshold +} + +func (s recycleBinPortalState) LooksRestored() bool { + return s.Recoverable > 0 && !s.LooksDeleted() +} + +func (s recycleBinPortalState) NeedsUndelete() bool { + return s.LooksRestored() && s.HasDeletedMessages && !s.HasLiveMessages +} + +func normalizeRecoverableGUIDSet(recoverableGUIDs []string) map[string]bool { + if len(recoverableGUIDs) == 0 { + return nil + } + + // Build a set for O(1) lookup. + // Handle both plain GUIDs and pipe-delimited "guid|...|..." format + // (in case the Rust library returns structured entries). + guidSet := make(map[string]bool, len(recoverableGUIDs)) + for _, g := range recoverableGUIDs { + if idx := strings.IndexByte(g, '|'); idx >= 0 { + g = g[:idx] + } + if g != "" { + guidSet[g] = true + } + } + return guidSet +} + +// classifyRecycleBinPortals matches recoverable message GUIDs from Apple's +// recycle-bin zones against local cloud_message rows and classifies each portal +// by how those GUIDs overlap with the newest messages in the conversation. +func (s *cloudBackfillStore) classifyRecycleBinPortals(ctx context.Context, recoverableGUIDs []string) ([]recycleBinPortalState, error) { + guidSet := normalizeRecoverableGUIDSet(recoverableGUIDs) + if len(guidSet) == 0 { + return nil, nil + } + + // Log a few sample GUIDs from each side for format comparison diagnostics. + log := zerolog.Ctx(ctx) + sampleRecycleBin := make([]string, 0, 5) + for g := range guidSet { + sampleRecycleBin = append(sampleRecycleBin, g) + if len(sampleRecycleBin) >= 5 { + break + } + } + log.Info().Strs("sample_recycle_guids", sampleRecycleBin). + Int("total_recycle_guids", len(guidSet)). + Msg("classifyRecycleBinPortals: recycle bin GUID samples") + + // Scan every non-stub message, including soft-deleted rows. That lets us + // detect restored portals that were previously soft-deleted locally. + rows, err := s.db.Query(ctx, ` + SELECT portal_id, guid, timestamp_ms, deleted + FROM cloud_message + WHERE login_id=$1 + AND portal_id IS NOT NULL AND portal_id <> '' + AND record_name <> '' + ORDER BY portal_id ASC, timestamp_ms DESC, guid DESC + `, s.loginID) + if err != nil { + return nil, err + } + defer rows.Close() + + statesByPortal := make(map[string]*recycleBinPortalState) + orderedPortalIDs := make([]string, 0) + currentPortalID := "" + var currentState *recycleBinPortalState + recoverableSuffixOpen := false + sampleDBGuids := make([]string, 0, 5) + + for rows.Next() { + var portalID, guid string + var timestampMS int64 + var deleted bool + if err = rows.Scan(&portalID, &guid, ×tampMS, &deleted); err != nil { + return nil, err + } + + if len(sampleDBGuids) < 5 { + sampleDBGuids = append(sampleDBGuids, guid) + } + + if portalID != currentPortalID { + currentPortalID = portalID + currentState = &recycleBinPortalState{ + PortalID: portalID, + NewestTS: timestampMS, + } + statesByPortal[portalID] = currentState + orderedPortalIDs = append(orderedPortalIDs, portalID) + recoverableSuffixOpen = true + } + + currentState.Total++ + if deleted { + currentState.HasDeletedMessages = true + } else { + currentState.HasLiveMessages = true + } + + isRecoverable := guidSet[guid] + if isRecoverable { + currentState.Recoverable++ + } + if recoverableSuffixOpen { + if isRecoverable { + currentState.RecoverableSuffix++ + } else { + recoverableSuffixOpen = false + } + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + states := make([]recycleBinPortalState, 0, len(orderedPortalIDs)) + totalMessages := 0 + totalRecoverable := 0 + deletedPortals := 0 + restoredPortals := 0 + for _, portalID := range orderedPortalIDs { + state := *statesByPortal[portalID] + totalMessages += state.Total + totalRecoverable += state.Recoverable + if state.Recoverable == 0 { + continue + } + if state.LooksDeleted() { + deletedPortals++ + } else if state.LooksRestored() { + restoredPortals++ + } + states = append(states, state) + } + log.Info().Strs("sample_db_guids", sampleDBGuids). + Int("total_db_messages", totalMessages). + Msg("classifyRecycleBinPortals: cloud_message GUID samples") + zerolog.Ctx(ctx).Info(). + Int("portals_checked", len(orderedPortalIDs)). + Int("total_messages", totalMessages). + Int("total_recoverable", totalRecoverable). + Int("guid_set_size", len(guidSet)). + Int("candidate_portals", len(states)). + Int("deleted_portals", deletedPortals). + Int("restored_portals", restoredPortals). + Msg("classifyRecycleBinPortals stats") + + return states, nil +} + +// insertDeletedChatTombstone inserts a cloud_chat row with deleted=TRUE. +// Used by seedDeletedChatsFromRecycleBin to persist delete knowledge across +// restarts on a fresh database. The ON CONFLICT clause ensures that if the +// chat already exists (from a prior sync), we don't overwrite it — only +// insert if it's genuinely new. If CloudKit later syncs the chat as live, +// upsertChatBatch will set deleted=FALSE automatically. +func (s *cloudBackfillStore) insertDeletedChatTombstone( + ctx context.Context, + cloudChatID, portalID, recordName, groupID, service string, + displayName *string, + participantsJSON string, +) error { + nowMS := time.Now().UnixMilli() + _, err := s.db.Exec(ctx, ` + INSERT INTO cloud_chat ( + login_id, cloud_chat_id, record_name, group_id, portal_id, service, + display_name, participants_json, updated_ts, created_ts, deleted + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE) + ON CONFLICT (login_id, cloud_chat_id) DO NOTHING + `, s.loginID, cloudChatID, recordName, groupID, portalID, service, nullableString(displayName), participantsJSON, nowMS, nowMS) + return err +} + +func (s *cloudBackfillStore) ensureDeletedChatTombstoneByPortalID(ctx context.Context, portalID string) error { + chatID := s.getChatIdentifierByPortalID(ctx, portalID) + if chatID == "" { + chatID = "synthetic:recoverable:" + portalID + } + + recordName := "" + recordNames, err := s.getCloudRecordNamesByPortalID(ctx, portalID) + if err != nil { + return err + } + if len(recordNames) > 0 { + recordName = recordNames[0] + } + + groupID := "" + if strings.HasPrefix(portalID, "gid:") { + groupID = strings.TrimPrefix(portalID, "gid:") + } + + displayNameValue, err := s.getDisplayNameByPortalID(ctx, portalID) + if err != nil { + return err + } + var displayName *string + if displayNameValue != "" { + displayName = &displayNameValue + } + + participants, err := s.getChatParticipantsByPortalID(ctx, portalID) + if err != nil { + return err + } + if len(participants) == 0 && !strings.HasPrefix(portalID, "gid:") && !strings.Contains(portalID, ",") { + participants = []string{portalID} + } + participantsJSON, err := json.Marshal(participants) + if err != nil { + return err + } + + service := "iMessage" + if strings.HasPrefix(chatID, "SMS") { + service = "SMS" + } + + return s.insertDeletedChatTombstone( + ctx, + chatID, + portalID, + recordName, + groupID, + service, + displayName, + string(participantsJSON), + ) +} + +func (s *cloudBackfillStore) getChatPortalID(ctx context.Context, cloudChatID string) (string, error) { + var portalID string + // Try matching by cloud_chat_id, record_name, or group_id. + // CloudKit messages reference chats by group_id UUID (the chatID field), + // while cloud_chat stores chat_identifier as cloud_chat_id and record hash as record_name. + // Use LOWER() on group_id because CloudKit stores it uppercase but messages reference it lowercase. + err := s.db.QueryRow(ctx, + `SELECT portal_id FROM cloud_chat WHERE login_id=$1 AND (cloud_chat_id=$2 OR record_name=$2 OR LOWER(group_id)=LOWER($2))`, + s.loginID, cloudChatID, + ).Scan(&portalID) + if err != nil { + if err == sql.ErrNoRows { + // Messages use chat_identifier format like "SMS;-;+14158138533" or "iMessage;-;user@example.com" + // but cloud_chat stores just the identifier part ("+14158138533" or "user@example.com"), + // or the reverse: cloud_chat stores "any;-;email" but the message has bare "email". + if parts := strings.SplitN(cloudChatID, ";-;", 2); len(parts) == 2 { + // Has a service prefix — strip it and try the bare identifier. + bareID := parts[1] + if pid, err2 := s.getChatPortalID(ctx, bareID); err2 == nil && pid != "" { + return pid, nil + } + // Bare lookup also failed. Try all service-prefix variants — the DB + // row may store a different prefix than the message carries. + for _, prefix := range []string{"iMessage;-;", "any;-;", "SMS;-;"} { + candidate := prefix + bareID + if candidate == cloudChatID { + continue // already tried exact match above + } + var pid string + if err2 := s.db.QueryRow(ctx, + `SELECT portal_id FROM cloud_chat WHERE login_id=$1 AND (cloud_chat_id=$2 OR record_name=$2)`, + s.loginID, candidate, + ).Scan(&pid); err2 == nil && pid != "" { + return pid, nil + } + } + } else if !strings.Contains(cloudChatID, ";") { + // Bare chatId (no service prefix at all). Try adding known prefixes — + // the DB row may have been seeded with "any;-;email" while the message + // carries just "email". + for _, prefix := range []string{"iMessage;-;", "any;-;", "SMS;-;"} { + var pid string + if err2 := s.db.QueryRow(ctx, + `SELECT portal_id FROM cloud_chat WHERE login_id=$1 AND (cloud_chat_id=$2 OR record_name=$2)`, + s.loginID, prefix+cloudChatID, + ).Scan(&pid); err2 == nil && pid != "" { + return pid, nil + } + } + } + return "", nil + } + return "", err + } + return portalID, nil +} + +// listDeletedPortalIDs returns portal_ids whose chat rows are still fully +// soft-deleted (at least one deleted row, no live replacement). Used to +// repopulate recentlyDeletedPortals on bridge restart so tombstoned chats from +// prior sessions stay protected without re-marking chats that were already +// restored locally. +func (s *cloudBackfillStore) listDeletedPortalIDs(ctx context.Context) ([]string, error) { + rows, err := s.db.Query(ctx, + ` + SELECT portal_id + FROM cloud_chat + WHERE login_id=$1 AND portal_id <> '' + GROUP BY portal_id + HAVING MAX(CASE WHEN deleted=TRUE THEN 1 ELSE 0 END) = 1 + AND MAX(CASE WHEN deleted=FALSE THEN 1 ELSE 0 END) = 0 + `, + s.loginID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var portalIDs []string + for rows.Next() { + var id string + if err = rows.Scan(&id); err != nil { + return nil, err + } + portalIDs = append(portalIDs, id) + } + return portalIDs, rows.Err() +} + +func (s *cloudBackfillStore) setRestoreOverride(ctx context.Context, portalID string) error { + _, err := s.db.Exec(ctx, ` + INSERT INTO restore_override (login_id, portal_id, updated_ts) + VALUES ($1, $2, $3) + ON CONFLICT (login_id, portal_id) DO UPDATE SET updated_ts=excluded.updated_ts + `, s.loginID, portalID, time.Now().UnixMilli()) + return err +} + +func (s *cloudBackfillStore) clearRestoreOverride(ctx context.Context, portalID string) error { + _, err := s.db.Exec(ctx, + `DELETE FROM restore_override WHERE login_id=$1 AND portal_id=$2`, + s.loginID, portalID, + ) + return err +} + +func (s *cloudBackfillStore) hasRestoreOverride(ctx context.Context, portalID string) (bool, error) { + var count int + err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM restore_override WHERE login_id=$1 AND portal_id=$2`, + s.loginID, portalID, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// listRestoreOverrides returns all portal IDs that currently have a restore +// override set. Returns nil on error. +func (s *cloudBackfillStore) listRestoreOverrides(ctx context.Context) []string { + rows, err := s.db.Query(ctx, + `SELECT portal_id FROM restore_override WHERE login_id=$1 ORDER BY portal_id`, + s.loginID, + ) + if err != nil { + return nil + } + defer rows.Close() + var out []string + for rows.Next() { + var pid string + if err := rows.Scan(&pid); err == nil { + out = append(out, pid) + } + } + return out +} + +// portalHasChat returns true if the given portal_id has at least one +// cloud_chat record (i.e., the conversation was included in CloudKit chat +// sync). Portals with no chat record are orphaned — typically junk/spam +// that Apple filters from the chat zone. +func (s *cloudBackfillStore) portalHasChat(ctx context.Context, portalID string) (bool, error) { + var count int + err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE`, + s.loginID, portalID, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// portalIsExplicitlyDeleted returns true if the given portal_id has a +// cloud_chat row with deleted=TRUE. This indicates the user or Apple +// explicitly deleted the conversation; messages for such portals must not +// be ingested during CloudKit sync (they would resurrect zombie portals). +// +// Unlike portalHasChat (which requires a live row), this returns false for +// portals that simply have no cloud_chat row — e.g. recycle-bin-only chats +// on a fresh sync whose chat record never appeared in the main zone. Those +// portals are allowed through so their main-zone messages are stored. +func (s *cloudBackfillStore) portalIsExplicitlyDeleted(ctx context.Context, portalID string) (bool, error) { + var count int + err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND deleted=TRUE`, + s.loginID, portalID, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +type softDeletedPortalInfo struct { + NewestTS int64 + Deleted bool +} + +// getSoftDeletedPortalInfo returns whether the portal is still fully +// soft-deleted (deleted rows with no live replacement) and the latest known +// timestamp for that deleted state. The timestamp is the max of: +// - newest soft-deleted cloud_message timestamp +// - cloud_chat.updated_ts, which is bumped on delete/undelete so chats with +// no imported messages still have a meaningful cutoff. +// +// OpenBubbles unconditionally revives on any incoming message; we add +// tail-timestamp gating because APNs replays stale messages more aggressively +// than CloudKit (which is OpenBubbles' primary sync path). +func (s *cloudBackfillStore) getSoftDeletedPortalInfo(ctx context.Context, portalID string) (softDeletedPortalInfo, error) { + var info softDeletedPortalInfo + var deletedCount, liveCount int + var newestMessageTS, newestChatTS sql.NullInt64 + err := s.db.QueryRow(ctx, ` + WITH chat_stats AS ( + SELECT + COALESCE(SUM(CASE WHEN deleted=TRUE THEN 1 ELSE 0 END), 0) AS deleted_count, + COALESCE(SUM(CASE WHEN deleted=FALSE THEN 1 ELSE 0 END), 0) AS live_count, + MAX(updated_ts) AS newest_chat_ts + FROM cloud_chat + WHERE login_id=$1 AND portal_id=$2 + ), + message_stats AS ( + SELECT MAX(timestamp_ms) AS newest_message_ts + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=TRUE + ) + SELECT + cs.deleted_count, + cs.live_count, + ms.newest_message_ts, + cs.newest_chat_ts + FROM chat_stats cs + CROSS JOIN message_stats ms + `, s.loginID, portalID).Scan(&deletedCount, &liveCount, &newestMessageTS, &newestChatTS) + if err != nil { + return info, err + } + + info.Deleted = deletedCount > 0 && liveCount == 0 + if newestMessageTS.Valid && newestMessageTS.Int64 > info.NewestTS { + info.NewestTS = newestMessageTS.Int64 + } + if newestChatTS.Valid && newestChatTS.Int64 > info.NewestTS { + info.NewestTS = newestChatTS.Int64 + } + return info, nil +} + +func (s *cloudBackfillStore) hasChat(ctx context.Context, cloudChatID string) (bool, error) { + var count int + err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM cloud_chat WHERE login_id=$1 AND cloud_chat_id=$2`, + s.loginID, cloudChatID, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// hasChatBatch checks existence of multiple cloud chat IDs in a single query +// and returns the set of IDs that already exist. +func (s *cloudBackfillStore) hasChatBatch(ctx context.Context, chatIDs []string) (map[string]bool, error) { + if len(chatIDs) == 0 { + return nil, nil + } + existing := make(map[string]bool, len(chatIDs)) + const chunkSize = 500 + for i := 0; i < len(chatIDs); i += chunkSize { + end := i + chunkSize + if end > len(chatIDs) { + end = len(chatIDs) + } + chunk := chatIDs[i:end] + + placeholders := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)+1) + args = append(args, s.loginID) + for j, id := range chunk { + placeholders[j] = fmt.Sprintf("$%d", j+2) + args = append(args, id) + } + + query := fmt.Sprintf( + `SELECT cloud_chat_id FROM cloud_chat WHERE login_id=$1 AND cloud_chat_id IN (%s)`, + strings.Join(placeholders, ","), + ) + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + existing[id] = true + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + } + return existing, nil +} + +func (s *cloudBackfillStore) getChatParticipantsByPortalID(ctx context.Context, portalID string) ([]string, error) { + var participantsJSON string + err := s.db.QueryRow(ctx, + `SELECT participants_json FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND participants_json IS NOT NULL AND participants_json <> '' LIMIT 1`, + s.loginID, portalID, + ).Scan(&participantsJSON) + // Fallback: the portal ID's UUID might be a chat_id that differs from + // the group_id. Try matching by group_id so gid: portals can + // still find participants stored under gid:. + if err != nil && strings.HasPrefix(portalID, "gid:") { + uuid := strings.TrimPrefix(portalID, "gid:") + err = s.db.QueryRow(ctx, + `SELECT participants_json FROM cloud_chat WHERE login_id=$1 AND (LOWER(group_id)=LOWER($2) OR LOWER(cloud_chat_id)=LOWER($2)) AND participants_json IS NOT NULL AND participants_json <> '' LIMIT 1`, + s.loginID, uuid, + ).Scan(&participantsJSON) + } + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + var participants []string + if err = json.Unmarshal([]byte(participantsJSON), &participants); err != nil { + return nil, err + } + // Normalize participants to portal ID format (e.g., tel:+14158138533) + normalized := make([]string, 0, len(participants)) + for _, p := range participants { + n := normalizeIdentifierForPortalID(p) + if n != "" { + normalized = append(normalized, n) + } + } + return normalized, nil +} + +// getDisplayNameByPortalID returns the CloudKit display_name for a given portal_id. +// This is the user-set group name (cv_name from the iMessage protocol), NOT an +// auto-generated label. Returns empty string if none found or if the group is unnamed. +func (s *cloudBackfillStore) getDisplayNameByPortalID(ctx context.Context, portalID string) (string, error) { + var displayName sql.NullString + err := s.db.QueryRow(ctx, + `SELECT display_name FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND display_name IS NOT NULL AND display_name <> '' ORDER BY updated_ts DESC LIMIT 1`, + s.loginID, portalID, + ).Scan(&displayName) + if err == nil && displayName.Valid { + return displayName.String, nil + } + // Fallback: the portal ID's UUID might be a chat_id that differs from + // the group_id. Try matching by group_id so gid: portals can + // still find the display_name stored under gid:. + if strings.HasPrefix(portalID, "gid:") { + uuid := strings.TrimPrefix(portalID, "gid:") + err = s.db.QueryRow(ctx, + `SELECT display_name FROM cloud_chat WHERE login_id=$1 AND (LOWER(group_id)=LOWER($2) OR LOWER(cloud_chat_id)=LOWER($2)) AND display_name IS NOT NULL AND display_name <> '' ORDER BY updated_ts DESC LIMIT 1`, + s.loginID, uuid, + ).Scan(&displayName) + if err == nil && displayName.Valid { + return displayName.String, nil + } + } + return "", nil +} + +// getChatIdentifierByPortalID returns the CloudKit chat_identifier (e.g. +// "iMessage;-;user@example.com") for a given portal_id. Used to construct the +// chat GUID for MoveToRecycleBin messages. +func (s *cloudBackfillStore) getChatIdentifierByPortalID(ctx context.Context, portalID string) string { + var chatID string + err := s.db.QueryRow(ctx, + `SELECT cloud_chat_id FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND cloud_chat_id <> '' AND cloud_chat_id NOT LIKE 'synthetic:%' AND cloud_chat_id NOT LIKE 'recycle:%' LIMIT 1`, + s.loginID, portalID, + ).Scan(&chatID) + if err != nil { + return "" + } + return chatID +} + +// getCloudRecordNamesByPortalID returns all non-empty chat record_names for a portal. +func (s *cloudBackfillStore) getCloudRecordNamesByPortalID(ctx context.Context, portalID string) ([]string, error) { + rows, err := s.db.Query(ctx, + `SELECT record_name FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND record_name <> ''`, + s.loginID, portalID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err = rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +// getMessageRecordNamesByPortalID returns all non-empty message record_names for a portal. +func (s *cloudBackfillStore) getMessageRecordNamesByPortalID(ctx context.Context, portalID string) ([]string, error) { + rows, err := s.db.Query(ctx, + `SELECT record_name FROM cloud_message WHERE login_id=$1 AND portal_id=$2 AND record_name <> ''`, + s.loginID, portalID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err = rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +// getCloudRecordNamesByGroupID returns all non-empty chat record_names for ANY +// portal_id that shares the given group_id. +func (s *cloudBackfillStore) getCloudRecordNamesByGroupID(ctx context.Context, groupID string) ([]string, error) { + rows, err := s.db.Query(ctx, + `SELECT record_name FROM cloud_chat WHERE login_id=$1 AND (LOWER(group_id)=LOWER($2) OR LOWER(cloud_chat_id)=LOWER($2)) AND record_name <> ''`, + s.loginID, groupID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err = rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +// findPortalIDsByGroupID returns all distinct portal IDs associated with a +// CloudKit group UUID. Used to dedupe group restores when participant data is +// missing in delete/recover payloads. +func (s *cloudBackfillStore) findPortalIDsByGroupID(ctx context.Context, groupID string) ([]string, error) { + rows, err := s.db.Query(ctx, + `SELECT DISTINCT portal_id FROM cloud_chat WHERE login_id=$1 AND (LOWER(group_id)=LOWER($2) OR LOWER(cloud_chat_id)=LOWER($2)) AND portal_id <> ''`, + s.loginID, groupID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var portalIDs []string + for rows.Next() { + var portalID string + if err = rows.Scan(&portalID); err != nil { + return nil, err + } + portalIDs = append(portalIDs, portalID) + } + return portalIDs, rows.Err() +} + +// getGroupIDForPortalID returns the group_id associated with a portal in the +// cloud_chat table. This is useful for cross-referencing when a portal's UUID +// (extracted from the portal ID) might be a chat_id rather than the group_id. +// Returns "" if no group_id is found. +func (s *cloudBackfillStore) getGroupIDForPortalID(ctx context.Context, portalID string) string { + var groupID string + err := s.db.QueryRow(ctx, + `SELECT group_id FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND group_id <> '' LIMIT 1`, + s.loginID, portalID, + ).Scan(&groupID) + if err == nil { + return groupID + } + // Fallback: after normalizeGroupChatPortalIDs, cloud_chat rows that used + // gid: now have portal_id=gid:. Search by cloud_chat_id + // so callers using the old portal_id can still find the group_id. + if strings.HasPrefix(portalID, "gid:") { + uuid := strings.TrimPrefix(portalID, "gid:") + err = s.db.QueryRow(ctx, + `SELECT group_id FROM cloud_chat WHERE login_id=$1 AND LOWER(cloud_chat_id)=LOWER($2) AND group_id <> '' LIMIT 1`, + s.loginID, uuid, + ).Scan(&groupID) + if err == nil { + return groupID + } + } + return "" +} + +// normalizeGroupChatPortalIDs unifies cloud_chat portal_ids so all rows for +// the same group use the canonical portal_id (gid:). When the same +// group is ingested via different CloudKit records, one row may have +// portal_id=gid: while another has portal_id=gid:. This +// inconsistency causes createPortalsFromCloudSync to see two distinct portals +// for the same group, leading to duplicates. Returns the number of rows updated. +func (s *cloudBackfillStore) normalizeGroupChatPortalIDs(ctx context.Context) (int64, error) { + // For each cloud_chat row with a gid: portal_id where the UUID does NOT + // match the row's own group_id, update portal_id to gid:. + // This only applies when group_id is known and the portal_id uses a + // different UUID (i.e. the chat_id). + res, err := s.db.Exec(ctx, ` + UPDATE cloud_chat + SET portal_id = 'gid:' || LOWER(group_id) + WHERE login_id = $1 + AND group_id <> '' + AND portal_id LIKE 'gid:%' + AND LOWER(SUBSTR(portal_id, 5)) <> LOWER(group_id) + `, s.loginID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// normalizeGroupMessagePortalIDs fixes cloud_message rows where the portal_id +// uses a UUID that differs from the canonical group_id → portal_id mapping in +// cloud_chat. This happens when resolveConversationID used the CloudKit chat_id +// UUID (before the getChatPortalID-first fix) instead of the group_id UUID. +// Returns the number of rows updated. +func (s *cloudBackfillStore) normalizeGroupMessagePortalIDs(ctx context.Context) (int64, error) { + // Find cloud_message rows with gid: portal_ids where the UUID matches + // a cloud_chat row's group_id but the portal_id doesn't match. + // Update them to use the canonical portal_id from cloud_chat. + res, err := s.db.Exec(ctx, ` + UPDATE cloud_message + SET portal_id = ( + SELECT cc.portal_id FROM cloud_chat cc + WHERE cc.login_id = cloud_message.login_id + AND (LOWER(cc.group_id) = LOWER(SUBSTR(cloud_message.portal_id, 5)) + OR LOWER(cc.cloud_chat_id) = LOWER(SUBSTR(cloud_message.portal_id, 5))) + AND cc.portal_id <> cloud_message.portal_id + AND cc.portal_id <> '' + LIMIT 1 + ) + WHERE login_id = $1 + AND portal_id LIKE 'gid:%' + AND EXISTS ( + SELECT 1 FROM cloud_chat cc + WHERE cc.login_id = cloud_message.login_id + AND (LOWER(cc.group_id) = LOWER(SUBSTR(cloud_message.portal_id, 5)) + OR LOWER(cc.cloud_chat_id) = LOWER(SUBSTR(cloud_message.portal_id, 5))) + AND cc.portal_id <> cloud_message.portal_id + AND cc.portal_id <> '' + ) + `, s.loginID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// getMessageRecordNamesByGroupID returns all non-empty message record_names +// for portals that share the given group_id. +func (s *cloudBackfillStore) getMessageRecordNamesByGroupID(ctx context.Context, groupID string) ([]string, error) { + rows, err := s.db.Query(ctx, ` + SELECT cm.record_name FROM cloud_message cm + INNER JOIN cloud_chat cc ON cc.login_id=cm.login_id AND cc.portal_id=cm.portal_id + WHERE cm.login_id=$1 AND LOWER(cc.group_id)=LOWER($2) AND cm.record_name <> '' + `, s.loginID, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err = rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +// getRecordNameByGUID returns the CloudKit record_name for a message GUID. +// Returns empty string if not found. Used for iCloud message deletion. +func (s *cloudBackfillStore) getRecordNameByGUID(ctx context.Context, guid string) string { + var recordName string + err := s.db.QueryRow(ctx, + `SELECT record_name FROM cloud_message WHERE login_id=$1 AND guid=$2 AND record_name <> ''`, + s.loginID, guid, + ).Scan(&recordName) + if err != nil { + return "" + } + return recordName +} + +// softDeleteMessageByGUID marks a cloud_message row as deleted=TRUE so it won't +// be re-bridged on backfill, while preserving the UUID for echo detection. +func (s *cloudBackfillStore) softDeleteMessageByGUID(ctx context.Context, guid string) { + _, _ = s.db.Exec(ctx, + `UPDATE cloud_message SET deleted=TRUE WHERE login_id=$1 AND guid=$2`, + s.loginID, guid, + ) +} + +// getGroupPhotoByPortalID returns the group_photo_guid and record_name for +// the most recently updated cloud_chat row that has a group photo set. +// Returns ("", "", nil) if no photo is set. +func (s *cloudBackfillStore) getGroupPhotoByPortalID(ctx context.Context, portalID string) (guid, recordName string, err error) { + var g, r sql.NullString + err = s.db.QueryRow(ctx, + `SELECT group_photo_guid, record_name FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND group_photo_guid IS NOT NULL AND group_photo_guid <> '' ORDER BY updated_ts DESC LIMIT 1`, + s.loginID, portalID, + ).Scan(&g, &r) + if err != nil { + if err == sql.ErrNoRows { + return "", "", nil + } + return "", "", err + } + if g.Valid { + rStr := "" + if r.Valid { + rStr = r.String + } + return g.String, rStr, nil + } + return "", "", nil +} + +// clearGroupPhotoGuid sets group_photo_guid = NULL for all cloud_chat rows +// matching a portal_id. Called when a real-time IconChange(cleared) arrives so +// that subsequent GetChatInfo calls know there is no custom photo. +func (s *cloudBackfillStore) clearGroupPhotoGuid(ctx context.Context, portalID string) error { + _, err := s.db.Exec(ctx, + `UPDATE cloud_chat SET group_photo_guid = NULL WHERE login_id = $1 AND portal_id = $2`, + s.loginID, portalID, + ) + return err +} + +// saveGroupPhoto persists MMCS-downloaded group photo bytes and the IconChange +// timestamp (used as avatar ID) to the group_photo_cache table. +// UPSERT so it works regardless of whether a cloud_chat row exists yet. +func (s *cloudBackfillStore) saveGroupPhoto(ctx context.Context, portalID string, ts int64, data []byte) error { + _, err := s.db.Exec(ctx, + `INSERT INTO group_photo_cache (login_id, portal_id, ts, data) VALUES ($1, $2, $3, $4) + ON CONFLICT (login_id, portal_id) DO UPDATE SET ts=excluded.ts, data=excluded.data`, + s.loginID, portalID, ts, data, + ) + return err +} + +// getGroupPhoto returns the locally cached group photo bytes and timestamp for +// the given portal. Returns (0, nil, nil) if no cached photo exists. +func (s *cloudBackfillStore) getGroupPhoto(ctx context.Context, portalID string) (ts int64, data []byte, err error) { + err = s.db.QueryRow(ctx, + `SELECT ts, data FROM group_photo_cache WHERE login_id=$1 AND portal_id=$2`, + s.loginID, portalID, + ).Scan(&ts, &data) + if err == sql.ErrNoRows { + return 0, nil, nil + } + return ts, data, err +} + +// clearGroupPhoto removes the cached group photo for a portal. +// Called when an IconChange(cleared) arrives so GetChatInfo won't +// re-apply a stale photo after restart. +func (s *cloudBackfillStore) clearGroupPhoto(ctx context.Context, portalID string) error { + _, err := s.db.Exec(ctx, + `DELETE FROM group_photo_cache WHERE login_id=$1 AND portal_id=$2`, + s.loginID, portalID, + ) + return err +} + +// updateDisplayNameByPortalID updates the display_name for all cloud_chat +// rows matching a portal_id. Used when a real-time rename event arrives to +// correct stale CloudKit data in the local cache. +func (s *cloudBackfillStore) updateDisplayNameByPortalID(ctx context.Context, portalID, displayName string) error { + _, err := s.db.Exec(ctx, + `UPDATE cloud_chat SET display_name=$1 WHERE login_id=$2 AND portal_id=$3`, + displayName, s.loginID, portalID, + ) + return err +} + +// deleteLocalChatByPortalID soft-deletes the cloud_chat and cloud_message +// records for a portal (sets deleted=TRUE, preserves the rows). +// +// cloud_chat is soft-deleted so that restore-chat can recover group name and +// participant data. Queries that drive portal creation (listPortalIDsWithNewestTimestamp, +// portalHasChat) filter on deleted=FALSE so soft-deleted rows don't resurrect portals. +// +// cloud_message is soft-deleted, NOT hard-deleted. The rows' GUIDs must survive +// so that hasMessageUUID can detect stale APNs echoes even after: +// - pending_cloud_deletion is cleared (CloudKit deletion finished) +// - Bridge restarts (recentlyDeletedPortals is repopulated from pending entries, +// but once cleared there is no in-memory protection) +// +// hasMessageUUID queries cloud_message without filtering on deleted, so +// soft-deleted UUIDs still block echoes. Fresh UUIDs from genuinely new +// messages are not in cloud_message → hasMessageUUID=false → portal allowed. +// +// All other cloud_message queries (listLatestMessages, hasPortalMessages, +// getOldestMessageTimestamp, FetchMessages) filter WHERE deleted=FALSE, so +// soft-deleted rows don't trigger backfill or portal resurrection. +func (s *cloudBackfillStore) deleteLocalChatByPortalID(ctx context.Context, portalID string) error { + nowMS := time.Now().UnixMilli() + // Only update rows not already deleted. Re-stamping already-deleted rows + // would push updated_ts to now, breaking tail-timestamp gating (the + // deleted tail would jump to the current time, suppressing all future + // messages as "not newer than deleted tail"). + if _, err := s.db.Exec(ctx, + `UPDATE cloud_chat SET deleted=TRUE, updated_ts=$3 WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE`, + s.loginID, portalID, nowMS, + ); err != nil { + return fmt.Errorf("failed to soft-delete cloud_chat records for portal %s: %w", portalID, err) + } + if _, err := s.db.Exec(ctx, + `UPDATE cloud_message SET deleted=TRUE, updated_ts=$3 WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE`, + s.loginID, portalID, nowMS, + ); err != nil { + return fmt.Errorf("failed to soft-delete cloud_message records for portal %s: %w", portalID, err) + } + // For gid: portals, also soft-delete rows stored under a different + // portal_id that share the same group_id. CloudKit chat_id UUIDs can + // differ from group_id UUIDs, causing rows to be stored under + // gid: while the bridge portal uses gid: or vice + // versa. Without this, the mismatched rows survive and recreate the + // portal on restart. + if strings.HasPrefix(portalID, "gid:") { + uuid := strings.TrimPrefix(portalID, "gid:") + if _, err := s.db.Exec(ctx, + `UPDATE cloud_chat SET deleted=TRUE, updated_ts=$3 WHERE login_id=$1 AND (LOWER(group_id)=LOWER($2) OR LOWER(cloud_chat_id)=LOWER($2)) AND deleted=FALSE`, + s.loginID, uuid, nowMS, + ); err != nil { + return fmt.Errorf("failed to soft-delete cloud_chat records by group_id %s: %w", uuid, err) + } + if _, err := s.db.Exec(ctx, + `UPDATE cloud_message SET deleted=TRUE, updated_ts=$3 + WHERE login_id=$1 AND deleted=FALSE + AND portal_id IN (SELECT portal_id FROM cloud_chat WHERE login_id=$1 AND (LOWER(group_id)=LOWER($2) OR LOWER(cloud_chat_id)=LOWER($2)))`, + s.loginID, uuid, nowMS, + ); err != nil { + return fmt.Errorf("failed to soft-delete cloud_message records by group_id %s: %w", uuid, err) + } + } + return nil +} + +// undeleteCloudChatByPortalID clears the chat-level deleted flag without +// restoring transcript rows. Used when genuinely newer traffic revives a +// deleted chat: the chat shell becomes live again, while soft-deleted message +// rows continue to suppress stale UUID echoes. +func (s *cloudBackfillStore) undeleteCloudChatByPortalID(ctx context.Context, portalID string) error { + if _, err := s.db.Exec(ctx, + `UPDATE cloud_chat + SET deleted=FALSE, updated_ts=$3, fwd_backfill_done=0 + WHERE login_id=$1 AND portal_id=$2 AND deleted=TRUE`, + s.loginID, portalID, time.Now().UnixMilli(), + ); err != nil { + return fmt.Errorf("failed to undelete cloud_chat for portal %s: %w", portalID, err) + } + return nil +} + +// hardDeleteMessagesByPortalID permanently removes all cloud_message rows for +// a portal. Used during chat recovery to purge potentially stale rows before +// re-importing fresh messages from CloudKit. Unlike soft-delete (which preserves +// rows for echo detection), hard-delete is appropriate here because the fresh +// CloudKit fetch will re-populate the correct rows immediately after. +func (s *cloudBackfillStore) hardDeleteMessagesByPortalID(ctx context.Context, portalID string) (int64, error) { + result, err := s.db.Exec(ctx, + `DELETE FROM cloud_message WHERE login_id=$1 AND portal_id=$2`, + s.loginID, portalID, + ) + if err != nil { + return 0, fmt.Errorf("failed to hard-delete cloud_message rows for portal %s: %w", portalID, err) + } + n, _ := result.RowsAffected() + return n, nil +} + +// resetForwardBackfillDone unconditionally sets fwd_backfill_done=0 for all +// cloud_chat rows of a portal so forward backfill re-runs. Used during chat +// recovery where the cloud_chat may or may not be soft-deleted. +func (s *cloudBackfillStore) resetForwardBackfillDone(ctx context.Context, portalID string) error { + _, err := s.db.Exec(ctx, + `UPDATE cloud_chat SET fwd_backfill_done=0 WHERE login_id=$1 AND portal_id=$2`, + s.loginID, portalID, + ) + return err +} + +// persistMessageUUID inserts a minimal cloud_message record for a realtime +// APNs message so the UUID survives restarts. CloudKit-synced messages are +// already stored via upsertMessageBatch; this covers the realtime path. +// Uses INSERT OR IGNORE so it's safe to call even if the message already exists. +func (s *cloudBackfillStore) persistMessageUUID(ctx context.Context, uuid, portalID string, timestampMS int64, isFromMe bool) error { + nowMS := time.Now().UnixMilli() + _, err := s.db.Exec(ctx, ` + INSERT OR IGNORE INTO cloud_message (login_id, guid, portal_id, timestamp_ms, is_from_me, created_ts, updated_ts) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, s.loginID, uuid, portalID, timestampMS, isFromMe, nowMS, nowMS) + return err +} + +// persistTapbackUUID inserts a minimal cloud_message record for a realtime APNs +// tapback so its UUID survives restarts. Unlike persistMessageUUID it sets +// tapback_type, ensuring getConversationReadByMe (which filters tapback_type IS +// NULL) does not treat the synthetic row as a substantive message and does not +// spuriously flip conversation read state for incoming reactions. +func (s *cloudBackfillStore) persistTapbackUUID(ctx context.Context, uuid, portalID string, timestampMS int64, isFromMe bool, tapbackType uint32) error { + nowMS := time.Now().UnixMilli() + _, err := s.db.Exec(ctx, ` + INSERT OR IGNORE INTO cloud_message (login_id, guid, portal_id, timestamp_ms, is_from_me, tapback_type, created_ts, updated_ts) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, s.loginID, uuid, portalID, timestampMS, isFromMe, tapbackType, nowMS, nowMS) + return err +} + +// hasMessageUUID checks if a message UUID exists in cloud_message for this login. +// Used for echo detection: if the UUID is known, the message is an echo of a +// previously-seen message and should not create a new portal. +func (s *cloudBackfillStore) hasMessageUUID(ctx context.Context, uuid string) (bool, error) { + var count int + // UPPER() on both sides: CloudKit GUIDs are lowercase, APNs UUIDs are + // uppercase, and incoming SMS constant_uuid values may vary in case. + // A case-sensitive match would miss cross-path duplicates (e.g. a message + // CloudKit-backfilled as lowercase then re-delivered by APNs as uppercase). + // Mirrors the pattern used in getMessageTimestampByGUID. + err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM cloud_message WHERE login_id=$1 AND UPPER(guid)=UPPER($2) LIMIT 1`, + s.loginID, uuid, + ).Scan(&count) + return count > 0, err +} + +// getMessageTimestampByGUID returns the Unix-millisecond send timestamp for a +// message UUID, and whether the row was found. Used to enforce the pre-startup +// receipt filter when the message is still being backfilled into the Matrix DB +// (so the Matrix DB lookup returns nothing but CloudKit already has the record). +// getMessageTextByGUID returns the text body of a message by UUID. +// Used when sending SMS/RCS reactions to include the original message text in the +// reaction string (e.g. "Loved "original message""). Returns "" if not found. +// Case-insensitive UUID comparison mirrors getMessageTimestampByGUID. +func (s *cloudBackfillStore) getMessageTextByGUID(ctx context.Context, uuid string) (string, error) { + var text sql.NullString + err := s.db.QueryRow(ctx, + `SELECT text FROM cloud_message WHERE login_id=$1 AND UPPER(guid)=UPPER($2) AND tapback_type IS NULL LIMIT 1`, + s.loginID, uuid, + ).Scan(&text) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + return text.String, nil +} + +func (s *cloudBackfillStore) getMessageTimestampByGUID(ctx context.Context, uuid string) (int64, bool, error) { + var ts int64 + // UPPER() on both sides: APNs delivers UUIDs as uppercase while CloudKit + // GUIDs may be lowercase or mixed-case, so a case-sensitive = would miss them. + err := s.db.QueryRow(ctx, + `SELECT timestamp_ms FROM cloud_message WHERE login_id=$1 AND UPPER(guid)=UPPER($2) LIMIT 1`, + s.loginID, uuid, + ).Scan(&ts) + if err == sql.ErrNoRows { + return 0, false, nil + } + return ts, err == nil, err +} + +// portalHasPreStartupOutgoingMessages returns true if the portal has any +// is_from_me messages with timestamp_ms < beforeMS. Used to detect portals +// that were backfilled this session: if the portal has outgoing messages +// predating startup, a live APNs read receipt arriving near startup is +// almost certainly a buffered re-delivery (APNs re-delivers them with +// TimestampMs = now on reconnect) rather than a genuine new read event. +func (s *cloudBackfillStore) portalHasPreStartupOutgoingMessages(ctx context.Context, portalID string, beforeMS int64) (bool, error) { + var count int + err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM cloud_message WHERE login_id=$1 AND portal_id=$2 AND is_from_me=TRUE AND timestamp_ms < $3 LIMIT 1`, + s.loginID, portalID, beforeMS, + ).Scan(&count) + return count > 0, err +} + +// findPortalIDsByParticipants returns all distinct portal_ids from cloud_chat +// whose participants overlap with the given normalized participant list. +// Used to find duplicate group portals that have the same members but different +// group UUIDs. Participants are compared after normalization (tel:/mailto: prefix). +func (s *cloudBackfillStore) findPortalIDsByParticipants(ctx context.Context, normalizedTarget []string, isSelf func(string) bool) ([]string, error) { + rows, err := s.db.Query(ctx, + `SELECT DISTINCT portal_id, participants_json FROM cloud_chat WHERE login_id=$1 AND portal_id <> '' AND deleted=FALSE`, + s.loginID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + // Build a set of target participants for fast lookup. + targetSet := make(map[string]bool, len(normalizedTarget)) + for _, p := range normalizedTarget { + targetSet[p] = true + } + + var matches []string + seen := make(map[string]bool) + for rows.Next() { + var portalID, participantsJSON string + if err = rows.Scan(&portalID, &participantsJSON); err != nil { + return nil, err + } + if seen[portalID] { + continue + } + var participants []string + if err = json.Unmarshal([]byte(participantsJSON), &participants); err != nil { + continue + } + // Normalize and check overlap: match if all non-self participants overlap. + normalized := make([]string, 0, len(participants)) + for _, p := range participants { + n := normalizeIdentifierForPortalID(p) + if n != "" { + normalized = append(normalized, n) + } + } + if participantSetsMatch(normalized, normalizedTarget, isSelf) { + matches = append(matches, portalID) + seen[portalID] = true + } + } + return matches, rows.Err() +} + +// participantSetsMatch checks if two normalized participant sets are equivalent +// (same members, ignoring order). Allows a difference of exactly 1 only if the +// single differing member is self (checked via isSelf predicate, which should +// test against all known user handles). Pass nil isSelf to disallow any difference. +func participantSetsMatch(a, b []string, isSelf func(string) bool) bool { + if len(a) == 0 || len(b) == 0 { + return false + } + setA := make(map[string]bool, len(a)) + for _, p := range a { + setA[p] = true + } + setB := make(map[string]bool, len(b)) + for _, p := range b { + setB[p] = true + } + // Count members in A not in B, and vice versa; track whether ALL + // differing members are self handles. + allDiffAreSelf := true + diff := 0 + for p := range setA { + if !setB[p] { + diff++ + if isSelf == nil || !isSelf(p) { + allDiffAreSelf = false + } + } + } + for p := range setB { + if !setA[p] { + diff++ + if isSelf == nil || !isSelf(p) { + allDiffAreSelf = false + } + } + } + return diff == 0 || (allDiffAreSelf && isSelf != nil) +} + +// deleteLocalChatByGroupID removes all local cloud_chat and cloud_message records +// for any portal_id that shares the given group_id. +func (s *cloudBackfillStore) deleteLocalChatByGroupID(ctx context.Context, groupID string) error { + // Find all portal_ids for this group + rows, err := s.db.Query(ctx, + `SELECT DISTINCT portal_id FROM cloud_chat WHERE login_id=$1 AND (LOWER(group_id)=LOWER($2) OR LOWER(cloud_chat_id)=LOWER($2)) AND portal_id <> ''`, + s.loginID, groupID, + ) + if err != nil { + return err + } + defer rows.Close() + + var portalIDs []string + for rows.Next() { + var pid string + if err = rows.Scan(&pid); err != nil { + return err + } + portalIDs = append(portalIDs, pid) + } + if err = rows.Err(); err != nil { + return err + } + + for _, pid := range portalIDs { + if err := s.deleteLocalChatByPortalID(ctx, pid); err != nil { + return err + } + } + return nil +} + +// getOldestMessageTimestamp returns the oldest non-deleted message timestamp +// for a portal, or 0 if no messages exist. +func (s *cloudBackfillStore) getOldestMessageTimestamp(ctx context.Context, portalID string) (int64, error) { + var ts sql.NullInt64 + err := s.db.QueryRow(ctx, ` + SELECT MIN(timestamp_ms) + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE + `, s.loginID, portalID).Scan(&ts) + if err != nil || !ts.Valid { + return 0, err + } + return ts.Int64, nil +} + +// getNewestMessageTimestamp returns the newest non-deleted message timestamp +// for a portal, or 0 if no messages exist. +func (s *cloudBackfillStore) getNewestMessageTimestamp(ctx context.Context, portalID string) (int64, error) { + var ts sql.NullInt64 + err := s.db.QueryRow(ctx, ` + SELECT MAX(timestamp_ms) + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE + `, s.loginID, portalID).Scan(&ts) + if err != nil || !ts.Valid { + return 0, err + } + return ts.Int64, nil +} + +// getNewestBackfillableMessageTimestamp returns the newest timestamp for messages +// that FetchMessages can actually serve (deleted=FALSE, record_name <> ”). +// When requireContentful is true, rows must have text or attachments. +func (s *cloudBackfillStore) getNewestBackfillableMessageTimestamp(ctx context.Context, portalID string, requireContentful bool) (int64, error) { + baseQuery := ` + SELECT MAX(timestamp_ms) + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' + ` + if requireContentful { + baseQuery += " AND (COALESCE(text, '') <> '' OR COALESCE(attachments_json, '') <> '')" + } + var ts sql.NullInt64 + err := s.db.QueryRow(ctx, baseQuery, s.loginID, portalID).Scan(&ts) + if err != nil || !ts.Valid { + return 0, err + } + return ts.Int64, nil +} + +// healMisroutedGroupMessages fixes cloud_message rows that were incorrectly +// routed to the wrong portal (typically self-chat) due to the ";+;" CloudChatId +// routing bug. It uses two matching strategies: +// +// 1. cloud_chat_id match: the hex suffix after ";+;" in chat_id equals the +// cloud_chat_id stored in cloud_chat (works when cloud_chat has a chat_id). +// +// 2. portal_id UUID match: the hex suffix after ";+;" equals the UUID part of +// a "gid:" portal_id (works for per-participant UUID portals whose +// cloud_chat_id was seeded as empty). +// +// Both deleted and live cloud_chat rows are considered — messages should be +// assigned to their correct portal even if it's currently soft-deleted (it will +// be undeleted when the user runs !restore-chat). +// Returns the number of rows fixed. +func (s *cloudBackfillStore) healMisroutedGroupMessages(ctx context.Context) (int, error) { + now := time.Now().UnixMilli() + result, err := s.db.Exec(ctx, ` + UPDATE cloud_message AS m + SET portal_id = cc.portal_id, + updated_ts = $2 + FROM cloud_chat cc + WHERE m.login_id = $1 + AND cc.login_id = $1 + AND m.chat_id LIKE '%;+;%' + AND ( + -- Strategy 1: cloud_chat_id matches the hex suffix after ";+;" + (cc.cloud_chat_id <> '' AND + LOWER(cc.cloud_chat_id) = LOWER(SUBSTR(m.chat_id, INSTR(m.chat_id, ';+;') + 3))) + OR + -- Strategy 2: portal_id is "gid:" and uuid matches hex suffix + (cc.portal_id LIKE 'gid:%' AND + LOWER(SUBSTR(cc.portal_id, 5)) = LOWER(SUBSTR(m.chat_id, INSTR(m.chat_id, ';+;') + 3))) + ) + AND m.portal_id <> cc.portal_id + AND cc.portal_id IS NOT NULL + AND cc.portal_id <> '' + `, s.loginID, now) + if err != nil { + return 0, err + } + n, _ := result.RowsAffected() + + // Soft-delete cloud_chat rows for any portal that now has zero messages — + // those portals were purely phantom portals created by the misrouting bug + // (e.g. the self-chat). Keeping them live causes CloudKit sync to zombie + // them back on the next resync. We only delete NON-gid: portals (DMs) + // because a gid: portal with 0 messages might still need to be restored. + if n > 0 { + _, _ = s.db.Exec(ctx, ` + UPDATE cloud_chat + SET deleted = TRUE, updated_ts = $2 + WHERE login_id = $1 + AND deleted = FALSE + AND portal_id NOT LIKE 'gid:%' + AND portal_id IS NOT NULL AND portal_id <> '' + AND NOT EXISTS ( + SELECT 1 FROM cloud_message + WHERE login_id = $1 + AND portal_id = cloud_chat.portal_id + AND deleted = FALSE + ) + `, s.loginID, now) + } + + return int(n), nil +} + +func (s *cloudBackfillStore) hasPortalMessages(ctx context.Context, portalID string) (bool, error) { + var count int + err := s.db.QueryRow(ctx, ` + SELECT COUNT(*) + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' + `, s.loginID, portalID).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// hasContentfulMessages checks if a portal has at least one non-deleted message +// with actual content (text or attachments). Seeded placeholder rows from the +// recycle bin have record_name but empty text/attachments — they don't count. +func (s *cloudBackfillStore) hasContentfulMessages(ctx context.Context, portalID string) (bool, error) { + var count int + err := s.db.QueryRow(ctx, ` + SELECT COUNT(*) + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' + AND (COALESCE(text, '') <> '' OR COALESCE(attachments_json, '') <> '') + `, s.loginID, portalID).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// countBackfillableMessages returns the number of rows FetchMessages can read +// for a portal (deleted=FALSE and record_name <> ”). +// When requireContentful is true, only rows with text or attachments count. +func (s *cloudBackfillStore) countBackfillableMessages(ctx context.Context, portalID string, requireContentful bool) (int, error) { + query := ` + SELECT COUNT(*) + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' + ` + if requireContentful { + query += " AND (COALESCE(text, '') <> '' OR COALESCE(attachments_json, '') <> '')" + } + var count int + if err := s.db.QueryRow(ctx, query, s.loginID, portalID).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +const cloudMessageSelectCols = `guid, COALESCE(chat_id, ''), portal_id, timestamp_ms, COALESCE(sender, ''), is_from_me, + COALESCE(text, ''), COALESCE(subject, ''), COALESCE(service, ''), deleted, + tapback_type, COALESCE(tapback_target_guid, ''), COALESCE(tapback_emoji, ''), + COALESCE(attachments_json, ''), COALESCE(date_read_ms, 0), COALESCE(has_body, TRUE)` + +func (s *cloudBackfillStore) listBackwardMessages( + ctx context.Context, + portalID string, + beforeTS int64, + beforeGUID string, + count int, +) ([]cloudMessageRow, error) { + // Filter record_name <> '' to exclude stub rows from persistMessageUUID. + query := `SELECT ` + cloudMessageSelectCols + ` + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' + ` + args := []any{s.loginID, portalID} + if beforeTS > 0 || beforeGUID != "" { + query += ` AND (timestamp_ms < $3 OR (timestamp_ms = $3 AND guid < $4))` + args = append(args, beforeTS, beforeGUID) + query += ` ORDER BY timestamp_ms DESC, guid DESC LIMIT $5` + args = append(args, count) + } else { + query += ` ORDER BY timestamp_ms DESC, guid DESC LIMIT $3` + args = append(args, count) + } + return s.queryMessages(ctx, query, args...) +} + +func (s *cloudBackfillStore) listForwardMessages( + ctx context.Context, + portalID string, + afterTS int64, + afterGUID string, + count int, +) ([]cloudMessageRow, error) { + // Filter record_name <> '' to exclude stub rows from persistMessageUUID. + query := `SELECT ` + cloudMessageSelectCols + ` + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' + AND (timestamp_ms > $3 OR (timestamp_ms = $3 AND guid > $4)) + ORDER BY timestamp_ms ASC, guid ASC + LIMIT $5 + ` + return s.queryMessages(ctx, query, s.loginID, portalID, afterTS, afterGUID, count) +} + +func (s *cloudBackfillStore) listLatestMessages(ctx context.Context, portalID string, count int) ([]cloudMessageRow, error) { + // Filter record_name <> '' to exclude stub rows from persistMessageUUID + // which have UUIDs for echo detection but no actual message content. + query := `SELECT ` + cloudMessageSelectCols + ` + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' + ORDER BY timestamp_ms DESC, guid DESC + LIMIT $3 + ` + return s.queryMessages(ctx, query, s.loginID, portalID, count) +} + +// listOldestMessages returns the oldest `count` non-deleted messages for a +// portal in chronological order (ASC). Used by forward backfill chunking to +// deliver messages starting from the beginning of conversation history. +func (s *cloudBackfillStore) listOldestMessages(ctx context.Context, portalID string, count int) ([]cloudMessageRow, error) { + // Filter record_name <> '' to exclude stub rows from persistMessageUUID. + query := `SELECT ` + cloudMessageSelectCols + ` + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' + ORDER BY timestamp_ms ASC, guid ASC + LIMIT $3 + ` + return s.queryMessages(ctx, query, s.loginID, portalID, count) +} + +// listAllAttachmentMessages returns every non-deleted cloud_message row that +// has at least one attachment. Used by preUploadCloudAttachments to drive the +// pre-upload pass before portal creation. +func (s *cloudBackfillStore) listAllAttachmentMessages(ctx context.Context) ([]cloudMessageRow, error) { + query := `SELECT ` + cloudMessageSelectCols + ` + FROM cloud_message + WHERE login_id=$1 + AND deleted=FALSE + AND attachments_json IS NOT NULL + AND attachments_json <> '' + ORDER BY timestamp_ms ASC, guid ASC + ` + return s.queryMessages(ctx, query, s.loginID) +} + +func (s *cloudBackfillStore) queryMessages(ctx context.Context, query string, args ...any) ([]cloudMessageRow, error) { + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]cloudMessageRow, 0) + for rows.Next() { + var row cloudMessageRow + if err = rows.Scan( + &row.GUID, + &row.CloudChatID, + &row.PortalID, + &row.TimestampMS, + &row.Sender, + &row.IsFromMe, + &row.Text, + &row.Subject, + &row.Service, + &row.Deleted, + &row.TapbackType, + &row.TapbackTargetGUID, + &row.TapbackEmoji, + &row.AttachmentsJSON, + &row.DateReadMS, + &row.HasBody, + ); err != nil { + return nil, err + } + out = append(out, row) + } + if err = rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// portalWithNewestMessage pairs a portal ID with its newest message timestamp +// and message count. Used to prioritize portal creation during initial sync. +type portalWithNewestMessage struct { + PortalID string + NewestTS int64 + MessageCount int +} + +// listPortalIDsWithNewestTimestamp returns all portal IDs from both messages +// and chat records, ordered by newest message timestamp descending (most +// recent activity first). Chat-only portals (no messages) are included with +// their updated_ts from the cloud_chat table so they still get portals created. +func (s *cloudBackfillStore) listPortalIDsWithNewestTimestamp(ctx context.Context) ([]portalWithNewestMessage, error) { + rows, err := s.db.Query(ctx, ` + SELECT sub.portal_id, MAX(sub.newest_ts) AS newest_ts, SUM(sub.msg_count) AS msg_count FROM ( + SELECT portal_id, MAX(timestamp_ms) AS newest_ts, COUNT(*) AS msg_count + FROM cloud_message + WHERE login_id=$1 AND portal_id IS NOT NULL AND portal_id <> '' AND deleted=FALSE AND record_name <> '' + GROUP BY portal_id + + UNION ALL + + SELECT cc.portal_id, COALESCE(cc.updated_ts, 0) AS newest_ts, 0 AS msg_count + FROM cloud_chat cc + WHERE cc.login_id=$1 AND cc.portal_id IS NOT NULL AND cc.portal_id <> '' + AND COALESCE(cc.is_filtered, 0) = 0 + AND cc.deleted = FALSE + AND cc.portal_id NOT IN ( + SELECT DISTINCT cm.portal_id FROM cloud_message cm + WHERE cm.login_id=$1 AND cm.portal_id IS NOT NULL AND cm.portal_id <> '' AND cm.deleted=FALSE + ) + ) sub + WHERE NOT EXISTS ( + SELECT 1 FROM cloud_chat fc + WHERE fc.login_id=$1 AND fc.portal_id=sub.portal_id AND COALESCE(fc.is_filtered, 0) != 0 + ) + GROUP BY sub.portal_id + ORDER BY newest_ts DESC + `, s.loginID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []portalWithNewestMessage + for rows.Next() { + var p portalWithNewestMessage + if err = rows.Scan(&p.PortalID, &p.NewestTS, &p.MessageCount); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// msgDebugPortalStat is one row returned by debugMessageStats. +type msgDebugPortalStat struct { + PortalID string + Total int + FromMe int + NotFromMe int + EmptySender int // not-from-me rows with empty sender (filtered by cloudRowToBackfillMessages) + SampleChats []string // up to 5 distinct chat_ids seen for this portal + SampleSenders []string // up to 5 distinct sender values for not-from-me rows +} + +// debugMessageStats returns per-portal message statistics for the given +// primary portal_id AND any sibling portals that share the same group_id in +// cloud_chat. For DMs it only returns the one portal row. +// Used by the !msg-debug command. +func (s *cloudBackfillStore) debugMessageStats(ctx context.Context, portalID string) ([]msgDebugPortalStat, error) { + // Step 1: collect all portal_ids to inspect — the given one plus siblings + // that share the same group_id. + siblingQuery := ` + SELECT DISTINCT cc2.portal_id + FROM cloud_chat cc1 + JOIN cloud_chat cc2 + ON cc2.login_id = cc1.login_id + AND cc2.group_id = cc1.group_id + AND cc2.group_id <> '' + WHERE cc1.login_id=$1 AND cc1.portal_id=$2 + ` + sibRows, err := s.db.Query(ctx, siblingQuery, s.loginID, portalID) + if err != nil { + return nil, err + } + portals := []string{portalID} + seenPortals := map[string]bool{portalID: true} + for sibRows.Next() { + var pid string + if err = sibRows.Scan(&pid); err != nil { + sibRows.Close() + return nil, err + } + if !seenPortals[pid] { + seenPortals[pid] = true + portals = append(portals, pid) + } + } + sibRows.Close() + if err = sibRows.Err(); err != nil { + return nil, err + } + + // Step 2: for each portal, get message counts and sample chat_ids. + var stats []msgDebugPortalStat + for _, pid := range portals { + var stat msgDebugPortalStat + stat.PortalID = pid + + countErr := s.db.QueryRow(ctx, ` + SELECT + COUNT(*), + SUM(CASE WHEN is_from_me THEN 1 ELSE 0 END), + SUM(CASE WHEN NOT is_from_me THEN 1 ELSE 0 END), + SUM(CASE WHEN NOT is_from_me AND COALESCE(sender,'') = '' THEN 1 ELSE 0 END) + FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' + `, s.loginID, pid).Scan(&stat.Total, &stat.FromMe, &stat.NotFromMe, &stat.EmptySender) + if countErr != nil { + continue + } + + // Sample up to 5 distinct non-empty chat_ids from this portal. + chatRows, chatErr := s.db.Query(ctx, ` + SELECT DISTINCT chat_id FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND COALESCE(chat_id,'') <> '' + LIMIT 5 + `, s.loginID, pid) + if chatErr == nil { + for chatRows.Next() { + var cid string + if scanErr := chatRows.Scan(&cid); scanErr == nil { + stat.SampleChats = append(stat.SampleChats, cid) + } + } + chatRows.Close() + } + + // Sample up to 5 distinct sender values for not-from-me rows. + senderRows, senderErr := s.db.Query(ctx, ` + SELECT DISTINCT COALESCE(sender,'') FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE AND record_name <> '' AND NOT is_from_me + LIMIT 5 + `, s.loginID, pid) + if senderErr == nil { + for senderRows.Next() { + var snd string + if scanErr := senderRows.Scan(&snd); scanErr == nil { + stat.SampleSenders = append(stat.SampleSenders, snd) + } + } + senderRows.Close() + } + + stats = append(stats, stat) + } + return stats, nil +} + +// debugFindPortalsByIdentifierSuffix scans cloud_message for portals whose +// stored chat_id ends with the given suffix (case-insensitive). This helps +// find DM messages that ended up under a different portal_id due to contact +// normalization differences (e.g. +19176138320 vs +1 917-613-8320). +// Returns up to 10 (portal_id, total_count) pairs ordered by count desc. +func (s *cloudBackfillStore) debugFindPortalsByIdentifierSuffix(ctx context.Context, suffix string) ([][2]string, error) { + rows, err := s.db.Query(ctx, ` + SELECT portal_id, COUNT(*) as cnt + FROM cloud_message + WHERE login_id=$1 AND deleted=FALSE AND record_name <> '' + AND LOWER(COALESCE(chat_id,'')) LIKE '%' || LOWER($2) + GROUP BY portal_id + ORDER BY cnt DESC + LIMIT 10 + `, s.loginID, suffix) + if err != nil { + return nil, err + } + defer rows.Close() + var out [][2]string + for rows.Next() { + var pid string + var cnt int + if err = rows.Scan(&pid, &cnt); err != nil { + return nil, err + } + out = append(out, [2]string{pid, strconv.Itoa(cnt)}) + } + return out, rows.Err() +} + +// debugChatInfo returns cloud_chat metadata for a portal (whether the chat +// record has synced, and whether it's filtered/deleted). Used by !msg-debug +// to distinguish "chat not synced yet" from "chat synced but messages missing". +type debugChatInfo struct { + Found bool + Deleted bool + IsFiltered int64 + CloudChatID string + GroupID string +} + +func (s *cloudBackfillStore) debugChatInfo(ctx context.Context, portalID string) (debugChatInfo, error) { + var info debugChatInfo + err := s.db.QueryRow(ctx, ` + SELECT cloud_chat_id, group_id, deleted, COALESCE(is_filtered, 0) + FROM cloud_chat + WHERE login_id=$1 AND portal_id=$2 + ORDER BY deleted ASC, length(cloud_chat_id) DESC + LIMIT 1 + `, s.loginID, portalID).Scan(&info.CloudChatID, &info.GroupID, &info.Deleted, &info.IsFiltered) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return info, nil + } + return info, err + } + info.Found = true + return info, nil +} + +// debugTotalMessageCount returns the total number of non-deleted, non-stub +// cloud_message rows across ALL portals. Used by !msg-debug to show sync +// progress (how many messages have been ingested so far). +func (s *cloudBackfillStore) debugTotalMessageCount(ctx context.Context) (int, error) { + var count int + err := s.db.QueryRow(ctx, ` + SELECT COUNT(*) FROM cloud_message + WHERE login_id=$1 AND deleted=FALSE AND record_name <> '' + `, s.loginID).Scan(&count) + return count, err +} + +func nullableString(value *string) any { + if value == nil { + return nil + } + return *value +} + +// softDeletedPortal describes a portal that is still locally marked deleted +// and can be restored. Some portals only have soft-deleted cloud_chat rows +// (for example if the chat metadata was deleted before messages were imported), +// so restore-chat must not rely solely on cloud_message rows. +type softDeletedPortal struct { + PortalID string + NewestTS int64 + Count int + CloudChatID string + GroupID string + ParticipantsJSON string +} + +// listSoftDeletedPortals returns portals whose cloud_chat rows are still +// soft-deleted with no live replacement. If message rows exist, they're +// included for ordering/counts, but chat-level deletion alone is enough to +// make the portal restorable. +func (s *cloudBackfillStore) listSoftDeletedPortals(ctx context.Context) ([]softDeletedPortal, error) { + rows, err := s.db.Query(ctx, ` + WITH deleted_chats AS ( + SELECT + portal_id, + COALESCE(MAX(updated_ts), 0) AS chat_ts, + MAX(cloud_chat_id) AS cloud_chat_id, + MAX(group_id) AS group_id, + MAX(participants_json) AS participants_json + FROM cloud_chat + WHERE login_id=$1 AND portal_id IS NOT NULL AND portal_id <> '' + GROUP BY portal_id + HAVING MAX(CASE WHEN deleted=TRUE THEN 1 ELSE 0 END) = 1 + AND MAX(CASE WHEN deleted=FALSE THEN 1 ELSE 0 END) = 0 + ), + message_stats AS ( + SELECT + portal_id, + MAX(timestamp_ms) AS newest_ts, + COUNT(*) AS msg_count, + MAX(CASE WHEN deleted=FALSE THEN 1 ELSE 0 END) AS has_live, + MAX(CASE WHEN deleted=TRUE THEN 1 ELSE 0 END) AS has_deleted + FROM cloud_message + WHERE login_id=$1 AND portal_id IS NOT NULL AND portal_id <> '' + GROUP BY portal_id + ) + SELECT + dc.portal_id, + COALESCE(ms.newest_ts, dc.chat_ts) AS newest_ts, + COALESCE(ms.msg_count, 0) AS msg_count, + COALESCE(dc.cloud_chat_id, '') AS cloud_chat_id, + COALESCE(dc.group_id, '') AS group_id, + COALESCE(dc.participants_json, '') AS participants_json + FROM deleted_chats dc + LEFT JOIN message_stats ms ON ms.portal_id=dc.portal_id + WHERE COALESCE(ms.has_live, 0) = 0 + ORDER BY newest_ts DESC + `, s.loginID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []softDeletedPortal + for rows.Next() { + var p softDeletedPortal + if err = rows.Scan(&p.PortalID, &p.NewestTS, &p.Count, &p.CloudChatID, &p.GroupID, &p.ParticipantsJSON); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// cloudChatRecord holds the data needed to re-upload a chat record to CloudKit. +type cloudChatRecord struct { + RecordName string + ChatIdentifier string + GroupID string + Style int64 + Service string + DisplayName *string + Participants []string +} + +// getCloudChatRecordByPortalID returns the full chat record data for a portal. +// Used by restore-chat to re-upload the record to CloudKit. +// Style is derived from the portal_id: gid: prefix = 43 (group), else 45 (DM). +func (s *cloudBackfillStore) getCloudChatRecordByPortalID(ctx context.Context, portalID string) (*cloudChatRecord, error) { + var rec cloudChatRecord + var displayName sql.NullString + var participantsJSON string + err := s.db.QueryRow(ctx, ` + SELECT record_name, cloud_chat_id, group_id, service, display_name, participants_json + FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND record_name <> '' LIMIT 1 + `, s.loginID, portalID).Scan( + &rec.RecordName, &rec.ChatIdentifier, &rec.GroupID, + &rec.Service, &displayName, &participantsJSON, + ) + if err != nil { + return nil, err + } + if displayName.Valid && displayName.String != "" { + rec.DisplayName = &displayName.String + } + if participantsJSON != "" { + _ = json.Unmarshal([]byte(participantsJSON), &rec.Participants) + } + // Derive style from portal_id: gid: = group (43), else DM (45) + if strings.HasPrefix(portalID, "gid:") { + rec.Style = 43 + } else { + rec.Style = 45 + } + return &rec, nil +} + +// undeleteCloudMessagesByPortalID reverses a portal-level soft-delete by +// setting deleted=FALSE on all cloud_message rows for the portal. +// Returns the number of rows updated. +func (s *cloudBackfillStore) undeleteCloudMessagesByPortalID(ctx context.Context, portalID string) (int, error) { + nowMS := time.Now().UnixMilli() + // Un-soft-delete cloud_chat rows so GetChatInfo can resolve group name + // and participants during the ChatResync that follows restore. + if _, err := s.db.Exec(ctx, + `UPDATE cloud_chat SET deleted=FALSE, updated_ts=$3 WHERE login_id=$1 AND portal_id=$2 AND deleted=TRUE`, + s.loginID, portalID, nowMS, + ); err != nil { + return 0, fmt.Errorf("failed to undelete cloud_chat for portal %s: %w", portalID, err) + } + + // ALWAYS reset fwd_backfill_done regardless of deleted state. This ensures + // forward backfill re-runs for the restored portal even if the cloud_chat + // row wasn't soft-deleted (e.g., recover arrived before delete was persisted, + // or the row was only tracked in-memory via recentlyDeletedPortals). + if _, err := s.db.Exec(ctx, + `UPDATE cloud_chat SET fwd_backfill_done=0 WHERE login_id=$1 AND portal_id=$2`, + s.loginID, portalID, + ); err != nil { + return 0, fmt.Errorf("failed to reset fwd_backfill_done for portal %s: %w", portalID, err) + } + + result, err := s.db.Exec(ctx, + `UPDATE cloud_message SET deleted=FALSE, updated_ts=$3 + WHERE login_id=$1 AND portal_id=$2 AND deleted=TRUE`, + s.loginID, portalID, nowMS, + ) + if err != nil { + return 0, err + } + n, _ := result.RowsAffected() + return int(n), nil +} + +// seedChatFromRecycleBin inserts or updates a cloud_chat row with data from +// Apple's recycle bin. This ensures GetChatInfo can resolve group name, +// participants, and style even when the local cloud_chat table was wiped. +func (s *cloudBackfillStore) seedChatFromRecycleBin(ctx context.Context, portalID, chatID, groupID, displayName, groupPhotoGuid string, participants []string) { + if chatID == "" { + chatID = "recycle:" + portalID + } + nowMS := time.Now().UnixMilli() + participantsJSON := "[]" + if len(participants) > 0 { + if b, err := json.Marshal(participants); err == nil { + participantsJSON = string(b) + } + } + var dnPtr *string + if displayName != "" { + dnPtr = &displayName + } + var photoPtr *string + if groupPhotoGuid != "" { + photoPtr = &groupPhotoGuid + } + _, _ = s.db.Exec(ctx, ` + INSERT INTO cloud_chat (login_id, cloud_chat_id, portal_id, group_id, display_name, group_photo_guid, participants_json, created_ts, updated_ts, deleted, fwd_backfill_done) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, FALSE, 0) + ON CONFLICT (login_id, cloud_chat_id) DO UPDATE SET + portal_id=EXCLUDED.portal_id, + display_name=COALESCE(EXCLUDED.display_name, cloud_chat.display_name), + group_photo_guid=COALESCE(EXCLUDED.group_photo_guid, cloud_chat.group_photo_guid), + participants_json=CASE WHEN EXCLUDED.participants_json <> '[]' THEN EXCLUDED.participants_json ELSE cloud_chat.participants_json END, + deleted=FALSE, + updated_ts=EXCLUDED.updated_ts + `, s.loginID, chatID, portalID, groupID, dnPtr, photoPtr, participantsJSON, nowMS) +} + +// loadAttachmentCacheJSON returns every persisted record_name → content_json +// pair for this login. The caller deserialises the JSON into +// *event.MessageEventContent and populates the in-memory attachmentContentCache +// so pre-upload skips already-uploaded attachments without touching CloudKit. +func (s *cloudBackfillStore) loadAttachmentCacheJSON(ctx context.Context) (map[string][]byte, error) { + rows, err := s.db.Query(ctx, + `SELECT record_name, content_json FROM cloud_attachment_cache WHERE login_id=$1`, + s.loginID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + cache := make(map[string][]byte) + for rows.Next() { + var recordName string + var contentJSON []byte + if err := rows.Scan(&recordName, &contentJSON); err != nil { + return nil, err + } + cache[recordName] = contentJSON + } + return cache, rows.Err() +} + +// saveAttachmentCacheEntry persists a record_name → MessageEventContent JSON +// pair. Idempotent (upsert). Errors are silently ignored — the persistent cache +// is a best-effort optimisation; missing entries fall back to re-download. +func (s *cloudBackfillStore) saveAttachmentCacheEntry(ctx context.Context, recordName string, contentJSON []byte) { + _, _ = s.db.Exec(ctx, ` + INSERT INTO cloud_attachment_cache (login_id, record_name, content_json, created_ts) + VALUES ($1, $2, $3, $4) + ON CONFLICT (login_id, record_name) DO UPDATE SET content_json=excluded.content_json + `, s.loginID, recordName, contentJSON, time.Now().UnixMilli()) +} + +// markForwardBackfillDone marks all cloud_chat rows for portalID as having +// completed their initial forward FetchMessages call. Idempotent. Called from +// FetchMessages when the forward pass completes so that preUploadCloudAttachments +// skips this portal on the next restart instead of re-uploading every attachment. +// +// Self-healing: if the UPDATE hits 0 rows (no cloud_chat row matches this +// portal_id), a synthetic row is inserted so the flag persists. This covers +// APNs-created portals, CloudKit portal_id mismatches between cloud_message +// and cloud_chat, and any other case where a portal exists without a +// corresponding cloud_chat row. +func (s *cloudBackfillStore) markForwardBackfillDone(ctx context.Context, portalID string) { + res, err := s.db.Exec(ctx, + `UPDATE cloud_chat SET fwd_backfill_done=1 WHERE login_id=$1 AND portal_id=$2`, + s.loginID, portalID, + ) + + // If the UPDATE failed (cancelled context, DB error) or matched 0 rows + // (no cloud_chat entry for this portal), insert a synthetic row. + // Use context.Background() — this MUST persist even during shutdown. + needsInsert := err != nil || res == nil + if !needsInsert { + if rows, _ := res.RowsAffected(); rows == 0 { + needsInsert = true + } + } + if needsInsert { + _, _ = s.db.Exec(context.Background(), ` + INSERT OR IGNORE INTO cloud_chat (login_id, cloud_chat_id, portal_id, created_ts, fwd_backfill_done) + VALUES ($1, $2, $3, $4, 1)`, + s.loginID, "synthetic:"+portalID, portalID, time.Now().UnixMilli(), + ) + } +} + +// isForwardBackfillDone returns true if forward backfill has completed for the +// given portal. Used by backward backfill to avoid permanently marking +// is_done=true before forward backfill has inserted the anchor message. +func (s *cloudBackfillStore) isForwardBackfillDone(ctx context.Context, portalID string) bool { + var done bool + err := s.db.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM cloud_chat WHERE login_id=$1 AND portal_id=$2 AND fwd_backfill_done=1)`, + s.loginID, portalID, + ).Scan(&done) + if err != nil { + return false + } + return done +} + +// getForwardBackfillDonePortals returns the set of portal IDs whose forward +// FetchMessages has completed at least once. Used by preUploadCloudAttachments +// to skip portals that don't need pre-upload on restart. +func (s *cloudBackfillStore) getForwardBackfillDonePortals(ctx context.Context) (map[string]bool, error) { + rows, err := s.db.Query(ctx, + `SELECT DISTINCT portal_id FROM cloud_chat WHERE login_id=$1 AND fwd_backfill_done=1`, + s.loginID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + done := make(map[string]bool) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + done[id] = true + } + return done, rows.Err() +} + +// hasCloudReadReceipt checks whether a message UUID in cloud_message already +// has a valid date_read_ms (i.e., CloudKit recorded that it was read). Used to +// suppress duplicate APNs read receipts that arrive after backfill — the +// synthetic receipt already used the correct CloudKit timestamp, so the stale +// APNs receipt (with delivery-time instead of read-time) should be dropped. +// Uses case-insensitive GUID matching (APNs delivers uppercase, CloudKit may be mixed). +func (s *cloudBackfillStore) hasCloudReadReceipt(ctx context.Context, uuid string) (bool, error) { + var dateReadMS int64 + err := s.db.QueryRow(ctx, ` + SELECT COALESCE(date_read_ms, 0) + FROM cloud_message + WHERE login_id=$1 AND UPPER(guid)=UPPER($2) AND is_from_me=TRUE + LIMIT 1 + `, s.loginID, uuid).Scan(&dateReadMS) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return dateReadMS > 978307200000, nil +} + +// isCloudBackfilledMessage checks whether a message UUID exists in the +// cloud_message table as a CloudKit-synced message. CloudKit entries (from +// upsertMessageBatch) have record_name populated, while real-time entries +// (from persistMessageUUID) have record_name=”. This distinguishes +// backfilled messages whose ghost receipts should be suppressed from +// real-time messages whose ghost receipts should go through. +func (s *cloudBackfillStore) isCloudBackfilledMessage(ctx context.Context, uuid string) (bool, error) { + var exists bool + err := s.db.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM cloud_message + WHERE login_id=$1 AND UPPER(guid)=UPPER($2) AND record_name != '' + ) + `, s.loginID, uuid).Scan(&exists) + if err != nil { + return false, err + } + return exists, nil +} + +// getConversationReadByMe returns true when the most recent non-tapback message +// in the conversation was sent by the user (is_from_me=true), meaning the user +// has read everything in the thread. If there are no messages in the local +// store, falls back to checking for a non-filtered cloud_chat row (portals with +// chat metadata but no stored messages are treated as read). +// Filtered (junk) chats and portals with no cloud_chat metadata are left unread. +// +// Must be called BEFORE markForwardBackfillDone (inserts synthetic rows). +func (s *cloudBackfillStore) getConversationReadByMe(ctx context.Context, portalID string) (bool, error) { + // Primary check: direction of the most recent non-tapback message. + // Reactions (tapback_type IS NOT NULL) are excluded: an incoming reaction + // to something you sent does not create an unread state. The filter finds + // the last substantive message and uses its direction as the read signal. + var isFromMe bool + err := s.db.QueryRow(ctx, ` + SELECT is_from_me FROM cloud_message + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE + AND tapback_type IS NULL + ORDER BY timestamp_ms DESC, rowid DESC + LIMIT 1 + `, s.loginID, portalID).Scan(&isFromMe) + if err == nil { + // Latest message direction determines read state: + // outgoing → user has read the conversation; incoming → leave unread. + return isFromMe, nil + } else if err != sql.ErrNoRows { + return false, err + } + // No messages in the local store — fall back to checking for a + // non-filtered cloud_chat row. Portals with chat metadata but no + // stored messages are treated as read. + var count int + err = s.db.QueryRow(ctx, ` + SELECT COUNT(*) FROM cloud_chat + WHERE login_id=$1 AND portal_id=$2 AND deleted=FALSE + AND COALESCE(is_filtered, 0) = 0 + `, s.loginID, portalID).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// pruneOrphanedAttachmentCache deletes cloud_attachment_cache entries whose +// record_name is not referenced by any live (non-deleted) cloud_message row. +// This prevents unbounded growth after portal deletions or message tombstones +// remove the messages that originally needed those cached attachments. +func (s *cloudBackfillStore) pruneOrphanedAttachmentCache(ctx context.Context) (int64, error) { + result, err := s.db.Exec(ctx, ` + DELETE FROM cloud_attachment_cache + WHERE login_id=$1 + AND record_name NOT IN ( + SELECT DISTINCT json_extract(je.value, '$.record_name') + FROM cloud_message, json_each(cloud_message.attachments_json) AS je + WHERE cloud_message.login_id=$1 + AND cloud_message.deleted=FALSE + AND cloud_message.attachments_json IS NOT NULL + AND cloud_message.attachments_json <> '' + AND json_extract(je.value, '$.record_name') IS NOT NULL + ) + `, s.loginID) + if err != nil { + return 0, fmt.Errorf("failed to prune orphaned attachment cache: %w", err) + } + n, _ := result.RowsAffected() + return n, nil +} + +// deleteOrphanedMessages hard-deletes cloud_message rows that are already +// soft-deleted (deleted=TRUE) AND whose portal_id has no matching cloud_chat +// entry. This is conservative: DM portals legitimately have messages without +// cloud_chat rows, so we only clean up rows that are BOTH orphaned AND already +// marked deleted (from tombstone processing or portal deletion). +func (s *cloudBackfillStore) deleteOrphanedMessages(ctx context.Context) (int64, error) { + result, err := s.db.Exec(ctx, ` + DELETE FROM cloud_message + WHERE login_id=$1 + AND deleted=TRUE + AND portal_id NOT IN ( + SELECT DISTINCT portal_id FROM cloud_chat WHERE login_id=$1 + ) + `, s.loginID) + if err != nil { + return 0, fmt.Errorf("failed to delete orphaned messages: %w", err) + } + n, _ := result.RowsAffected() + return n, nil +} diff --git a/pkg/connector/cloud_backfill_store_test.go b/pkg/connector/cloud_backfill_store_test.go new file mode 100644 index 00000000..49dc8209 --- /dev/null +++ b/pkg/connector/cloud_backfill_store_test.go @@ -0,0 +1,115 @@ +package connector + +import "testing" + +func TestParticipantSetsMatch(t *testing.T) { + self := "tel:+15551234567" + selfEmail := "mailto:user@example.com" + // isSelf checks against all known handles (phone + email). + isSelf := func(h string) bool { + n := normalizeIdentifierForPortalID(h) + return n == normalizeIdentifierForPortalID(self) || n == normalizeIdentifierForPortalID(selfEmail) + } + + tests := []struct { + name string + a, b []string + isSelf func(string) bool + want bool + }{ + { + name: "identical sets", + a: []string{"tel:+15551111111", "tel:+15552222222", self}, + b: []string{"tel:+15552222222", "tel:+15551111111", self}, + isSelf: isSelf, + want: true, + }, + { + name: "self in a but not b", + a: []string{"tel:+15551111111", self}, + b: []string{"tel:+15551111111"}, + isSelf: isSelf, + want: true, + }, + { + name: "self in b but not a", + a: []string{"tel:+15551111111"}, + b: []string{"tel:+15551111111", self}, + isSelf: isSelf, + want: true, + }, + { + name: "self email handle in a, phone handle absent", + a: []string{"tel:+15551111111", selfEmail}, + b: []string{"tel:+15551111111"}, + isSelf: isSelf, + want: true, + }, + { + name: "non-self member differs", + a: []string{"tel:+15551111111", "tel:+15552222222"}, + b: []string{"tel:+15551111111", "tel:+15553333333"}, + isSelf: isSelf, + want: false, + }, + { + name: "diff is 1 but differing member is not self", + a: []string{"tel:+15551111111", "tel:+15552222222", "tel:+15554444444"}, + b: []string{"tel:+15551111111", "tel:+15552222222"}, + isSelf: isSelf, + want: false, + }, + { + name: "both empty", + a: []string{}, + b: []string{}, + isSelf: isSelf, + want: false, + }, + { + name: "empty set a", + a: []string{}, + b: []string{"tel:+15551111111"}, + isSelf: isSelf, + want: false, + }, + { + name: "empty set b", + a: []string{"tel:+15551111111"}, + b: []string{}, + isSelf: isSelf, + want: false, + }, + { + name: "nil isSelf disallows any difference", + a: []string{"tel:+15551111111", self}, + b: []string{"tel:+15551111111"}, + isSelf: nil, + want: false, + }, + { + name: "both diffs are self handles (phone vs email)", + a: []string{"tel:+15551111111", self}, + b: []string{"tel:+15551111111", selfEmail}, + isSelf: isSelf, + want: true, + }, + { + name: "duplicates in input", + a: []string{"tel:+15551111111", "tel:+15551111111"}, + b: []string{"tel:+15551111111"}, + isSelf: isSelf, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := participantSetsMatch(tt.a, tt.b, tt.isSelf) + if got != tt.want { + t.Errorf("participantSetsMatch(%v, %v) = %v, want %v", + tt.a, tt.b, got, tt.want) + } + }) + } +} diff --git a/pkg/connector/cloud_contacts.go b/pkg/connector/cloud_contacts.go new file mode 100644 index 00000000..5204207c --- /dev/null +++ b/pkg/connector/cloud_contacts.go @@ -0,0 +1,762 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// Cloud-based contact sync via Apple's CardDAV (iCloud Contacts). +// Uses DSID + mmeAuthToken credentials obtained from the MobileMe delegate +// during login to access iCloud Contacts without a Mac relay. + +package connector + +import ( + "context" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + + "github.com/lrhodin/imessage/imessage" + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +// contactSource is the interface for contact name resolution. +// Both iCloud CardDAV and external CardDAV servers implement this. +type contactSource interface { + SyncContacts(log zerolog.Logger) error + GetContactInfo(identifier string) (*imessage.Contact, error) + // GetAllContacts returns a snapshot of all cached contacts for bulk search. + GetAllContacts() []*imessage.Contact +} + +// cloudContactsClient fetches contacts from iCloud via CardDAV and caches +// them locally for fast phone/email lookups. +type cloudContactsClient struct { + baseURL string // CardDAV URL from MobileMe delegate + dsid string // cached DSID for URL construction + rustClient *rustpushgo.Client // for getting auth headers via TokenProvider + httpClient *http.Client + + mu sync.RWMutex + byPhone map[string]*imessage.Contact // normalized phone → contact + byEmail map[string]*imessage.Contact // lowercase email → contact + contacts []*imessage.Contact // all contacts + lastSync time.Time +} + +// newCloudContactsClient creates a CardDAV contacts client using the rust Client's +// TokenProvider for authentication. Returns nil if the token provider is unavailable +// or the contacts URL can't be retrieved. +func newCloudContactsClient(rustClient *rustpushgo.Client, log zerolog.Logger) *cloudContactsClient { + if rustClient == nil { + return nil + } + + contactsURL, err := rustClient.GetContactsUrl() + if err != nil { + log.Warn().Err(err).Msg("Failed to get contacts URL from TokenProvider") + return nil + } + if contactsURL == nil || *contactsURL == "" { + log.Warn().Msg("No contacts CardDAV URL available from TokenProvider") + return nil + } + + dsidPtr, err := rustClient.GetDsid() + if err != nil { + log.Warn().Err(err).Msg("Failed to get DSID from TokenProvider") + return nil + } + + return &cloudContactsClient{ + baseURL: strings.TrimRight(*contactsURL, "/"), + dsid: *dsidPtr, + rustClient: rustClient, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + byPhone: make(map[string]*imessage.Contact), + byEmail: make(map[string]*imessage.Contact), + } +} + +// doRequest performs an authenticated request to the CardDAV server. +// Gets fresh auth + anisette headers from the TokenProvider on each call. +func (c *cloudContactsClient) doRequest(method, url, body string, depth string) (*http.Response, error) { + // Get auth headers from Rust (includes Authorization + anisette, auto-refreshes) + headersPtr, err := c.rustClient.GetIcloudAuthHeaders() + if err != nil { + return nil, fmt.Errorf("failed to get iCloud auth headers: %w", err) + } + if headersPtr == nil { + return nil, fmt.Errorf("no iCloud auth headers available (no token provider)") + } + headers := *headersPtr + + req, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + return nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + if depth != "" { + req.Header.Set("Depth", depth) + } + return c.httpClient.Do(req) +} + +// SyncContacts fetches all contacts from iCloud via CardDAV and rebuilds the cache. +func (c *cloudContactsClient) SyncContacts(log zerolog.Logger) error { + // Step 1: Get the principal URL + principalURL, err := c.discoverPrincipal(log) + if err != nil { + log.Warn().Err(err).Msg("CardDAV: failed to discover principal URL") + return err + } + log.Debug().Str("principal", principalURL).Msg("CardDAV: discovered principal URL") + + // Step 2: Get the address book home set + homeSetURL, err := c.discoverAddressBookHome(log, principalURL) + if err != nil { + log.Warn().Err(err).Msg("CardDAV: failed to discover address book home") + return err + } + log.Debug().Str("home_set", homeSetURL).Msg("CardDAV: discovered address book home") + + // Step 3: List address books + addressBooks, err := c.listAddressBooks(log, homeSetURL) + if err != nil { + log.Warn().Err(err).Msg("CardDAV: failed to list address books") + return err + } + log.Debug().Int("count", len(addressBooks)).Msg("CardDAV: found address books") + + // Step 4: Fetch all vCards from each address book + var allContacts []*imessage.Contact + for _, abURL := range addressBooks { + contacts, fetchErr := c.fetchAllVCards(log, abURL) + if fetchErr != nil { + log.Warn().Err(fetchErr).Str("address_book", abURL).Msg("CardDAV: failed to fetch vCards") + continue + } + allContacts = append(allContacts, contacts...) + } + + // Step 4.5: Download any photo URLs — use authenticated fetcher for iCloud URLs + downloadContactPhotos(allContacts, log, c.downloadAuthURL) + + // Step 5: Build lookup caches + c.mu.Lock() + defer c.mu.Unlock() + + c.byPhone = make(map[string]*imessage.Contact, len(allContacts)*2) + c.byEmail = make(map[string]*imessage.Contact, len(allContacts)) + c.contacts = allContacts + + for _, contact := range allContacts { + for _, phone := range contact.Phones { + for _, suffix := range phoneSuffixes(phone) { + c.byPhone[suffix] = contact + } + } + for _, email := range contact.Emails { + c.byEmail[strings.ToLower(email)] = contact + } + } + c.lastSync = time.Now() + + // Debug: log all email keys and a sample of phone keys + emailKeys := make([]string, 0, len(c.byEmail)) + for k := range c.byEmail { + emailKeys = append(emailKeys, k) + } + log.Debug().Strs("email_keys", emailKeys).Msg("CardDAV email lookup keys") + + // Debug: log contacts with their phone/email for troubleshooting + for _, contact := range allContacts { + if contact.HasName() { + log.Debug(). + Str("first", contact.FirstName). + Str("last", contact.LastName). + Strs("phones", contact.Phones). + Strs("emails", contact.Emails). + Msg("CardDAV contact loaded") + } + } + + log.Info(). + Int("contacts", len(allContacts)). + Int("phone_keys", len(c.byPhone)). + Int("email_keys", len(c.byEmail)). + Msg("Contact cache synced from iCloud CardDAV") + return nil +} + +// GetContactInfo looks up a contact by phone number or email. +func (c *cloudContactsClient) GetContactInfo(identifier string) (*imessage.Contact, error) { + if c == nil { + return nil, nil + } + + c.mu.RLock() + defer c.mu.RUnlock() + + // Try email first + if !strings.HasPrefix(identifier, "+") && strings.Contains(identifier, "@") { + if contact, ok := c.byEmail[strings.ToLower(identifier)]; ok { + return contact, nil + } + return nil, nil + } + + // Phone number: try all suffix variations + for _, suffix := range phoneSuffixes(identifier) { + if contact, ok := c.byPhone[suffix]; ok { + return contact, nil + } + } + + return nil, nil +} + +// GetAllContacts returns a snapshot of the full contact list for bulk search. +func (c *cloudContactsClient) GetAllContacts() []*imessage.Contact { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + // Return a copy of the slice header so callers can iterate safely. + result := make([]*imessage.Contact, len(c.contacts)) + copy(result, c.contacts) + return result +} + +// ============================================================================ +// CardDAV Protocol Implementation +// ============================================================================ + +// discoverPrincipal finds the principal URL via PROPFIND on the base URL. +func (c *cloudContactsClient) discoverPrincipal(log zerolog.Logger) (string, error) { + body := ` + + + + +` + + resp, err := c.doRequest("PROPFIND", c.baseURL+"/", body, "0") + if err != nil { + return "", fmt.Errorf("PROPFIND failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 207 { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("PROPFIND returned %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 500)])) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + href := extractPropValue(data, "current-user-principal") + if href == "" { + log.Debug().Str("body", string(data[:min(len(data), 2000)])).Msg("CardDAV: PROPFIND response (no principal found)") + return "", fmt.Errorf("no current-user-principal in response") + } + + return c.resolveURL(href), nil +} + +// discoverAddressBookHome finds the address book home set from the principal. +func (c *cloudContactsClient) discoverAddressBookHome(log zerolog.Logger, principalURL string) (string, error) { + body := ` + + + + +` + + resp, err := c.doRequest("PROPFIND", principalURL, body, "0") + if err != nil { + return "", fmt.Errorf("PROPFIND failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 207 { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("PROPFIND returned %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 500)])) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + href := extractPropValue(data, "addressbook-home-set") + if href == "" { + log.Debug().Str("body", string(data[:min(len(data), 2000)])).Msg("CardDAV: PROPFIND response (no home set found)") + return "", fmt.Errorf("no addressbook-home-set in response") + } + + return c.resolveURL(href), nil +} + +// listAddressBooks returns the URLs of all address books in the home set. +func (c *cloudContactsClient) listAddressBooks(log zerolog.Logger, homeSetURL string) ([]string, error) { + body := ` + + + + + +` + + resp, err := c.doRequest("PROPFIND", homeSetURL, body, "1") + if err != nil { + return nil, fmt.Errorf("PROPFIND failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 207 { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("PROPFIND returned %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 500)])) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + log.Debug(). + Int("response_bytes", len(data)). + Str("body_preview", string(data[:min(len(data), 3000)])). + Msg("CardDAV: listAddressBooks PROPFIND response") + return c.parseAddressBookList(data, homeSetURL, log), nil +} + +// fetchAllVCards fetches all vCards from an address book using REPORT addressbook-query. +func (c *cloudContactsClient) fetchAllVCards(log zerolog.Logger, addressBookURL string) ([]*imessage.Contact, error) { + body := ` + + + + + +` + + resp, err := c.doRequest("REPORT", addressBookURL, body, "1") + if err != nil { + return nil, fmt.Errorf("REPORT failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 207 { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("REPORT returned %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 500)])) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + log.Debug(). + Int("response_bytes", len(data)). + Str("address_book", addressBookURL). + Msg("CardDAV: REPORT response received") + + return c.parseVCardMultistatus(data, log), nil +} + +// resolveURL converts a relative href to an absolute URL. +func (c *cloudContactsClient) resolveURL(href string) string { + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + // Extract scheme + host from baseURL + base := c.baseURL + if idx := strings.Index(base, "://"); idx >= 0 { + schemeHost := base[:idx+3] + rest := base[idx+3:] + if slashIdx := strings.Index(rest, "/"); slashIdx >= 0 { + base = schemeHost + rest[:slashIdx] + } + } + return base + href +} + +// ============================================================================ +// XML Parsing helpers +// ============================================================================ + +// multistatus represents a WebDAV multistatus response. +type multistatus struct { + XMLName xml.Name `xml:"multistatus"` + Responses []davResponse `xml:"response"` +} + +type davResponse struct { + Href string `xml:"href"` + Propstat []davPropstat `xml:"propstat"` +} + +type davPropstat struct { + Status string `xml:"status"` + Prop davProp `xml:"prop"` +} + +type davProp struct { + ResourceType davResourceType `xml:"resourcetype"` + DisplayName string `xml:"displayname"` + GetETag string `xml:"getetag"` + AddressData string `xml:"address-data"` + Principal davHref `xml:"current-user-principal"` + HomeSet davHref `xml:"addressbook-home-set"` +} + +type davResourceType struct { + AddressBook *struct{} `xml:"addressbook"` + Collection *struct{} `xml:"collection"` +} + +type davHref struct { + Href string `xml:"href"` +} + +// extractPropValue extracts a property href from a multistatus response. +func extractPropValue(data []byte, propName string) string { + var ms multistatus + if err := xml.Unmarshal(data, &ms); err != nil { + return "" + } + + for _, resp := range ms.Responses { + for _, ps := range resp.Propstat { + if !strings.Contains(ps.Status, "200") { + continue + } + switch propName { + case "current-user-principal": + if ps.Prop.Principal.Href != "" { + return ps.Prop.Principal.Href + } + case "addressbook-home-set": + if ps.Prop.HomeSet.Href != "" { + return ps.Prop.HomeSet.Href + } + } + } + } + return "" +} + +// parseAddressBookList extracts address book URLs from a PROPFIND response. +func (c *cloudContactsClient) parseAddressBookList(data []byte, homeSetURL string, log zerolog.Logger) []string { + var ms multistatus + if err := xml.Unmarshal(data, &ms); err != nil { + log.Warn().Err(err).Msg("CardDAV: failed to parse address book list XML") + return nil + } + + var addressBooks []string + for _, resp := range ms.Responses { + href := c.resolveURL(resp.Href) + // Skip the home set itself + if href == homeSetURL || resp.Href == homeSetURL { + continue + } + for _, ps := range resp.Propstat { + if !strings.Contains(ps.Status, "200") { + continue + } + if ps.Prop.ResourceType.AddressBook != nil { + log.Debug(). + Str("href", href). + Str("name", ps.Prop.DisplayName). + Msg("CardDAV: found address book") + addressBooks = append(addressBooks, href) + } + } + } + + // Fallback: if no address books found with proper resourcetype, + // try the default Apple path + if len(addressBooks) == 0 { + defaultURL := c.baseURL + "/" + c.dsid + "/carddavhome/card/" + log.Debug().Str("url", defaultURL).Msg("CardDAV: no address books found via PROPFIND, trying default path") + addressBooks = append(addressBooks, defaultURL) + } + + return addressBooks +} + +// parseVCardMultistatus extracts contacts from a REPORT multistatus response. +func (c *cloudContactsClient) parseVCardMultistatus(data []byte, log zerolog.Logger) []*imessage.Contact { + var ms multistatus + if err := xml.Unmarshal(data, &ms); err != nil { + log.Warn().Err(err).Msg("CardDAV: failed to parse REPORT XML") + return nil + } + + var contacts []*imessage.Contact + skippedNoData := 0 + skippedNoInfo := 0 + for _, resp := range ms.Responses { + for _, ps := range resp.Propstat { + if !strings.Contains(ps.Status, "200") { + continue + } + vcardData := strings.TrimSpace(ps.Prop.AddressData) + if vcardData == "" { + skippedNoData++ + continue + } + contact := parseVCard(vcardData) + if contact != nil && (contact.HasName() || len(contact.Phones) > 0 || len(contact.Emails) > 0) { + contacts = append(contacts, contact) + } else { + skippedNoInfo++ + } + } + } + log.Debug(). + Int("responses", len(ms.Responses)). + Int("parsed", len(contacts)). + Int("skipped_no_data", skippedNoData). + Int("skipped_no_info", skippedNoInfo). + Msg("CardDAV REPORT parsing stats") + return contacts +} + +// ============================================================================ +// vCard Parser +// ============================================================================ + +// parseVCard parses a vCard string into a Contact struct. +// Handles vCard 3.0 and 4.0 format, including folded lines and quoted-printable. +func parseVCard(vcardData string) *imessage.Contact { + contact := &imessage.Contact{} + + // Unfold continuation lines (RFC 6350 §3.2): a line starting with a + // space or tab is a continuation of the previous logical line. + vcardData = strings.ReplaceAll(vcardData, "\r\n ", "") + vcardData = strings.ReplaceAll(vcardData, "\r\n\t", "") + vcardData = strings.ReplaceAll(vcardData, "\n ", "") + vcardData = strings.ReplaceAll(vcardData, "\n\t", "") + + lines := strings.Split(vcardData, "\n") + for _, line := range lines { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + + // Split into property name (with params) and value + colonIdx := strings.Index(line, ":") + if colonIdx < 0 { + continue + } + nameWithParams := line[:colonIdx] + value := line[colonIdx+1:] + + // Extract property name (before any ;parameters) + propName := nameWithParams + if semiIdx := strings.Index(nameWithParams, ";"); semiIdx >= 0 { + propName = nameWithParams[:semiIdx] + } + // Strip Apple vCard group prefix (e.g. "item1.TEL" → "TEL") + if dotIdx := strings.Index(propName, "."); dotIdx >= 0 { + propName = propName[dotIdx+1:] + } + propName = strings.ToUpper(propName) + + switch propName { + case "N": + // N:Last;First;Middle;Prefix;Suffix + parts := strings.Split(value, ";") + if len(parts) >= 1 { + contact.LastName = decodeVCardValue(parts[0]) + } + if len(parts) >= 2 { + contact.FirstName = decodeVCardValue(parts[1]) + } + case "FN": + // Full formatted name — use as fallback if N didn't provide names + if contact.FirstName == "" && contact.LastName == "" { + fn := decodeVCardValue(value) + parts := strings.SplitN(fn, " ", 2) + if len(parts) == 2 { + contact.FirstName = parts[0] + contact.LastName = parts[1] + } else if len(parts) == 1 { + contact.FirstName = parts[0] + } + } + case "NICKNAME": + contact.Nickname = decodeVCardValue(value) + case "TEL": + phone := decodeVCardValue(value) + // Strip tel: URI prefix if present (vCard 4.0) + phone = strings.TrimPrefix(phone, "tel:") + phone = strings.TrimSpace(phone) + if phone != "" { + contact.Phones = append(contact.Phones, phone) + } + case "EMAIL": + email := decodeVCardValue(value) + email = strings.TrimSpace(email) + if email != "" { + contact.Emails = append(contact.Emails, email) + } + case "PHOTO": + // Try to extract inline base64 photo data, or capture URL for deferred download + photo, photoURL := extractVCardPhoto(nameWithParams, value) + if photo != nil { + contact.Avatar = photo + } else if photoURL != "" { + contact.AvatarURL = photoURL + } + } + } + + return contact +} + +// decodeVCardValue handles basic vCard value decoding (escaped characters). +func decodeVCardValue(s string) string { + s = strings.ReplaceAll(s, "\\n", "\n") + s = strings.ReplaceAll(s, "\\N", "\n") + s = strings.ReplaceAll(s, "\\,", ",") + s = strings.ReplaceAll(s, "\\;", ";") + s = strings.ReplaceAll(s, "\\\\", "\\") + return strings.TrimSpace(s) +} + +// extractVCardPhoto tries to decode a base64-encoded PHOTO value. +// Returns (photoBytes, photoURL). If the photo is a URL reference, +// photoBytes is nil and photoURL is set for deferred download. +func extractVCardPhoto(nameWithParams, value string) ([]byte, string) { + params := strings.ToUpper(nameWithParams) + // Check for base64 encoding (vCard 3.0: ENCODING=b or ENCODING=BASE64) + if !strings.Contains(params, "ENCODING=B") && !strings.Contains(params, "ENCODING=BASE64") { + // vCard 4.0 uses data: URIs + if strings.HasPrefix(value, "data:") { + // data:image/jpeg;base64,/9j/4AAQ... + if idx := strings.Index(value, ","); idx >= 0 { + value = value[idx+1:] + } else { + return nil, "" + } + } else if strings.HasPrefix(value, "http") { + // URL reference — return for deferred download + return nil, value + } else { + // Assume base64 if it looks like it + if len(value) < 100 { + return nil, "" + } + } + } + + data, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, "" + } + return data, "" +} + +// downloadAuthURL fetches a URL using iCloud auth headers. +func (c *cloudContactsClient) downloadAuthURL(ctx context.Context, targetURL string) ([]byte, error) { + resp, err := c.doRequest("GET", targetURL, "", "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, targetURL) + } + return io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) +} + +// photoFetcher downloads a URL with authentication. Nil means use generic unauthenticated fetch. +type photoFetcher func(ctx context.Context, url string) ([]byte, error) + +// downloadContactPhotos downloads photo URLs for contacts that have AvatarURL +// set but no Avatar bytes. Uses bounded concurrency to avoid overwhelming +// the server. If an authenticated fetcher is provided, iCloud URLs use it; +// all other URLs use the generic unauthenticated downloader. +func downloadContactPhotos(contacts []*imessage.Contact, log zerolog.Logger, authFetch ...photoFetcher) { + var needsDownload []*imessage.Contact + for _, c := range contacts { + if c.AvatarURL != "" && c.Avatar == nil { + needsDownload = append(needsDownload, c) + } + } + if len(needsDownload) == 0 { + return + } + + log.Info().Int("count", len(needsDownload)).Msg("Downloading contact photo URLs") + + var authDL photoFetcher + if len(authFetch) > 0 { + authDL = authFetch[0] + } + + const maxConcurrency = 10 + sem := make(chan struct{}, maxConcurrency) + var wg sync.WaitGroup + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + for _, contact := range needsDownload { + wg.Add(1) + go func(c *imessage.Contact) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + var data []byte + var err error + if authDL != nil && (strings.Contains(c.AvatarURL, ".icloud.com") || strings.Contains(c.AvatarURL, ".apple.com")) { + data, err = authDL(ctx, c.AvatarURL) + } else { + data, _, err = downloadURL(ctx, c.AvatarURL) + } + if err != nil { + log.Debug().Err(err). + Str("name", c.Name()). + Str("url", c.AvatarURL). + Msg("Failed to download contact photo URL") + return + } + if len(data) > 0 { + c.Avatar = data + c.AvatarURL = "" + } + }(contact) + } + + wg.Wait() + + downloaded := 0 + for _, c := range needsDownload { + if c.Avatar != nil { + downloaded++ + } + } + log.Info(). + Int("attempted", len(needsDownload)). + Int("downloaded", downloaded). + Msg("Contact photo URL downloads complete") +} diff --git a/pkg/connector/command_contacts.go b/pkg/connector/command_contacts.go new file mode 100644 index 00000000..1fccfd37 --- /dev/null +++ b/pkg/connector/command_contacts.go @@ -0,0 +1,314 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +// contacts command — search loaded contacts by name, confirm iMessage +// reachability, and let the user pick from a numbered list to start a DM. +// +// Flow: +// !im contacts John +// → Searches contacts, batch-validates against iMessage, shows numbered list +// 2 +// → Resolves identifier and creates (or opens) the Matrix room + +import ( + "fmt" + "strconv" + "strings" + + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/provisionutil" + + "github.com/lrhodin/imessage/imessage" +) + +// contactMatch holds one iMessage-reachable identifier for a matching contact. +type contactMatch struct { + identifier string // portal ID: "tel:+15551234567" or "mailto:user@example.com" + displayName string // contact's full name + label string // human-readable identifier: "📞 +1 (555) 234-5678" +} + +// cmdContacts is the !im contacts command handler. +var cmdContacts = &commands.FullHandler{ + Name: "contacts", + Aliases: []string{"find"}, + Func: fnContacts, + Help: commands.HelpMeta{ + Section: commands.HelpSectionChats, + Description: "Search contacts by name, see who's on iMessage, then reply with a number to open a chat", + Args: "", + }, + RequiresLogin: true, +} + +func fnContacts(ce *commands.Event) { + if len(ce.Args) == 0 { + ce.Reply("**Usage:** `$cmdprefix contacts `\n\nSearches your synced contacts and shows which ones are reachable on iMessage.\n\nExample: `$cmdprefix contacts John`") + return + } + + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return + } + if client.contacts == nil { + ce.Reply("Contacts have not synced yet. Try again in a moment.") + return + } + + query := strings.ToLower(strings.Join(ce.Args, " ")) + + // --- Phase 1: name search across all loaded contacts --- + all := client.contacts.GetAllContacts() + + type candidate struct { + identifier string // tel:+ or mailto: form used for validation / portal key + rawLabel string // phone/email as stored in the contact record + name string // contact display name + } + var candidates []candidate + seen := make(map[string]bool) + + for _, contact := range all { + if !contactMatchesQuery(contact, query) { + continue + } + name := contact.Name() + + for _, phone := range contact.Phones { + norm := normalizePhoneForPortalID(phone) + if norm == "" { + continue + } + id := "tel:" + norm + if seen[id] { + continue + } + seen[id] = true + candidates = append(candidates, candidate{identifier: id, rawLabel: phone, name: name}) + } + for _, email := range contact.Emails { + id := "mailto:" + strings.ToLower(email) + if seen[id] { + continue + } + seen[id] = true + candidates = append(candidates, candidate{identifier: id, rawLabel: email, name: name}) + } + } + + if len(candidates) == 0 { + ce.Reply("No contacts found matching **\"%s\"**.", strings.Join(ce.Args, " ")) + return + } + + // --- Phase 2: batch-validate all identifiers against Apple iMessage --- + ids := make([]string, len(candidates)) + for i, c := range candidates { + ids[i] = c.identifier + } + valid := client.client.ValidateTargets(ids, client.handle) + validSet := make(map[string]bool, len(valid)) + for _, v := range valid { + validSet[v] = true + } + + // Filter to only iMessage-reachable identifiers. + var matches []contactMatch + for _, cand := range candidates { + if !validSet[cand.identifier] { + continue + } + var label string + if strings.HasPrefix(cand.identifier, "tel:") { + label = "📞 " + cand.rawLabel + } else { + label = "📧 " + cand.rawLabel + } + matches = append(matches, contactMatch{ + identifier: cand.identifier, + displayName: cand.name, + label: label, + }) + } + + if len(matches) == 0 { + ce.Reply("No contacts matching **\"%s\"** are reachable on iMessage.", strings.Join(ce.Args, " ")) + return + } + + // --- Phase 3: show numbered list, wait for selection --- + var sb strings.Builder + fmt.Fprintf(&sb, "Found **%d** iMessage contact(s) matching \"%s\":\n\n", len(matches), strings.Join(ce.Args, " ")) + for i, m := range matches { + fmt.Fprintf(&sb, "%d. **%s** — %s\n", i+1, m.displayName, m.label) + } + sb.WriteString("\nReply with a number to start a chat, or `$cmdprefix cancel` to cancel.") + ce.Reply(sb.String()) + + // Store state so the next message from this user is treated as a selection. + commands.StoreCommandState(ce.User, &commands.CommandState{ + Action: "select contact to message", + Next: commands.MinimalCommandHandlerFunc(func(ce *commands.Event) { + n, err := strconv.Atoi(strings.TrimSpace(ce.RawArgs)) + if err != nil || n < 1 || n > len(matches) { + ce.Reply("Please reply with a number between 1 and %d, or `$cmdprefix cancel` to cancel.", len(matches)) + return + } + + // Clear state immediately before the (potentially slow) portal create. + commands.StoreCommandState(ce.User, nil) + + chosen := matches[n-1] + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return + } + + resp, err := provisionutil.ResolveIdentifier(ce.Ctx, login, chosen.identifier, true) + if err != nil { + ce.Reply("Failed to start chat with **%s**: %v", chosen.displayName, err) + return + } + if resp == nil || resp.Portal == nil { + ce.Reply("Could not start a chat with **%s** (%s).", chosen.displayName, chosen.label) + return + } + + roomName := resp.Portal.Name + if roomName == "" { + roomName = resp.Portal.MXID.String() + } + if resp.JustCreated { + ce.Reply("Started chat with **%s**: [%s](%s)", chosen.displayName, roomName, resp.Portal.MXID.URI().MatrixToURL()) + } else { + ce.Reply("You already have a chat with **%s**: [%s](%s)", chosen.displayName, roomName, resp.Portal.MXID.URI().MatrixToURL()) + } + }), + Cancel: func() {}, // nothing to clean up; cancel reply is handled by the framework + }) +} + +// contactMatchesQuery returns true if the contact's name fuzzy-matches the query. +// +// Strategy (applied in order, short-circuits on first hit): +// 1. Fast exact substring on the full name / nickname. +// 2. Fuzzy: split both query and name into words; every query word must +// fuzzily match at least one name word via prefix match OR Levenshtein +// distance within the per-word threshold. +// +// Threshold table (Levenshtein): +// +// ≤2 chars → 0 (short tokens must prefix-match exactly) +// 3–5 chars → 1 edit ("jon"→"John", "smth"→"Smith") +// 6+ chars → 2 edits ("johnsen"→"Johnson") +func contactMatchesQuery(contact *imessage.Contact, query string) bool { + name := strings.ToLower(contact.Name()) + nick := strings.ToLower(contact.Nickname) + + // 1. Fast path: exact substring anywhere in name or nickname. + if strings.Contains(name, query) { + return true + } + if nick != "" && strings.Contains(nick, query) { + return true + } + + // 2. Fuzzy word-by-word match. + queryWords := strings.Fields(query) + if len(queryWords) == 0 { + return false + } + nameWords := strings.Fields(name) + if nick != "" { + nameWords = append(nameWords, strings.Fields(nick)...) + } + for _, qw := range queryWords { + if !anyNameWordFuzzyMatches(qw, nameWords) { + return false + } + } + return true +} + +// anyNameWordFuzzyMatches returns true if qw fuzzy-matches any word in nameWords. +func anyNameWordFuzzyMatches(qw string, nameWords []string) bool { + threshold := fuzzyEditThreshold(len(qw)) + for _, nw := range nameWords { + // Prefix match: "jo" matches "john", "john" matches "johnson". + if strings.HasPrefix(nw, qw) { + return true + } + // Edit-distance match for longer query words. + if threshold > 0 && levenshtein(qw, nw) <= threshold { + return true + } + } + return false +} + +// fuzzyEditThreshold returns the maximum Levenshtein edits allowed for a +// query word of the given length. +func fuzzyEditThreshold(n int) int { + switch { + case n <= 2: + return 0 // must prefix-match exactly + case n <= 5: + return 1 // "jon"→"john", "smth"→"smith" + default: + return 2 // "johnsen"→"johnson" + } +} + +// levenshtein computes the edit distance between two strings (two-row DP). +func levenshtein(a, b string) int { + if a == b { + return 0 + } + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + prev := make([]int, lb+1) + curr := make([]int, lb+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= la; i++ { + curr[0] = i + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost) + } + prev, curr = curr, prev + } + return prev[lb] +} diff --git a/pkg/connector/commands.go b/pkg/connector/commands.go new file mode 100644 index 00000000..caf68e84 --- /dev/null +++ b/pkg/connector/commands.go @@ -0,0 +1,1021 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + + "github.com/lrhodin/imessage/imessage" +) + +// Help sections for Apple-service commands added by this bridge. Orders slot +// in after bridgev2's built-in sections (General=0, Auth=10, Chats=20, +// Admin=50), so `!im help` renders each service as its own heading at the +// bottom instead of lumping everything under "General". +var ( + HelpSectionFaceTime = commands.HelpSection{Name: "FaceTime", Order: 60} + HelpSectionFindMy = commands.HelpSection{Name: "Find My", Order: 70} + HelpSectionSharedStreams = commands.HelpSection{Name: "Shared Streams", Order: 80} + HelpSectionStatusKit = commands.HelpSection{Name: "StatusKit", Order: 90} +) + +// BridgeCommands returns the custom slash commands for the iMessage bridge. +// Register these in main.go's PostInit hook: +// +// m.Bridge.Commands.(*commands.Processor).AddHandlers(connector.BridgeCommands()...) +func BridgeCommands() []*commands.FullHandler { + cmds := []*commands.FullHandler{ + cmdRestoreChat, + cmdRestoreDebug, + cmdMsgDebug, + cmdContacts, + // Apple service integrations + cmdFaceTime, + cmdFaceTimeSend, + cmdFaceTimeClear, + cmdFaceTimeState, + cmdFaceTimeSessionLink, + cmdFaceTimeUseLink, + cmdFaceTimeDeleteLink, + cmdFaceTimeLetMeIn, + cmdFaceTimeLetMeInApprove, + cmdFaceTimeLetMeInDeny, + cmdFaceTimeCreateSession, + cmdFaceTimeRing, + cmdFaceTimeAddMembers, + cmdFaceTimeRemoveMembers, + cmdFindMy, + cmdFindMyAcceptShare, + cmdFindMyDeleteItem, + cmdFindMyRenameBeacon, + cmdFindMyStateJSON, + cmdFindMyDevices, + cmdFindMyFriends, + cmdFindMyFriendsImport, + cmdSharedAlbums, + cmdSharedSubscribe, + cmdSharedSubscribeToken, + cmdSharedUnsubscribe, + cmdSharedState, + cmdSharedAssetsJSON, + cmdSharedDeleteAssets, + cmdStatuskitState, + cmdStatuskitShare, + cmdStatuskitResetKeys, + cmdStatuskitRollKeys, + cmdStatuskitRequestHandles, + cmdStatuskitClearInterest, + cmdStatuskitInviteToChannel, + } + return cmds +} + +// cmdRestoreChat lists deleted rooms, then waits for the user to reply with +// just a number to restore that room. +// +// Usage: +// +// !restore-chat — show numbered list of restorable rooms +// 3 — (bare number) restore room #3 from the list +var cmdRestoreChat = &commands.FullHandler{ + Name: "restore-chat", + Aliases: []string{"restore"}, + Func: fnRestoreChat, + Help: commands.HelpMeta{ + Section: commands.HelpSectionChats, + Description: "List iMessage chats in the recycle bin that can be recreated. Reply with the item number to restore that room and backfill its history.", + Args: "", + }, + RequiresLogin: true, +} + +func fnRestoreChat(ce *commands.Event) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil { + ce.Reply("Bridge client not available.") + return + } + + // chatdb backend: use chat.db restore path. + if client.Main.Config.UseChatDBBackfill() && client.chatDB != nil { + fnRestoreChatFromChatDB(ce, login, client) + return + } + + // CloudKit backend: use delete-aware CloudKit restore. + if client.Main.Config.UseCloudKitBackfill() && client.cloudStore != nil { + fnRestoreChatFromCloudKit(ce, login, client) + return + } + + ce.Reply("No backfill source available.") +} + +// fnRestoreChatFromChatDB handles restore-chat using the local macOS chat.db. +// Lists all chats in chat.db that don't have an active Matrix room. +func fnRestoreChatFromChatDB(ce *commands.Event, login *bridgev2.UserLogin, client *IMClient) { + chats, err := client.chatDB.api.GetChatsWithMessagesAfter(time.Time{}) + if err != nil { + ce.Reply("Failed to query chat.db: %v", err) + return + } + + type chatDBEntry struct { + portalID string + name string + } + var candidates []chatDBEntry + + for _, chat := range chats { + parsed := imessage.ParseIdentifier(chat.ChatGUID) + if parsed.LocalID == "" { + continue + } + + var portalID string + if parsed.IsGroup { + info, err := client.chatDB.api.GetChatInfo(chat.ChatGUID, chat.ThreadID) + if err != nil || info == nil { + continue + } + members := []string{client.handle} + for _, m := range info.Members { + members = append(members, addIdentifierPrefix(m)) + } + sort.Strings(members) + portalID = strings.Join(members, ",") + } else { + portalID = string(identifierToPortalID(parsed)) + } + + portalKey := networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: login.ID} + existing, _ := ce.Bridge.GetExistingPortalByKey(ce.Ctx, portalKey) + if existing != nil && existing.MXID != "" { + continue // room already exists + } + + name := friendlyPortalName(ce.Ctx, ce.Bridge, client, portalKey, portalID) + candidates = append(candidates, chatDBEntry{portalID: portalID, name: name}) + } + + if len(candidates) == 0 { + ce.Reply("No chats found in chat.db that can be restored.") + return + } + + var sb strings.Builder + sb.WriteString("**Chats available to restore from chat.db:**\n\n") + for i, c := range candidates { + sb.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, c.name)) + } + sb.WriteString("\nReply with a number to restore, or `$cmdprefix cancel` to cancel.") + ce.Reply(sb.String()) + + commands.StoreCommandState(ce.User, &commands.CommandState{ + Action: "restore chat", + Next: commands.MinimalCommandHandlerFunc(func(ce *commands.Event) { + n, err := strconv.Atoi(strings.TrimSpace(ce.RawArgs)) + if err != nil || n < 1 || n > len(candidates) { + ce.Reply("Please reply with a number between 1 and %d, or `$cmdprefix cancel` to cancel.", len(candidates)) + return + } + + commands.StoreCommandState(ce.User, nil) + + chosen := candidates[n-1] + portalKey := networkid.PortalKey{ID: networkid.PortalID(chosen.portalID), Receiver: login.ID} + + // Remove from recentlyDeletedPortals so recreation isn't blocked. + client.recentlyDeletedPortalsMu.Lock() + delete(client.recentlyDeletedPortals, chosen.portalID) + client.recentlyDeletedPortalsMu.Unlock() + + client.Main.Bridge.QueueRemoteEvent(login, &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + PortalKey: portalKey, + CreatePortal: true, + Timestamp: time.Now(), + }, + GetChatInfoFunc: client.GetChatInfo, + }) + + ce.Reply("Restoring **%s** — the room will appear shortly with history from chat.db.", chosen.name) + }), + Cancel: func() {}, + }) +} + +// restoreChatCandidate represents a chat that can be restored. +type restoreChatCandidate struct { + portalID string + displayName string + participants []string // normalized participants from recycle bin (may be nil) + groupID string // CloudKit group UUID (for groups) + chatID string // CloudKit chat_identifier + groupPhotoGuid string // CloudKit group photo GUID (for group avatar) + source string // debug: which source produced this candidate +} + +// fnRestoreChatFromCloudKit finds deleted chats from CloudKit recycle-bin +// state, then presents them for restore. +func fnRestoreChatFromCloudKit(ce *commands.Event, login *bridgev2.UserLogin, client *IMClient) { + ce.Reply("Querying deleted chats…") + + var candidates []restoreChatCandidate + seenPortalIDs := make(map[string]bool) + + // portalIsLive returns true if a Matrix room already exists for the portal. + // Only checks bridge portal state (MXID), NOT cloud_chat DB — a live + // cloud_chat row can exist from metadata refresh without the portal + // actually being restored (e.g., user started restore then trashed it). + portalIsLive := func(portalID string) bool { + portalKey := networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: login.ID} + if existing, _ := ce.Bridge.GetExistingPortalByKey(ce.Ctx, portalKey); existing != nil && existing.MXID != "" { + return true + } + return false + } + + // Source 1: Use recoverable chat identities directly from Apple's recycle bin. + if client.client != nil && client.cloudStore != nil { + recoverableChats, err := client.client.ListRecoverableChats() + if err == nil { + for _, chat := range recoverableChats { + portalID := client.resolvePortalIDForCloudChat(chat.Participants, chat.DisplayName, chat.GroupId, chat.Style) + if portalID == "" || seenPortalIDs[portalID] { + continue + } + if portalIsLive(portalID) { + continue + } + portalKey := networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: login.ID} + // Prefer CloudKit's display_name for groups (user-set custom name). + // friendlyPortalName falls back to member names which prevents + // the old portalID-equality check from triggering. + name := "" + if chat.DisplayName != nil && *chat.DisplayName != "" { + name = *chat.DisplayName + } + if name == "" { + name = friendlyPortalName(ce.Ctx, ce.Bridge, client, portalKey, portalID) + } + // If the local lookup returned the "Group …" fallback but + // the recycle bin chat has participants, build a name from them. + isGroup := strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") + if isGroup && strings.HasPrefix(name, "Group ") && len(chat.Participants) > 0 { + normalized := make([]string, 0, len(chat.Participants)) + for _, p := range chat.Participants { + n := normalizeIdentifierForPortalID(p) + if n != "" { + normalized = append(normalized, n) + } + } + if len(normalized) > 0 { + if built := client.buildGroupName(normalized); built != "" && built != "Group Chat" { + name = built + } + } + } + // Normalize participants for later use during restore. + var normParts []string + for _, p := range chat.Participants { + if n := normalizeIdentifierForPortalID(p); n != "" { + normParts = append(normParts, n) + } + } + photoGuid := "" + if chat.GroupPhotoGuid != nil { + photoGuid = *chat.GroupPhotoGuid + } + candidates = append(candidates, restoreChatCandidate{ + portalID: portalID, + displayName: name, + participants: normParts, + groupID: chat.GroupId, + chatID: chat.CloudChatId, + groupPhotoGuid: photoGuid, + source: "S1:recycle", + }) + seenPortalIDs[portalID] = true + } + } + } + + // Source 2: Derive deleted portals from recoverable message metadata. + if client.client != nil && client.cloudStore != nil { + entries, err := client.client.ListRecoverableMessageGuids() + if err == nil && len(entries) > 0 { + for _, hint := range client.buildRecoverableMessagePortalHints(ce.Ctx, entries) { + if seenPortalIDs[hint.PortalID] { + continue + } + if portalIsLive(hint.PortalID) { + continue + } + info, err := client.cloudStore.getSoftDeletedPortalInfo(ce.Ctx, hint.PortalID) + if err != nil { + continue + } + if !info.Deleted && hint.Count < 2 { + continue + } + portalKey := networkid.PortalKey{ID: networkid.PortalID(hint.PortalID), Receiver: login.ID} + name := friendlyPortalName(ce.Ctx, ce.Bridge, client, portalKey, hint.PortalID) + candidates = append(candidates, restoreChatCandidate{ + portalID: hint.PortalID, + displayName: name, + participants: hint.Participants, + chatID: hint.CloudChatID, + source: "S2:msg-hint", + }) + seenPortalIDs[hint.PortalID] = true + } + + // Source 3: Match recoverable GUIDs against cloud_message (pre-seed fallback). + states, err := client.cloudStore.classifyRecycleBinPortals(ce.Ctx, entries) + if err == nil { + for _, state := range states { + if !state.LooksDeleted() { + continue + } + portalID := state.PortalID + if seenPortalIDs[portalID] { + continue + } + if portalIsLive(portalID) { + continue + } + portalKey := networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: login.ID} + name := friendlyPortalName(ce.Ctx, ce.Bridge, client, portalKey, portalID) + candidates = append(candidates, restoreChatCandidate{ + portalID: portalID, + displayName: name, + source: "S3", + }) + seenPortalIDs[portalID] = true + } + } + } + } + + // Source 4: Locally soft-deleted portals (from seed or APNs delete). + if client.cloudStore != nil { + deleted, err := client.cloudStore.listSoftDeletedPortals(ce.Ctx) + if err == nil { + for _, p := range deleted { + if seenPortalIDs[p.PortalID] { + continue + } + if portalIsLive(p.PortalID) { + continue + } + portalKey := networkid.PortalKey{ID: networkid.PortalID(p.PortalID), Receiver: login.ID} + name := friendlyPortalName(ce.Ctx, ce.Bridge, client, portalKey, p.PortalID) + var parts []string + if p.ParticipantsJSON != "" { + _ = json.Unmarshal([]byte(p.ParticipantsJSON), &parts) + } + candidates = append(candidates, restoreChatCandidate{ + portalID: p.PortalID, + displayName: name, + groupID: p.GroupID, + chatID: p.CloudChatID, + participants: parts, + source: "S4:soft-del", + }) + seenPortalIDs[p.PortalID] = true + } + } + } + + // Source 5: In-memory recentlyDeletedPortals — catches portals deleted + // this session that have no cloud_chat rows at all (e.g. APNs-only chats + // that were never synced from CloudKit). Without this, deleting a chat + // from Beeper that has no CloudKit backing makes it unrestorable. + client.recentlyDeletedPortalsMu.RLock() + for portalID := range client.recentlyDeletedPortals { + if seenPortalIDs[portalID] { + continue + } + if portalIsLive(portalID) { + continue + } + portalKey := networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: login.ID} + name := friendlyPortalName(ce.Ctx, ce.Bridge, client, portalKey, portalID) + candidates = append(candidates, restoreChatCandidate{ + portalID: portalID, + displayName: name, + source: "S5:recent", + }) + seenPortalIDs[portalID] = true + } + client.recentlyDeletedPortalsMu.RUnlock() + + // Sort candidates so gid: entries come before comma-based entries. + // gid: entries carry the authoritative CloudKit metadata (custom group + // name, groupID) while style=0 entries only have participant names. + // By processing gid: first, the dedup keeps the better-named candidate. + sort.SliceStable(candidates, func(i, j int) bool { + iGid := strings.HasPrefix(candidates[i].portalID, "gid:") + jGid := strings.HasPrefix(candidates[j].portalID, "gid:") + if iGid != jGid { + return iGid + } + return false + }) + + // Deduplicate group candidates by protocol group UUID / participants. + // The same group can appear with different portal IDs (gid: vs + // participant-based) from different sources. Deduping by group identity + // keeps one row per conversation while still allowing distinct groups + // that share a display name. + // + // For gid: candidates without an explicit groupID, cross-reference + // cloud_chat to find the real group_id. This handles the case where + // a chat_id UUID differs from the group_id UUID — without this, + // gid: and gid: look like different groups. + { + seenGroups := make(map[string]bool) + // Track participant sets (full set match). + seenParticipantSets := make(map[string]bool) + // Track individual participants seen in gid: candidates. + // Per-participant encryption envelopes each get a unique UUID, + // so two gid: candidates for the same group will have different + // UUIDs but share members. If any non-self member of a gid: + // candidate was already seen in another gid: candidate, they're + // the same conversation. + seenGidMembers := make(map[string]bool) + var deduped []restoreChatCandidate + for _, c := range candidates { + if isGroupPortalID(c.portalID) { + groupID := c.groupID + if groupID == "" && strings.HasPrefix(c.portalID, "gid:") && client.cloudStore != nil { + groupID = client.cloudStore.getGroupIDForPortalID(ce.Ctx, c.portalID) + } + key := groupPortalDedupKey(c.portalID, groupID, c.participants) + // Also check cross-reference keys: a group's chat_id UUID + // differs from its group_id UUID, so register both so the + // second candidate (with the other UUID) gets caught. + altKeys := make([]string, 0, 2) + if c.chatID != "" { + altKeys = append(altKeys, "group:"+normalizeUUID(c.chatID)) + } + if strings.HasPrefix(c.portalID, "gid:") { + altKeys = append(altKeys, "group:"+normalizeUUID(strings.TrimPrefix(c.portalID, "gid:"))) + } + isDup := seenGroups[key] + if !isDup { + for _, ak := range altKeys { + if seenGroups[ak] { + isDup = true + break + } + } + } + // Check participant set (full match). + if !isDup && len(c.participants) > 0 { + pkey := strings.Join(normalizeRecoverableParticipants(c.participants), ",") + if pkey != "" && seenParticipantSets[pkey] { + isDup = true + } + } + // Check individual members for gid: candidates. + // Per-participant encryption envelopes produce different + // gid: UUIDs for the same group — one UUID per member. + // Messages from James → gid:uuid-A, from Ludvig → gid:uuid-B. + // The participant sets are different ([James,self] vs [Ludvig,self]) + // but they share the group. Detect this by checking if ANY + // non-self member was already seen in a prior gid: candidate. + if !isDup && strings.HasPrefix(c.portalID, "gid:") && len(c.participants) > 0 { + for _, p := range c.participants { + norm := strings.ToLower(p) + if client.isMyHandle(norm) { + continue + } + if seenGidMembers[norm] { + isDup = true + break + } + } + } + // Last resort: display-name dedup for gid: candidates that + // have no participants (e.g. Source 4/5 which only know the + // portal_id). Without this, per-participant encryption UUIDs + // that appear in Source 4 produce duplicate entries since all + // other dedup checks require participants. + if !isDup && strings.HasPrefix(c.portalID, "gid:") && len(c.participants) == 0 { + nameKey := "gid-name:" + strings.ToLower(c.displayName) + if seenGroups[nameKey] { + isDup = true + } + } + if isDup { + continue + } + seenGroups[key] = true + for _, ak := range altKeys { + seenGroups[ak] = true + } + // Register display-name key for gid: candidates. + if strings.HasPrefix(c.portalID, "gid:") { + nameKey := "gid-name:" + strings.ToLower(c.displayName) + seenGroups[nameKey] = true + } + + // Register participant set for ALL group candidates. + if len(c.participants) > 0 { + pkey := strings.Join(normalizeRecoverableParticipants(c.participants), ",") + if pkey != "" { + seenParticipantSets[pkey] = true + } + } + // Register individual members for gid: overlap detection. + if strings.HasPrefix(c.portalID, "gid:") { + for _, p := range c.participants { + norm := strings.ToLower(p) + if !client.isMyHandle(norm) { + seenGidMembers[norm] = true + } + } + } + } + deduped = append(deduped, c) + } + candidates = deduped + } + + if len(candidates) == 0 { + client.cloudSyncRunningLock.RLock() + syncing := client.cloudSyncRunning + client.cloudSyncRunningLock.RUnlock() + if syncing || !client.isCloudSyncDone() { + ce.Reply("iMessage history is still syncing from iCloud — please try again once the sync is complete.") + } else { + ce.Reply("No deleted chats found.") + } + return + } + + var sb strings.Builder + sb.WriteString("**Deleted chats available to restore:**\n\n") + for i, c := range candidates { + sb.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, c.displayName)) + } + sb.WriteString("\nReply with a number to restore, or `$cmdprefix cancel` to cancel.") + ce.Reply(sb.String()) + + commands.StoreCommandState(ce.User, &commands.CommandState{ + Action: "restore chat", + Next: commands.MinimalCommandHandlerFunc(func(ce *commands.Event) { + n, err := strconv.Atoi(strings.TrimSpace(ce.RawArgs)) + if err != nil || n < 1 || n > len(candidates) { + ce.Reply("Please reply with a number between 1 and %d, or `$cmdprefix cancel` to cancel.", len(candidates)) + return + } + + commands.StoreCommandState(ce.User, nil) + + chosen := candidates[n-1] + portalKey := networkid.PortalKey{ID: networkid.PortalID(chosen.portalID), Receiver: login.ID} + if err := client.startRestoreBackfillPipeline(restorePipelineOptions{ + PortalID: chosen.portalID, + PortalKey: portalKey, + Source: "restore_chat_cmd", + DisplayName: chosen.displayName, + Participants: chosen.participants, + ChatID: chosen.chatID, + GroupID: chosen.groupID, + GroupPhotoGuid: chosen.groupPhotoGuid, + RecoverOnApple: true, + Notify: func(format string, args ...any) { + ce.Reply(format, args...) + }, + }); err != nil { + ce.Reply("Failed to start restore for **%s**: %v", chosen.displayName, err) + } + }), + Cancel: func() {}, + }) +} + +// friendlyPortalName returns a human-readable name for a portal. +// Tries the bridgev2 portal DB first, then the IMClient's resolveGroupName +// (which checks cloud_chat for display_name and participant contacts), +// then falls back to formatting the portal_id. +func friendlyPortalName(ctx context.Context, bridge *bridgev2.Bridge, client *IMClient, key networkid.PortalKey, portalID string) string { + if portal, _ := bridge.GetExistingPortalByKey(ctx, key); portal != nil && portal.Name != "" { + return portal.Name + } + // For group chats, resolve from cloud store (display_name / contact names). + isGroup := strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") + if isGroup && client != nil { + if name, _ := client.resolveGroupName(ctx, portalID); name != "" && name != "Group Chat" { + return name + } + } + // For DM portals, try to resolve a contact name. + if client != nil && !isGroup { + contact := client.lookupContact(portalID) + if contact != nil && contact.HasName() { + name := client.Main.Config.FormatDisplayname(DisplaynameParams{ + FirstName: contact.FirstName, + LastName: contact.LastName, + Nickname: contact.Nickname, + ID: stripIdentifierPrefix(portalID), + }) + if name != "" { + return name + } + } + } + // Strip URI prefix for a cleaner display. + id := strings.TrimPrefix(strings.TrimPrefix(portalID, "mailto:"), "tel:") + if strings.HasPrefix(portalID, "gid:") { + trimmed := strings.TrimPrefix(portalID, "gid:") + if len(trimmed) > 8 { + trimmed = trimmed[:8] + } + return "Group " + trimmed + "…" + } + return id +} + +func pluralMessages(n int) string { + if n == 1 { + return "1 message" + } + return fmt.Sprintf("%d messages", n) +} + +// restorePortalByID is the programmatic equivalent of the restore-chat command. +func (c *IMClient) restorePortalByID(_ context.Context, portalID string) error { + portalKey := networkid.PortalKey{ + ID: networkid.PortalID(portalID), + Receiver: c.UserLogin.ID, + } + + if c.Main.Config.UseCloudKitBackfill() && c.cloudStore != nil { + return c.startRestoreBackfillPipeline(restorePipelineOptions{ + PortalID: portalID, + PortalKey: portalKey, + Source: "restore_portal_by_id", + RecoverOnApple: true, + }) + } else { + // chatdb backend — use existing local data. + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + PortalKey: portalKey, + CreatePortal: true, + Timestamp: time.Now(), + }, + GetChatInfoFunc: c.GetChatInfo, + }) + } + + return nil +} + +// cmdRestoreDebug dumps recycle bin + cloud_chat state for diagnosing restore issues. +// +// Usage: !restore-debug +var cmdRestoreDebug = &commands.FullHandler{ + Name: "restore-debug", + Aliases: []string{"rdebug", "chat-debug"}, + Func: fnRestoreDebug, + Help: commands.HelpMeta{ + Section: commands.HelpSectionChats, + Description: "Dump cloud_chat + recycle-bin state for all portals to diagnose missing or failed restores.", + Args: "", + }, + RequiresLogin: true, +} + +func fnRestoreDebug(ce *commands.Event) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("Not logged in.") + return + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil { + ce.Reply("Bridge client not available.") + return + } + + if !client.Main.Config.UseCloudKitBackfill() || client.cloudStore == nil { + ce.Reply("CloudKit backfill is not enabled.") + return + } + + var sb strings.Builder + sb.WriteString("**Restore debug dump**\n\n") + + // ── 1. Recycle bin (live CloudKit query) ────────────────────────────────── + sb.WriteString("**Recycle bin (ListRecoverableChats)**\n") + recycleBin, err := client.client.ListRecoverableChats() + if err != nil { + sb.WriteString(fmt.Sprintf(" error: %v\n", err)) + } else if len(recycleBin) == 0 { + sb.WriteString(" (empty — recycle bin is clear)\n") + } else { + for i, chat := range recycleBin { + name := "(no name)" + if chat.DisplayName != nil && *chat.DisplayName != "" { + name = *chat.DisplayName + } + photo := "" + if chat.GroupPhotoGuid != nil && *chat.GroupPhotoGuid != "" { + g := *chat.GroupPhotoGuid + if len(g) > 8 { + g = g[:8] + } + photo = " photo=" + g + "…" + } + + portalID := client.resolvePortalIDForCloudChat(chat.Participants, chat.DisplayName, chat.GroupId, chat.Style) + cacheBackfillable := "?" + cacheContentful := "?" + if portalID != "" { + if n, cntErr := client.cloudStore.countBackfillableMessages(ce.Ctx, portalID, false); cntErr == nil { + cacheBackfillable = strconv.Itoa(n) + } + if n, cntErr := client.cloudStore.countBackfillableMessages(ce.Ctx, portalID, true); cntErr == nil { + cacheContentful = strconv.Itoa(n) + } + } + + sb.WriteString(fmt.Sprintf(" %d. [style=%d del=%v] %q pid=%s cid=%s gid=%s parts=%d cache_msgs=%s contentful=%s%s\n", + i+1, chat.Style, chat.Deleted, + name, portalID, chat.CloudChatId, chat.GroupId, + len(chat.Participants), cacheBackfillable, cacheContentful, photo)) + } + } + + // ── 2. Soft-deleted portals in cloud_chat ───────────────────────────────── + sb.WriteString("\n**Soft-deleted portals (cloud_chat)**\n") + softDel, err := client.cloudStore.listSoftDeletedPortals(ce.Ctx) + if err != nil { + sb.WriteString(fmt.Sprintf(" error: %v\n", err)) + } else if len(softDel) == 0 { + sb.WriteString(" (none)\n") + } else { + for _, p := range softDel { + name, _ := client.cloudStore.getDisplayNameByPortalID(ce.Ctx, p.PortalID) + cid := p.CloudChatID + if cid == "" { + cid = "(none)" + } + gid := p.GroupID + if gid == "" { + gid = "(none)" + } + sb.WriteString(fmt.Sprintf(" %s name=%q msgs=%d cid=%s gid=%s\n", + p.PortalID, name, p.Count, cid, gid)) + } + } + + // ── 3. Restore overrides ────────────────────────────────────────────────── + sb.WriteString("\n**Restore overrides**\n") + overrides := client.cloudStore.listRestoreOverrides(ce.Ctx) + if len(overrides) == 0 { + sb.WriteString(" (none)\n") + } else { + for _, pid := range overrides { + sb.WriteString(fmt.Sprintf(" %s\n", pid)) + } + } + + ce.Reply(sb.String()) +} + +// cmdMsgDebug inspects cloud_message for a given identifier and reports where +// messages ended up, breaking out from-me vs not-from-me counts, and showing +// sibling group portals. This helps diagnose two common bugs: +// +// - DM has 0 messages in cloud_message (APNs-only or wrong portal_id) +// - Group chat shows only one side (per-participant UUID routing split) +// +// Usage: +// +// !msg-debug +19176138320 — phone number (normalized automatically) +// !msg-debug user@example.com — email handle +// !msg-debug gid:abc123-uuid — explicit group portal ID +var cmdMsgDebug = &commands.FullHandler{ + Name: "msg-debug", + Aliases: []string{"msgdbg"}, + Func: fnMsgDebug, + RequiresLogin: true, + Help: commands.HelpMeta{ + Section: commands.HelpSectionChats, + Description: "Check cloud_message sync status and IDS registration for a contact. Shows sync progress, per-participant UUID routing splits, and which handles Apple's IDS confirms as iMessage. Pass an identifier to avoid accidentally opening a chat room.", + Args: "[phone|email|gid:uuid]", + }, +} + +func fnMsgDebug(ce *commands.Event) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("Not logged in.") + return + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil { + ce.Reply("Bridge client not available.") + return + } + if !client.Main.Config.UseCloudKitBackfill() || client.cloudStore == nil { + ce.Reply("CloudKit backfill not enabled.") + return + } + + identifier := strings.TrimSpace(ce.RawArgs) + + // Resolve to a canonical portal_id. + var portalID string + var inputDesc string + + if identifier == "" { + // No args: use current room's portal (lets user run from inside a chat room). + if ce.Portal == nil { + ce.Reply("Usage: `!msg-debug `\n\nOr run from inside a bridged room with no arguments.\n\nExamples:\n `!msg-debug +19176138320`\n `!msg-debug user@example.com`\n `!msg-debug gid:abc123`") + return + } + portalID = string(ce.Portal.ID) + inputDesc = "(current room)" + } else if strings.HasPrefix(identifier, "gid:") { + portalID = identifier + inputDesc = identifier + } else { + normalized := normalizeIdentifierForPortalID(identifier) + if normalized == "" { + ce.Reply("Could not normalise %q as a phone number or email.", identifier) + return + } + resolved := client.resolveContactPortalID(normalized) + resolved = client.resolveExistingDMPortalID(string(resolved)) + portalID = string(resolved) + inputDesc = identifier + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("**msg-debug: `%s`**\n", portalID)) + sb.WriteString(fmt.Sprintf("_input: `%s`_\n\n", inputDesc)) + + // ── 0. cloud_chat status (did the chat metadata even sync?) ────────────── + chatInfo, chatErr := client.cloudStore.debugChatInfo(ce.Ctx, portalID) + if chatErr != nil { + sb.WriteString(fmt.Sprintf("cloud_chat lookup error: %v\n\n", chatErr)) + } else if !chatInfo.Found { + sb.WriteString("⚠️ **cloud_chat: NOT FOUND** — chat metadata hasn't synced yet (or this identifier never appeared in CloudKit)\n\n") + } else { + chatStatus := "✓ live" + if chatInfo.Deleted { + chatStatus = "soft-deleted" + } + filteredNote := "" + if chatInfo.IsFiltered != 0 { + filteredNote = fmt.Sprintf(" ⚠️ IS_FILTERED=%d (excluded from portal creation!)", chatInfo.IsFiltered) + } + sb.WriteString(fmt.Sprintf("cloud_chat: %s cid=%s gid=%s%s\n\n", chatStatus, chatInfo.CloudChatID, chatInfo.GroupID, filteredNote)) + } + + // ── 1. IDS lookup (is this handle registered on iMessage?) ─────────────── + if !strings.HasPrefix(portalID, "gid:") && client.client != nil { + idsTargets := []string{portalID} + contact := client.lookupContact(portalID) + for _, altID := range contactPortalIDs(contact) { + if altID != portalID { + idsTargets = append(idsTargets, altID) + } + } + valid := client.client.ValidateTargets(idsTargets, client.handle) + validSet := make(map[string]bool, len(valid)) + for _, v := range valid { + validSet[v] = true + } + sb.WriteString("**IDS lookup:**\n") + for _, t := range idsTargets { + status := "❌ not on iMessage" + if validSet[t] { + status = "✅ iMessage" + } + note := "" + if t != portalID { + note = " (contact alias)" + } + sb.WriteString(fmt.Sprintf(" %s — %s%s\n", t, status, note)) + } + if len(valid) == 0 { + sb.WriteString(" ⚠️ No handles validated — number may not be on iMessage, or IDS is unavailable\n") + } else if !validSet[portalID] { + // Primary ID not valid, but an alias is — this means cloud_message stored under alias! + sb.WriteString(fmt.Sprintf(" ⚠️ Primary portal ID is NOT on iMessage, but alias(es) above are — messages may be stored under a different portal ID!\n")) + } + sb.WriteString("\n") + } + + // ── 2. Per-portal message stats (primary + group siblings) ──────────────── + stats, err := client.cloudStore.debugMessageStats(ce.Ctx, portalID) + if err != nil { + sb.WriteString(fmt.Sprintf("Error querying stats: %v\n", err)) + } else if len(stats) == 0 { + // Show total sync progress so user knows if sync is still running. + totalMsgs, _ := client.cloudStore.debugTotalMessageCount(ce.Ctx) + syncDone := client.isCloudSyncDone() + client.cloudSyncRunningLock.RLock() + syncRunning := client.cloudSyncRunning + client.cloudSyncRunningLock.RUnlock() + syncStatus := "✅ complete" + if syncRunning { + syncStatus = "⏳ running now" + } else if !syncDone { + syncStatus = "⚠️ not started / interrupted" + } + sb.WriteString(fmt.Sprintf("No cloud_message rows found for this portal (or its group siblings).\nSync: %s — %d messages ingested across all portals\n", syncStatus, totalMsgs)) + } else { + sb.WriteString("**cloud_message rows by portal:**\n") + for _, s := range stats { + marker := "" + if s.PortalID == portalID { + marker = " ← target" + } + chatSample := "" + if len(s.SampleChats) > 0 { + chatSample = "\n chat_ids=" + strings.Join(s.SampleChats, ", ") + } + senderSample := "" + if len(s.SampleSenders) > 0 { + senderSample = "\n senders=" + strings.Join(s.SampleSenders, ", ") + } + emptySenderNote := "" + if s.EmptySender > 0 { + emptySenderNote = fmt.Sprintf(" ⚠️ empty_sender=%d (will be filtered from backfill!)", s.EmptySender) + } + sb.WriteString(fmt.Sprintf(" %s%s\n total=%d from_me=%d not_from_me=%d%s%s%s\n", + s.PortalID, marker, s.Total, s.FromMe, s.NotFromMe, emptySenderNote, chatSample, senderSample)) + } + } + + // ── 3. For DMs: search by identifier suffix (catch normalization splits) ── + if !strings.HasPrefix(portalID, "gid:") { + // Strip tel: / mailto: prefix to get the raw identifier. + suffix := strings.TrimPrefix(strings.TrimPrefix(portalID, "tel:"), "mailto:") + if suffix != "" && suffix != portalID { + matches, suffixErr := client.cloudStore.debugFindPortalsByIdentifierSuffix(ce.Ctx, suffix) + if suffixErr == nil && len(matches) > 0 { + anyOther := false + for _, m := range matches { + if m[0] != portalID { + anyOther = true + break + } + } + if anyOther { + sb.WriteString(fmt.Sprintf("\n**Portals with chat_id containing `%s`:**\n", suffix)) + for _, m := range matches { + marker := "" + if m[0] == portalID { + marker = " ← target" + } + sb.WriteString(fmt.Sprintf(" %s count=%s%s\n", m[0], m[1], marker)) + } + } + } + } + } + + ce.Reply(sb.String()) +} diff --git a/pkg/connector/config.go b/pkg/connector/config.go new file mode 100644 index 00000000..2dcc45fa --- /dev/null +++ b/pkg/connector/config.go @@ -0,0 +1,158 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + _ "embed" + "strings" + "text/template" + + up "go.mau.fi/util/configupgrade" + "gopkg.in/yaml.v3" +) + +//go:embed example-config.yaml +var ExampleConfig string + +type IMConfig struct { + DisplaynameTemplate string `yaml:"displayname_template"` + displaynameTemplate *template.Template + + // CloudKitBackfill enables message history backfill (master on/off switch). + // When false, the bridge only handles real-time messages via APNs push + // and skips the device PIN / iCloud Keychain steps during login. + // Default is false. + CloudKitBackfill bool `yaml:"cloudkit_backfill"` + + // BackfillSource selects the backfill engine when CloudKitBackfill is true. + // "cloudkit" (default) syncs from iCloud; "chatdb" reads the local macOS + // chat.db (requires Full Disk Access). + BackfillSource string `yaml:"backfill_source"` + + // VideoTranscoding enables automatic remuxing/transcoding of non-MP4 + // videos (e.g. QuickTime .mov) to MP4 for broad Matrix client + // compatibility. Requires ffmpeg to be installed. Default is false. + VideoTranscoding bool `yaml:"video_transcoding"` + + // HEICConversion enables automatic conversion of HEIC/HEIF images + // to JPEG for broad Matrix client compatibility. + // Requires libheif to be installed. Default is false. + HEICConversion bool `yaml:"heic_conversion"` + + // HEICJPEGQuality sets the JPEG output quality (1–100) used when + // converting HEIC/HEIF images. Default is 95. + HEICJPEGQuality int `yaml:"heic_jpeg_quality"` + + // PreferredHandle overrides the outgoing iMessage identity. + // Use the full URI format: "tel:+15551234567" or "mailto:user@example.com". + // If empty, the handle chosen during login is used. + PreferredHandle string `yaml:"preferred_handle"` + + // CardDAV is an external CardDAV server for contact name resolution. + // When configured, this is used instead of iCloud CardDAV contacts. + CardDAV CardDAVConfig `yaml:"carddav"` +} + +// CardDAVConfig configures an external CardDAV server for contact name resolution. +// Supports Google (with app passwords), Nextcloud, Radicale, Fastmail, etc. +type CardDAVConfig struct { + // Email address used for CardDAV auto-discovery (RFC 6764 .well-known/carddav). + // Also used as the username if Username is empty. + Email string `yaml:"email"` + + // URL is the CardDAV server URL. Leave empty to auto-discover from Email. + // Example: https://www.googleapis.com/carddav/v1/principals/you@gmail.com/lists/default/ + URL string `yaml:"url"` + + // Username for HTTP Basic authentication. Defaults to Email if empty. + Username string `yaml:"username"` + + // PasswordEncrypted is the AES-256-GCM encrypted app password (base64). + // Set by the install script via the carddav-setup subcommand. + PasswordEncrypted string `yaml:"password_encrypted"` +} + +// IsConfigured returns true if the CardDAV config has enough info to connect. +func (c *CardDAVConfig) IsConfigured() bool { + return c.Email != "" && c.PasswordEncrypted != "" +} + +// GetUsername returns the effective username (falls back to Email). +func (c *CardDAVConfig) GetUsername() string { + if c.Username != "" { + return c.Username + } + return c.Email +} + +type umIMConfig IMConfig + +func (c *IMConfig) UnmarshalYAML(node *yaml.Node) error { + err := node.Decode((*umIMConfig)(c)) + if err != nil { + return err + } + return c.PostProcess() +} + +func (c *IMConfig) PostProcess() error { + var err error + c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate) + return err +} + +type DisplaynameParams struct { + FirstName string + LastName string + Nickname string + Phone string + Email string + ID string +} + +func (c *IMConfig) FormatDisplayname(params DisplaynameParams) string { + var buf strings.Builder + err := c.displaynameTemplate.Execute(&buf, ¶ms) + if err != nil { + return params.ID + } + name := strings.TrimSpace(buf.String()) + if name == "" { + return params.ID + } + return name +} + +// UseChatDBBackfill returns true when backfill is enabled and sourced from chat.db. +func (c *IMConfig) UseChatDBBackfill() bool { + return c.CloudKitBackfill && c.BackfillSource == "chatdb" +} + +// UseCloudKitBackfill returns true when backfill is enabled and sourced from CloudKit. +func (c *IMConfig) UseCloudKitBackfill() bool { + return c.CloudKitBackfill && c.BackfillSource != "chatdb" +} + +func upgradeConfig(helper up.Helper) { + helper.Copy(up.Str, "displayname_template") + helper.Copy(up.Bool, "cloudkit_backfill") + helper.Copy(up.Str, "backfill_source") + helper.Copy(up.Bool, "video_transcoding") + helper.Copy(up.Bool, "heic_conversion") + helper.Copy(up.Int, "heic_jpeg_quality") + helper.Copy(up.Str, "preferred_handle") + helper.Copy(up.Str, "carddav", "email") + helper.Copy(up.Str, "carddav", "url") + helper.Copy(up.Str, "carddav", "username") + helper.Copy(up.Str, "carddav", "password_encrypted") +} + +func (c *IMConnector) GetConfig() (string, any, up.Upgrader) { + return ExampleConfig, &c.Config, up.SimpleUpgrader(upgradeConfig) +} diff --git a/pkg/connector/config_test.go b/pkg/connector/config_test.go new file mode 100644 index 00000000..6ba55792 --- /dev/null +++ b/pkg/connector/config_test.go @@ -0,0 +1,173 @@ +package connector + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestCardDAVConfig_IsConfigured(t *testing.T) { + tests := []struct { + name string + cfg CardDAVConfig + want bool + }{ + {"both set", CardDAVConfig{Email: "a@b.com", PasswordEncrypted: "enc"}, true}, + {"no email", CardDAVConfig{PasswordEncrypted: "enc"}, false}, + {"no password", CardDAVConfig{Email: "a@b.com"}, false}, + {"empty", CardDAVConfig{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.IsConfigured(); got != tt.want { + t.Errorf("IsConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCardDAVConfig_GetUsername(t *testing.T) { + t.Run("explicit username", func(t *testing.T) { + cfg := CardDAVConfig{Email: "a@b.com", Username: "user"} + if got := cfg.GetUsername(); got != "user" { + t.Errorf("GetUsername() = %q, want %q", got, "user") + } + }) + t.Run("falls back to email", func(t *testing.T) { + cfg := CardDAVConfig{Email: "a@b.com"} + if got := cfg.GetUsername(); got != "a@b.com" { + t.Errorf("GetUsername() = %q, want %q", got, "a@b.com") + } + }) +} + +func TestIMConfig_PostProcess(t *testing.T) { + c := &IMConfig{DisplaynameTemplate: "{{.FirstName}} {{.LastName}}"} + if err := c.PostProcess(); err != nil { + t.Fatalf("PostProcess() error: %v", err) + } + if c.displaynameTemplate == nil { + t.Fatal("displaynameTemplate should not be nil after PostProcess") + } +} + +func TestIMConfig_PostProcess_InvalidTemplate(t *testing.T) { + c := &IMConfig{DisplaynameTemplate: "{{.Bad"} + if err := c.PostProcess(); err == nil { + t.Error("PostProcess() should return error for invalid template") + } +} + +func TestIMConfig_FormatDisplayname(t *testing.T) { + c := &IMConfig{DisplaynameTemplate: "{{.FirstName}} {{.LastName}}"} + c.PostProcess() + + tests := []struct { + name string + params DisplaynameParams + want string + }{ + {"full name", DisplaynameParams{FirstName: "Alice", LastName: "Smith", ID: "id1"}, "Alice Smith"}, + {"first only", DisplaynameParams{FirstName: "Alice", ID: "id2"}, "Alice"}, + {"empty falls back to ID", DisplaynameParams{ID: "fallback-id"}, "fallback-id"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := c.FormatDisplayname(tt.params) + if got != tt.want { + t.Errorf("FormatDisplayname() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIMConfig_FormatDisplayname_DefaultTemplate(t *testing.T) { + tmpl := `{{if .FirstName}}{{.FirstName}}{{if .LastName}} {{.LastName}}{{end}}{{else if .Nickname}}{{.Nickname}}{{else if .Phone}}{{.Phone}}{{else if .Email}}{{.Email}}{{else}}{{.ID}}{{end}}` + c := &IMConfig{DisplaynameTemplate: tmpl} + c.PostProcess() + + tests := []struct { + name string + params DisplaynameParams + want string + }{ + {"first+last", DisplaynameParams{FirstName: "Alice", LastName: "Smith"}, "Alice Smith"}, + {"first only", DisplaynameParams{FirstName: "Alice"}, "Alice"}, + {"nickname", DisplaynameParams{Nickname: "Al"}, "Al"}, + {"phone", DisplaynameParams{Phone: "+1555"}, "+1555"}, + {"email", DisplaynameParams{Email: "a@b.com"}, "a@b.com"}, + {"id fallback", DisplaynameParams{ID: "some-id"}, "some-id"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := c.FormatDisplayname(tt.params) + if got != tt.want { + t.Errorf("FormatDisplayname() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIMConfig_UseChatDBBackfill(t *testing.T) { + tests := []struct { + name string + cfg IMConfig + want bool + }{ + {"enabled chatdb", IMConfig{CloudKitBackfill: true, BackfillSource: "chatdb"}, true}, + {"enabled cloudkit", IMConfig{CloudKitBackfill: true, BackfillSource: "cloudkit"}, false}, + {"disabled chatdb", IMConfig{CloudKitBackfill: false, BackfillSource: "chatdb"}, false}, + {"disabled empty", IMConfig{CloudKitBackfill: false}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.UseChatDBBackfill(); got != tt.want { + t.Errorf("UseChatDBBackfill() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIMConfig_UseCloudKitBackfill(t *testing.T) { + tests := []struct { + name string + cfg IMConfig + want bool + }{ + {"enabled cloudkit", IMConfig{CloudKitBackfill: true, BackfillSource: "cloudkit"}, true}, + {"enabled empty source", IMConfig{CloudKitBackfill: true}, true}, + {"enabled chatdb", IMConfig{CloudKitBackfill: true, BackfillSource: "chatdb"}, false}, + {"disabled", IMConfig{CloudKitBackfill: false}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.UseCloudKitBackfill(); got != tt.want { + t.Errorf("UseCloudKitBackfill() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIMConfig_UnmarshalYAML(t *testing.T) { + yamlData := ` +displayname_template: "{{.FirstName}}" +cloudkit_backfill: true +backfill_source: chatdb +` + var c IMConfig + if err := yaml.Unmarshal([]byte(yamlData), &c); err != nil { + t.Fatalf("UnmarshalYAML error: %v", err) + } + if c.DisplaynameTemplate != "{{.FirstName}}" { + t.Errorf("DisplaynameTemplate = %q, want %q", c.DisplaynameTemplate, "{{.FirstName}}") + } + if !c.CloudKitBackfill { + t.Error("CloudKitBackfill should be true") + } + if c.BackfillSource != "chatdb" { + t.Errorf("BackfillSource = %q, want %q", c.BackfillSource, "chatdb") + } + if c.displaynameTemplate == nil { + t.Error("displaynameTemplate should be set after unmarshal (PostProcess called)") + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go new file mode 100644 index 00000000..0f18f516 --- /dev/null +++ b/pkg/connector/connector.go @@ -0,0 +1,331 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "context" + "fmt" + "math" + "runtime" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/id" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +func isRunningOnMacOS() bool { + return runtime.GOOS == "darwin" +} + +type IMConnector struct { + Bridge *bridgev2.Bridge + Config IMConfig +} + +var _ bridgev2.NetworkConnector = (*IMConnector)(nil) + +func (c *IMConnector) GetName() bridgev2.BridgeName { + return bridgev2.BridgeName{ + DisplayName: "iMessage", + NetworkURL: "https://support.apple.com/messages", + NetworkIcon: "mxc://maunium.net/tManJEpANASZvDVzvRvhILdl", + NetworkID: "imessage", + BeeperBridgeType: "imessagego", + DefaultPort: 29332, + DefaultCommandPrefix: "!im", + } +} + +func (c *IMConnector) Init(bridge *bridgev2.Bridge) { + c.Bridge = bridge +} + +func (c *IMConnector) Start(ctx context.Context) error { + // Override backfill defaults for iMessage CloudKit sync. + // Applied in Start() because Init() runs before config YAML is loaded. + // Only apply when CloudKit backfill is enabled — otherwise leave the + // mautrix defaults alone (backfill won't be used). + if c.Config.CloudKitBackfill { + // The mautrix defaults (max_initial_messages=50, batch_size=100) are too + // low — CloudKit chats can have tens of thousands of messages, and many + // small backward batch_send requests create fragmented DAG branches that + // clients can't paginate through. High max_initial_messages ensures all + // messages are delivered in one forward batch during room creation. + cfg := &c.Bridge.Config.Backfill + if !cfg.Enabled { + cfg.Enabled = true + } + if cfg.MaxInitialMessages < 100 { + cfg.MaxInitialMessages = math.MaxInt32 // uncapped — backfill everything CloudKit downloaded + } + // Catchup should match the initial cap — unlimited when uncapped, + // capped when the user caps max_initial_messages. + cfg.MaxCatchupMessages = cfg.MaxInitialMessages + if !cfg.Queue.Enabled { + cfg.Queue.Enabled = true + } + if cfg.Queue.BatchSize <= 100 { + cfg.Queue.BatchSize = 10000 + } + if cfg.MaxInitialMessages < math.MaxInt32 { + // User explicitly capped initial messages — disable backward + // backfill so the cap is the final word on message count. + cfg.Queue.MaxBatches = 0 + } else if cfg.Queue.MaxBatches == 0 { + cfg.Queue.MaxBatches = -1 + } + } + + // Auto-restore: if the DB has no logins but we have valid backup session + // state (session.json + keystore), create a user_login from the backup + // instead of requiring a full re-login. + c.tryAutoRestore(ctx) + + return nil +} + +// tryAutoRestore checks if the database is empty but valid session state +// exists in the backup files. If so, it creates a user_login entry from +// the backup, avoiding the need for a full Apple ID re-authentication. +func (c *IMConnector) tryAutoRestore(ctx context.Context) { + log := c.Bridge.Log.With().Str("component", "imessage").Logger() + + // Only restore if there are no existing logins. + usersWithLogins, err := c.Bridge.DB.UserLogin.GetAllUserIDsWithLogins(ctx) + if err != nil { + log.Warn().Err(err).Msg("Failed to check existing logins for auto-restore") + return + } + if len(usersWithLogins) > 0 { + return // DB already has logins, nothing to restore + } + + // Check for backup session state + state := loadSessionState(log) + if state.IDSUsers == "" || state.IDSIdentity == "" || state.APSState == "" { + log.Debug().Msg("No complete backup session state found, skipping auto-restore") + return + } + + // Validate against keystore + rustpushgo.InitLogger() + session := &cachedSessionState{ + IDSIdentity: state.IDSIdentity, + APSState: state.APSState, + IDSUsers: state.IDSUsers, + source: "backup file (auto-restore)", + } + if !session.validate(log) { + log.Info().Msg("Backup session state failed keystore validation, skipping auto-restore") + return + } + // Chat.db mode doesn't join the keychain clique (no CloudKit), so + // trustedpeers.plist is never written. Only require clique state + // when CloudKit backfill is active. + if c.Config.UseCloudKitBackfill() && !hasKeychainCliqueState(log) { + log.Info().Msg("Skipping auto-restore: keychain trust circle not initialized (will require interactive login)") + return + } + + // Extract login ID and username from the cached IDS users + users := rustpushgo.NewWrappedIdsUsers(&state.IDSUsers) + loginID := networkid.UserLoginID(users.LoginId(0)) + if loginID == "" { + log.Warn().Msg("Backup session has no login ID, skipping auto-restore") + return + } + + handles := users.GetHandles() + username := string(loginID) + if len(handles) > 0 { + username = handles[0] + } + + // Find the admin user to attach this login to + adminMXID := "" + for userID, perm := range c.Bridge.Config.Permissions { + if perm.Admin { + adminMXID = userID + break + } + } + if adminMXID == "" { + log.Warn().Msg("No admin user in config, skipping auto-restore") + return + } + + user, err := c.Bridge.GetUserByMXID(ctx, id.UserID(adminMXID)) + if err != nil { + log.Warn().Err(err).Msg("Failed to get admin user for auto-restore") + return + } + + log.Info(). + Str("login_id", string(loginID)). + Str("username", username). + Msg("Auto-restoring login from backup session state") + + platform := state.Platform + if platform == "" { + platform = runtime.GOOS + } + + meta := &UserLoginMetadata{ + Platform: platform, + HardwareKey: state.HardwareKey, + DeviceID: state.DeviceID, + ChatsSynced: false, + APSState: state.APSState, + IDSUsers: state.IDSUsers, + IDSIdentity: state.IDSIdentity, + AccountUsername: state.AccountUsername, + AccountHashedPasswordHex: state.AccountHashedPasswordHex, + AccountPET: state.AccountPET, + AccountADSID: state.AccountADSID, + AccountDSID: state.AccountDSID, + AccountSPDBase64: state.AccountSPDBase64, + MmeDelegateJSON: state.MmeDelegateJSON, + } + + _, err = user.NewLogin(ctx, &database.UserLogin{ + ID: loginID, + RemoteName: username, + RemoteProfile: status.RemoteProfile{ + Name: username, + }, + Metadata: meta, + }, &bridgev2.NewLoginParams{ + DeleteOnConflict: true, + }) + if err != nil { + log.Err(err).Msg("Failed to auto-restore login from backup") + return + } + + log.Info().Str("login_id", string(loginID)).Msg("Successfully auto-restored login from backup session state") +} + +func (c *IMConnector) GetLoginFlows() []bridgev2.LoginFlow { + flows := []bridgev2.LoginFlow{} + if isRunningOnMacOS() { + flows = append(flows, bridgev2.LoginFlow{ + Name: "Apple ID", + Description: "Log in with your Apple ID to send and receive iMessages", + ID: LoginFlowIDAppleID, + }) + } + flows = append(flows, bridgev2.LoginFlow{ + Name: "Apple ID (External Key)", + Description: "Log in using a hardware key extracted from a Mac. Works on any platform.", + ID: LoginFlowIDExternalKey, + }) + return flows +} + +func (c *IMConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { + switch flowID { + case LoginFlowIDAppleID: + if !isRunningOnMacOS() { + return nil, fmt.Errorf("Apple ID login requires macOS. Use 'External Key' login on other platforms.") + } + return &AppleIDLogin{User: user, Main: c}, nil + case LoginFlowIDExternalKey: + return &ExternalKeyLogin{User: user, Main: c}, nil + default: + return nil, fmt.Errorf("unknown login flow: %s", flowID) + } +} + +func (c *IMConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error { + meta := login.Metadata.(*UserLoginMetadata) + log := c.Bridge.Log.With().Str("component", "imessage").Logger() + + rustpushgo.InitLogger() + + var cfg *rustpushgo.WrappedOsConfig + var err error + + if meta.HardwareKey != "" { + // Cross-platform mode: use hardware key with open-absinthe NAC emulation. + if meta.DeviceID != "" { + cfg, err = rustpushgo.CreateConfigFromHardwareKeyWithDeviceId(meta.HardwareKey, meta.DeviceID) + } else { + cfg, err = rustpushgo.CreateConfigFromHardwareKey(meta.HardwareKey) + } + } else if isRunningOnMacOS() { + // Local macOS mode: use IOKit + AAAbsintheContext. + if meta.DeviceID != "" { + cfg, err = rustpushgo.CreateLocalMacosConfigWithDeviceId(meta.DeviceID) + } else { + cfg, err = rustpushgo.CreateLocalMacosConfig() + } + } else { + return fmt.Errorf("no hardware key configured and not running on macOS — re-login with 'External Key' flow") + } + if err != nil { + return fmt.Errorf("failed to create config: %w", err) + } + + usersStr := &meta.IDSUsers + identityStr := &meta.IDSIdentity + apsStateStr := &meta.APSState + + // Eagerly persist full session state to the backup file so it survives DB resets. + saveSessionState(log, PersistedSessionState{ + IDSIdentity: meta.IDSIdentity, + APSState: meta.APSState, + IDSUsers: meta.IDSUsers, + PreferredHandle: meta.PreferredHandle, + Platform: meta.Platform, + HardwareKey: meta.HardwareKey, + DeviceID: meta.DeviceID, + AccountUsername: meta.AccountUsername, + AccountHashedPasswordHex: meta.AccountHashedPasswordHex, + AccountPET: meta.AccountPET, + AccountADSID: meta.AccountADSID, + AccountDSID: meta.AccountDSID, + AccountSPDBase64: meta.AccountSPDBase64, + MmeDelegateJSON: meta.MmeDelegateJSON, + }) + + client := &IMClient{ + Main: c, + UserLogin: login, + config: cfg, + users: rustpushgo.NewWrappedIdsUsers(usersStr), + identity: rustpushgo.NewWrappedIdsngmIdentity(identityStr), + connection: rustpushgo.Connect(cfg, rustpushgo.NewWrappedApsState(apsStateStr)), + contactsReady: false, + contactsReadyCh: make(chan struct{}), + cloudStore: newCloudBackfillStore(c.Bridge.DB.Database, login.ID), + sharedProfileStore: newSharedProfileStore(c.Bridge.DB.Database, login.ID), + fordCache: NewFordKeyCache(), + recentUnsends: make(map[string]time.Time), + recentOutboundUnsends: make(map[string]time.Time), + recentSmsReactionEchoes: make(map[string]time.Time), + smsPortals: make(map[string]bool), + sharedStreamAssetCache: make(map[string]map[string]struct{}), + sharedAlbumRooms: make(map[string]id.RoomID), + imGroupNames: make(map[string]string), + imGroupGuids: make(map[string]string), + imGroupParticipants: make(map[string][]string), + gidAliases: make(map[string]string), + lastGroupForMember: make(map[string]networkid.PortalKey), + restorePipelines: make(map[string]bool), + forwardBackfillSem: make(chan struct{}, 3), + } + + login.Client = client + return nil +} diff --git a/pkg/connector/contact_merge.go b/pkg/connector/contact_merge.go new file mode 100644 index 00000000..162817a7 --- /dev/null +++ b/pkg/connector/contact_merge.go @@ -0,0 +1,313 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +// Contact-based DM portal merging. +// +// When a contact has multiple phone numbers or emails, iMessage stores each +// as a separate conversation. Without merging, the bridge creates separate +// Matrix rooms for each number. This file provides helpers to redirect +// incoming messages from a secondary phone number to an existing primary portal. + +import ( + "context" + "sort" + "strings" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + + "github.com/lrhodin/imessage/imessage" +) + +// resolveContactPortalID checks if the given DM identifier belongs to a contact +// that already has an existing portal under a different phone number or email. +// Returns the original identifier (as a PortalID) if no existing portal is found. +func (c *IMClient) resolveContactPortalID(identifier string) networkid.PortalID { + defaultID := networkid.PortalID(identifier) + + if strings.Contains(identifier, ",") { + return defaultID + } + + contact := c.lookupContact(identifier) + if contact == nil || !contact.HasName() { + return defaultID + } + + altIDs := contactPortalIDs(contact) + if len(altIDs) <= 1 { + return defaultID + } + + ctx := context.Background() + for _, altID := range altIDs { + if altID == identifier { + continue + } + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, networkid.PortalKey{ + ID: networkid.PortalID(altID), + Receiver: c.UserLogin.ID, + }) + if err == nil && portal != nil && portal.MXID != "" { + c.UserLogin.Log.Debug(). + Str("original", identifier). + Str("resolved", altID). + Msg("Resolved contact portal to existing portal") + return networkid.PortalID(altID) + } + } + + return defaultID +} + +// resolveSendTarget determines the best identifier to send to for a DM portal. +func (c *IMClient) resolveSendTarget(portalID string) (target string) { + if c.client == nil || strings.Contains(portalID, ",") { + return portalID + } + + contact := c.lookupContact(portalID) + if contact == nil || len(contactPortalIDs(contact)) <= 1 { + return portalID + } + // ValidateTargets crosses into the identity-manager FFI path. Upstream + // has reachable panic sites (identity_manager.rs:249/335/542/555); fall + // back to the original portalID if the FFI call panics rather than + // crashing the send path. + defer func() { + if r := recover(); r != nil { + c.UserLogin.Log.Error().Interface("panic", r).Str("portal_id", portalID). + Msg("resolveSendTarget panicked in FFI path — falling back to original portalID") + target = portalID + } + }() + + valid := c.client.ValidateTargets([]string{portalID}, c.handle) + if len(valid) > 0 { + return portalID + } + + c.UserLogin.Log.Info(). + Str("portal_id", portalID). + Msg("Portal ID not reachable on iMessage, trying alternate contact numbers") + + for _, altID := range contactPortalIDs(contact) { + if altID == portalID { + continue + } + valid := c.client.ValidateTargets([]string{altID}, c.handle) + if len(valid) > 0 { + c.UserLogin.Log.Info(). + Str("portal_id", portalID). + Str("send_target", altID). + Msg("Resolved send target to alternate contact number") + return altID + } + } + + c.UserLogin.Log.Warn(). + Str("portal_id", portalID). + Msg("No reachable number found for contact") + return portalID +} + +// lookupContact resolves a portal/identifier string to a Contact using +// cloud contacts (iCloud CardDAV), falling back to chat.db contacts. +func (c *IMClient) lookupContact(identifier string) *imessage.Contact { + localID := stripIdentifierPrefix(identifier) + if localID == "" { + return nil + } + + if c.contacts != nil { + contact, _ := c.contacts.GetContactInfo(localID) + if contact != nil { + return contact + } + } + if c.chatDB != nil { + contact, _ := c.chatDB.api.GetContactInfo(localID) + return contact + } + return nil +} + +// getUniqueParticipantCount counts the number of unique *people* in a +// participant list by collapsing multiple self-handles into one and merging +// handles that belong to the same contact. +func (c *IMClient) getUniqueParticipantCount(participants []string) int { + seen := make(map[string]bool) + selfSeen := false + count := 0 + for _, p := range participants { + normalized := normalizeIdentifierForPortalID(p) + if normalized == "" { + count++ + continue + } + if c.isMyHandle(normalized) { + if !selfSeen { + selfSeen = true + count++ + } + continue + } + if seen[normalized] { + continue + } + count++ + seen[normalized] = true + contact := c.lookupContact(normalized) + if contact == nil { + continue + } + for _, altID := range contactPortalIDs(contact) { + seen[altID] = true + } + } + return count +} + +// getContactChatGUIDs returns all possible chat.db GUIDs for a DM portal, +// including GUIDs for alternate phone numbers/emails belonging to the same contact. +func (c *IMClient) getContactChatGUIDs(portalID string) []string { + guids := portalIDToChatGUIDs(portalID) + + contact := c.lookupContact(portalID) + if contact == nil { + return guids + } + + for _, altID := range contactPortalIDs(contact) { + if altID == portalID { + continue + } + guids = append(guids, portalIDToChatGUIDs(altID)...) + } + + return guids +} + +// contactKeyFromContact returns a stable identity key for grouping a contact's +// DM entries during initial sync deduplication. Returns "" if no merging is +// needed (single phone, no name, etc.). +func contactKeyFromContact(contact *imessage.Contact) string { + if contact == nil || !contact.HasName() { + return "" + } + phones := make([]string, 0, len(contact.Phones)) + for _, p := range contact.Phones { + n := normalizePhoneForPortalID(p) + if n != "" { + phones = append(phones, n) + } + } + if len(phones) <= 1 { + return "" + } + sort.Strings(phones) + return strings.Join(phones, "|") +} + +// contactPortalIDs returns all portal ID strings for a contact's phone numbers +// and emails. +func contactPortalIDs(contact *imessage.Contact) []string { + if contact == nil { + return nil + } + + seen := make(map[string]bool) + var ids []string + + for _, phone := range contact.Phones { + normalized := normalizePhoneForPortalID(phone) + if normalized == "" { + continue + } + pid := "tel:" + normalized + if !seen[pid] { + seen[pid] = true + ids = append(ids, pid) + } + } + + for _, email := range contact.Emails { + pid := "mailto:" + strings.ToLower(email) + if !seen[pid] { + seen[pid] = true + ids = append(ids, pid) + } + } + + return ids +} + +// normalizePhoneForPortalID converts a phone number to E.164-like format. +func normalizePhoneForPortalID(phone string) string { + n := normalizePhone(phone) + if n == "" { + return "" + } + if strings.HasPrefix(n, "+") { + return n + } + if len(n) == 10 { + return "+1" + n + } + if len(n) == 11 && n[0] == '1' { + return "+" + n + } + return "+" + n +} + +// canonicalContactHandle returns a deterministic canonical handle for a contact +// that has multiple iMessage handles (phone + email). This ensures CloudKit +// backfill creates a single portal per contact rather than one per handle. +// If the identifier doesn't resolve to a multi-handle contact, returns it unchanged. +func (c *IMClient) canonicalContactHandle(identifier string) string { + contact := c.lookupContact(identifier) + if contact == nil || !contact.HasName() { + return identifier + } + altIDs := contactPortalIDs(contact) + if len(altIDs) <= 1 { + return identifier + } + sort.Strings(altIDs) + for _, id := range altIDs { + if strings.HasPrefix(id, "tel:") { + return id + } + } + return altIDs[0] +} + +// canonicalizeDMSender remaps the sender identity for DM events so that the +// ghost matches the portal's canonical handle. Without this, a contact sending +// from their email handle into a phone-based DM portal causes a phantom ghost +// to briefly join the room. +func (c *IMClient) canonicalizeDMSender(portalKey networkid.PortalKey, sender bridgev2.EventSender) bridgev2.EventSender { + if sender.IsFromMe { + return sender + } + portalID := string(portalKey.ID) + // Only remap for DM portals (not groups or gid: portals). + if strings.Contains(portalID, ",") || strings.HasPrefix(portalID, "gid:") { + return sender + } + canonicalUserID := makeUserID(portalID) + if sender.Sender != canonicalUserID { + return bridgev2.EventSender{ + IsFromMe: false, + Sender: canonicalUserID, + } + } + return sender +} diff --git a/pkg/connector/contacts_local_darwin.go b/pkg/connector/contacts_local_darwin.go new file mode 100644 index 00000000..64bd7a4a --- /dev/null +++ b/pkg/connector/contacts_local_darwin.go @@ -0,0 +1,49 @@ +//go:build darwin && !ios + +package connector + +import ( + "github.com/rs/zerolog" + + "github.com/lrhodin/imessage/imessage" + "github.com/lrhodin/imessage/imessage/mac" +) + +// localContactSource wraps the macOS Contacts framework as a contactSource. +// Used when backfill_source=chatdb (no iCloud, local-only). +type localContactSource struct { + store *mac.ContactStore + contacts []*imessage.Contact +} + +func newLocalContactSource(log zerolog.Logger) contactSource { + cs := mac.NewContactStore() + if err := cs.RequestContactAccess(); err != nil { + log.Warn().Err(err).Msg("Failed to request macOS contact access") + return nil + } + if !cs.HasContactAccess { + log.Warn().Msg("macOS contact access denied — contacts command will be unavailable") + return nil + } + log.Info().Msg("Using local macOS Contacts for contact resolution") + return &localContactSource{store: cs} +} + +func (l *localContactSource) SyncContacts(log zerolog.Logger) error { + contacts, err := l.store.GetContactList() + if err != nil { + return err + } + l.contacts = contacts + log.Info().Int("count", len(contacts)).Msg("Loaded local macOS contacts") + return nil +} + +func (l *localContactSource) GetContactInfo(identifier string) (*imessage.Contact, error) { + return l.store.GetContactInfo(identifier) +} + +func (l *localContactSource) GetAllContacts() []*imessage.Contact { + return l.contacts +} diff --git a/pkg/connector/contacts_local_other.go b/pkg/connector/contacts_local_other.go new file mode 100644 index 00000000..13dd1b1d --- /dev/null +++ b/pkg/connector/contacts_local_other.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package connector + +import "github.com/rs/zerolog" + +func newLocalContactSource(_ zerolog.Logger) contactSource { return nil } diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go new file mode 100644 index 00000000..c51c8574 --- /dev/null +++ b/pkg/connector/dbmeta.go @@ -0,0 +1,76 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "maunium.net/go/mautrix/bridgev2/database" +) + +type PortalMetadata struct { + ThreadID string `json:"thread_id,omitempty"` + SenderGuid string `json:"sender_guid,omitempty"` // Persistent iMessage group UUID + GroupName string `json:"group_name,omitempty"` // iMessage cv_name for outbound routing + IsSms bool `json:"is_sms,omitempty"` // True if this portal routes through SMS +} + +type GhostMetadata struct{} + +type MessageMetadata struct { + HasAttachments bool `json:"has_attachments,omitempty"` +} + +type UserLoginMetadata struct { + Platform string `json:"platform,omitempty"` + ChatsSynced bool `json:"chats_synced,omitempty"` + + // Persisted rustpush state (restored across restarts) + APSState string `json:"aps_state,omitempty"` + IDSUsers string `json:"ids_users,omitempty"` + IDSIdentity string `json:"ids_identity,omitempty"` + DeviceID string `json:"device_id,omitempty"` + + // Hardware key for cross-platform (non-macOS) operation. + // Base64-encoded JSON HardwareConfig extracted from a real Mac. + HardwareKey string `json:"hardware_key,omitempty"` + + // PreferredHandle is the user-chosen handle for outgoing messages + // (e.g. "tel:+15551234567" or "mailto:user@example.com"). + PreferredHandle string `json:"preferred_handle,omitempty"` + + // iCloud account persist data for TokenProvider restoration. + // Allows CardDAV contacts and CloudKit to work across restarts. + AccountUsername string `json:"account_username,omitempty"` + AccountHashedPasswordHex string `json:"account_hashed_password_hex,omitempty"` + AccountPET string `json:"account_pet,omitempty"` + AccountADSID string `json:"account_adsid,omitempty"` + AccountDSID string `json:"account_dsid,omitempty"` + AccountSPDBase64 string `json:"account_spd_base64,omitempty"` + + // Cached MobileMe delegate JSON — seeded on restore so contacts work + // without needing to refresh (which requires a still-valid PET). + MmeDelegateJSON string `json:"mme_delegate_json,omitempty"` +} + +func (c *IMConnector) GetDBMetaTypes() database.MetaTypes { + return database.MetaTypes{ + Portal: func() any { + return &PortalMetadata{} + }, + Ghost: func() any { + return &GhostMetadata{} + }, + Message: func() any { + return &MessageMetadata{} + }, + Reaction: nil, + UserLogin: func() any { + return &UserLoginMetadata{} + }, + } +} diff --git a/pkg/connector/dbmeta_test.go b/pkg/connector/dbmeta_test.go new file mode 100644 index 00000000..60e0a2ef --- /dev/null +++ b/pkg/connector/dbmeta_test.go @@ -0,0 +1,125 @@ +package connector + +import ( + "encoding/json" + "testing" +) + +func TestGetDBMetaTypes(t *testing.T) { + c := &IMConnector{} + mt := c.GetDBMetaTypes() + + if mt.Portal == nil { + t.Fatal("Portal factory should not be nil") + } + if mt.Ghost == nil { + t.Fatal("Ghost factory should not be nil") + } + if mt.Message == nil { + t.Fatal("Message factory should not be nil") + } + if mt.UserLogin == nil { + t.Fatal("UserLogin factory should not be nil") + } + if mt.Reaction != nil { + t.Error("Reaction factory should be nil") + } + + // Verify types + if _, ok := mt.Portal().(*PortalMetadata); !ok { + t.Error("Portal() should return *PortalMetadata") + } + if _, ok := mt.Ghost().(*GhostMetadata); !ok { + t.Error("Ghost() should return *GhostMetadata") + } + if _, ok := mt.Message().(*MessageMetadata); !ok { + t.Error("Message() should return *MessageMetadata") + } + if _, ok := mt.UserLogin().(*UserLoginMetadata); !ok { + t.Error("UserLogin() should return *UserLoginMetadata") + } +} + +func TestPortalMetadata_JSON(t *testing.T) { + pm := &PortalMetadata{ + ThreadID: "thread-123", + SenderGuid: "sender-456", + GroupName: "My Group", + } + data, err := json.Marshal(pm) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var pm2 PortalMetadata + if err := json.Unmarshal(data, &pm2); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if pm2 != *pm { + t.Errorf("round-trip mismatch: got %+v, want %+v", pm2, *pm) + } +} + +func TestMessageMetadata_JSON(t *testing.T) { + mm := &MessageMetadata{HasAttachments: true} + data, err := json.Marshal(mm) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var mm2 MessageMetadata + if err := json.Unmarshal(data, &mm2); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if mm2.HasAttachments != true { + t.Error("HasAttachments should be true") + } +} + +func TestUserLoginMetadata_JSON(t *testing.T) { + ulm := &UserLoginMetadata{ + Platform: "darwin", + ChatsSynced: true, + PreferredHandle: "tel:+15551234567", + HardwareKey: "base64stuff", + } + data, err := json.Marshal(ulm) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var ulm2 UserLoginMetadata + if err := json.Unmarshal(data, &ulm2); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if ulm2.Platform != "darwin" { + t.Errorf("Platform = %q, want %q", ulm2.Platform, "darwin") + } + if ulm2.PreferredHandle != "tel:+15551234567" { + t.Errorf("PreferredHandle = %q, want %q", ulm2.PreferredHandle, "tel:+15551234567") + } +} + +func TestGhostMetadata_JSON(t *testing.T) { + gm := &GhostMetadata{} + data, err := json.Marshal(gm) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + var gm2 GhostMetadata + if err := json.Unmarshal(data, &gm2); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } +} + +func TestPortalMetadata_OmitEmpty(t *testing.T) { + pm := &PortalMetadata{} + data, err := json.Marshal(pm) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + // Empty struct should marshal to "{}" since all fields are omitempty + if string(data) != "{}" { + t.Errorf("empty PortalMetadata marshaled to %s, want {}", string(data)) + } +} diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml new file mode 100644 index 00000000..79fa4514 --- /dev/null +++ b/pkg/connector/example-config.yaml @@ -0,0 +1,46 @@ +# Display name template for iMessage contacts. +# Available variables: {{.FirstName}}, {{.LastName}}, {{.Nickname}}, +# {{.Phone}}, {{.Email}}, {{.ID}} +displayname_template: "{{if .FirstName}}{{.FirstName}}{{if .LastName}} {{.LastName}}{{end}}{{else if .Nickname}}{{.Nickname}}{{else if .Phone}}{{.Phone}}{{else if .Email}}{{.Email}}{{else}}{{.ID}}{{end}}" + +# Enable CloudKit message history backfill. +# When true, the bridge will sync past messages from iCloud during setup. +# Requires entering your device PIN during login to join the iCloud Keychain. +# When false (default), only real-time messages are bridged — no PIN needed. +cloudkit_backfill: false + +# Backfill source: "cloudkit" (default) syncs from iCloud. +# "chatdb" reads the local macOS chat.db (requires Full Disk Access). +backfill_source: cloudkit + +# Enable automatic video transcoding/remuxing of non-MP4 videos (e.g. +# QuickTime .mov) to MP4 for broad Matrix client compatibility. +# Requires ffmpeg to be installed on the system. +video_transcoding: false + +# Enable automatic HEIC/HEIF to JPEG conversion for broad Matrix client +# compatibility. Requires libheif to be installed on the system. +heic_conversion: false + +# JPEG output quality for HEIC/HEIF conversion (1–100). Default is 95. +heic_jpeg_quality: 95 + +# Override the outgoing iMessage identity (what recipients see your messages "from"). +# Use the full URI format: "tel:+15551234567" or "mailto:user@example.com". +# Leave empty to use the handle chosen during login. +# Available handles are logged on startup. +preferred_handle: "" + +# External CardDAV server for contact name resolution. +# Works with Google (app passwords), Nextcloud, Radicale, Fastmail, etc. +# When configured, this is used instead of iCloud contacts. +# Set up during install or via: ./mautrix-imessage-v2 carddav-setup +carddav: + # Email address — used for auto-discovery and as default username. + email: "" + # CardDAV server URL. Leave empty to auto-discover from email. + url: "" + # Username for authentication (defaults to email if empty). + username: "" + # Encrypted app password (set by carddav-setup command). + password_encrypted: "" diff --git a/pkg/connector/external_carddav.go b/pkg/connector/external_carddav.go new file mode 100644 index 00000000..ad0adae7 --- /dev/null +++ b/pkg/connector/external_carddav.go @@ -0,0 +1,588 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// External CardDAV contact source for non-iCloud servers (Google, Nextcloud, +// Radicale, Fastmail, etc.). Uses HTTP Basic auth and standard CardDAV +// protocol. Reuses the vCard parser and XML types from cloud_contacts.go. + +package connector + +import ( + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + + "github.com/lrhodin/imessage/imessage" +) + +// externalCardDAVClient fetches contacts from an external CardDAV server +// (Google, Nextcloud, Fastmail, etc.) using HTTP Basic authentication. +type externalCardDAVClient struct { + baseURL string // CardDAV server URL (discovered or manual) + username string + password string + httpClient *http.Client + + mu sync.RWMutex + byPhone map[string]*imessage.Contact + byEmail map[string]*imessage.Contact + contacts []*imessage.Contact + lastSync time.Time +} + +// newExternalCardDAVClient creates an external CardDAV client. +// Performs auto-discovery if url is empty. Returns nil if configuration is insufficient. +func newExternalCardDAVClient(cfg CardDAVConfig, log zerolog.Logger) *externalCardDAVClient { + if !cfg.IsConfigured() { + return nil + } + + // Decrypt password + password, err := DecryptCardDAVPassword(cfg.PasswordEncrypted) + if err != nil { + log.Warn().Err(err).Msg("Failed to decrypt CardDAV password") + return nil + } + + username := cfg.GetUsername() + url := cfg.URL + + // Auto-discover if no URL provided + if url == "" { + discovered, err := DiscoverCardDAVURL(cfg.Email, username, password, log) + if err != nil { + log.Warn().Err(err).Str("email", cfg.Email).Msg("CardDAV auto-discovery failed") + return nil + } + url = discovered + log.Info().Str("url", url).Msg("CardDAV URL auto-discovered") + } + + return &externalCardDAVClient{ + baseURL: strings.TrimRight(url, "/"), + username: username, + password: password, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + byPhone: make(map[string]*imessage.Contact), + byEmail: make(map[string]*imessage.Contact), + } +} + +// doRequest performs an authenticated CardDAV request with HTTP Basic auth. +func (c *externalCardDAVClient) doRequest(method, url, body string, depth string) (*http.Response, error) { + req, err := http.NewRequest(method, url, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.SetBasicAuth(c.username, c.password) + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + if depth != "" { + req.Header.Set("Depth", depth) + } + return c.httpClient.Do(req) +} + +// SyncContacts fetches all contacts from the external CardDAV server. +func (c *externalCardDAVClient) SyncContacts(log zerolog.Logger) error { + // Step 1: Discover principal + principalURL, err := c.discoverPrincipal(log) + if err != nil { + return fmt.Errorf("discover principal: %w", err) + } + log.Debug().Str("principal", principalURL).Msg("External CardDAV: discovered principal") + + // Step 2: Get address book home set + homeSetURL, err := c.discoverAddressBookHome(log, principalURL) + if err != nil { + return fmt.Errorf("discover address book home: %w", err) + } + log.Debug().Str("home_set", homeSetURL).Msg("External CardDAV: discovered address book home") + + // Step 3: List address books + addressBooks, err := c.listAddressBooks(log, homeSetURL) + if err != nil { + return fmt.Errorf("list address books: %w", err) + } + log.Debug().Int("count", len(addressBooks)).Msg("External CardDAV: found address books") + + // Step 4: Fetch all vCards + var allContacts []*imessage.Contact + for _, abURL := range addressBooks { + contacts, fetchErr := c.fetchAllVCards(log, abURL) + if fetchErr != nil { + log.Warn().Err(fetchErr).Str("address_book", abURL).Msg("External CardDAV: failed to fetch vCards") + continue + } + allContacts = append(allContacts, contacts...) + } + + // Step 4.5: Download any photo URLs (e.g. Google uses URL-based PHOTO fields) + downloadContactPhotos(allContacts, log) + + // Step 5: Build lookup caches + c.mu.Lock() + defer c.mu.Unlock() + + c.byPhone = make(map[string]*imessage.Contact, len(allContacts)*2) + c.byEmail = make(map[string]*imessage.Contact, len(allContacts)) + c.contacts = allContacts + + for _, contact := range allContacts { + for _, phone := range contact.Phones { + for _, suffix := range phoneSuffixes(phone) { + c.byPhone[suffix] = contact + } + } + for _, email := range contact.Emails { + c.byEmail[strings.ToLower(email)] = contact + } + } + c.lastSync = time.Now() + + // Debug logging + for _, contact := range allContacts { + if contact.HasName() { + log.Debug(). + Str("first", contact.FirstName). + Str("last", contact.LastName). + Strs("phones", contact.Phones). + Strs("emails", contact.Emails). + Bool("has_photo", contact.Avatar != nil). + Msg("External CardDAV contact loaded") + } + } + + log.Info(). + Int("contacts", len(allContacts)). + Int("phone_keys", len(c.byPhone)). + Int("email_keys", len(c.byEmail)). + Msg("Contact cache synced from external CardDAV") + return nil +} + +// GetContactInfo looks up a contact by phone number or email. +func (c *externalCardDAVClient) GetContactInfo(identifier string) (*imessage.Contact, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + // Try email first + if !strings.HasPrefix(identifier, "+") && strings.Contains(identifier, "@") { + if contact, ok := c.byEmail[strings.ToLower(identifier)]; ok { + return contact, nil + } + return nil, nil + } + + // Phone number: try all suffix variations + for _, suffix := range phoneSuffixes(identifier) { + if contact, ok := c.byPhone[suffix]; ok { + return contact, nil + } + } + + return nil, nil +} + +// GetAllContacts returns a snapshot of the full contact list for bulk search. +func (c *externalCardDAVClient) GetAllContacts() []*imessage.Contact { + c.mu.RLock() + defer c.mu.RUnlock() + result := make([]*imessage.Contact, len(c.contacts)) + copy(result, c.contacts) + return result +} + +// ============================================================================ +// CardDAV Protocol (same as cloud_contacts.go but with Basic auth) +// ============================================================================ + +func (c *externalCardDAVClient) discoverPrincipal(log zerolog.Logger) (string, error) { + body := ` + + + + +` + + resp, err := c.doRequest("PROPFIND", c.baseURL+"/", body, "0") + if err != nil { + return "", fmt.Errorf("PROPFIND failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 207 { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("PROPFIND returned %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 500)])) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + href := extractPropValue(data, "current-user-principal") + if href == "" { + log.Debug().Str("body", string(data[:min(len(data), 2000)])).Msg("External CardDAV: PROPFIND response (no principal)") + return "", fmt.Errorf("no current-user-principal in response") + } + + return c.resolveURL(href), nil +} + +func (c *externalCardDAVClient) discoverAddressBookHome(log zerolog.Logger, principalURL string) (string, error) { + body := ` + + + + +` + + resp, err := c.doRequest("PROPFIND", principalURL, body, "0") + if err != nil { + return "", fmt.Errorf("PROPFIND failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 207 { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("PROPFIND returned %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 500)])) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + href := extractPropValue(data, "addressbook-home-set") + if href == "" { + return "", fmt.Errorf("no addressbook-home-set in response") + } + + return c.resolveURL(href), nil +} + +func (c *externalCardDAVClient) listAddressBooks(log zerolog.Logger, homeSetURL string) ([]string, error) { + body := ` + + + + + +` + + resp, err := c.doRequest("PROPFIND", homeSetURL, body, "1") + if err != nil { + return nil, fmt.Errorf("PROPFIND failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 207 { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("PROPFIND returned %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 500)])) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + return c.parseAddressBookList(data, homeSetURL, log), nil +} + +func (c *externalCardDAVClient) fetchAllVCards(log zerolog.Logger, addressBookURL string) ([]*imessage.Contact, error) { + body := ` + + + + + + + + +` + + resp, err := c.doRequest("REPORT", addressBookURL, body, "1") + if err != nil { + return nil, fmt.Errorf("REPORT failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 207 { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("REPORT returned %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 500)])) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + log.Debug(). + Int("response_bytes", len(data)). + Str("address_book", addressBookURL). + Msg("External CardDAV: REPORT response received") + + return parseVCardMultistatusStandalone(data, log), nil +} + +// resolveURL converts a relative href to an absolute URL. +func (c *externalCardDAVClient) resolveURL(href string) string { + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + base := c.baseURL + if idx := strings.Index(base, "://"); idx >= 0 { + schemeHost := base[:idx+3] + rest := base[idx+3:] + if slashIdx := strings.Index(rest, "/"); slashIdx >= 0 { + base = schemeHost + rest[:slashIdx] + } + } + return base + href +} + +// parseAddressBookList extracts address book URLs from a PROPFIND response. +func (c *externalCardDAVClient) parseAddressBookList(data []byte, homeSetURL string, log zerolog.Logger) []string { + var ms multistatus + if err := xml.Unmarshal(data, &ms); err != nil { + log.Warn().Err(err).Msg("External CardDAV: failed to parse address book list") + return nil + } + + var addressBooks []string + for _, resp := range ms.Responses { + href := c.resolveURL(resp.Href) + if href == homeSetURL || resp.Href == homeSetURL { + continue + } + for _, ps := range resp.Propstat { + if !strings.Contains(ps.Status, "200") { + continue + } + if ps.Prop.ResourceType.AddressBook != nil { + log.Debug(). + Str("href", href). + Str("name", ps.Prop.DisplayName). + Msg("External CardDAV: found address book") + addressBooks = append(addressBooks, href) + } + } + } + return addressBooks +} + +// parseVCardMultistatusStandalone extracts contacts from a REPORT multistatus +// response. Standalone version that doesn't depend on cloudContactsClient. +func parseVCardMultistatusStandalone(data []byte, log zerolog.Logger) []*imessage.Contact { + var ms multistatus + if err := xml.Unmarshal(data, &ms); err != nil { + log.Warn().Err(err).Msg("External CardDAV: failed to parse REPORT XML") + return nil + } + + var contacts []*imessage.Contact + for _, resp := range ms.Responses { + for _, ps := range resp.Propstat { + if !strings.Contains(ps.Status, "200") { + continue + } + vcardData := strings.TrimSpace(ps.Prop.AddressData) + if vcardData == "" { + continue + } + contact := parseVCard(vcardData) + if contact != nil && (contact.HasName() || len(contact.Phones) > 0 || len(contact.Emails) > 0) { + contacts = append(contacts, contact) + } + } + } + return contacts +} + +// ============================================================================ +// CardDAV Auto-Discovery (RFC 6764) + Known Providers +// ============================================================================ + +// KnownCardDAVProvider maps a provider name to its URL pattern. +// {email} is replaced with the user's email address. +type KnownCardDAVProvider struct { + Name string + Domains []string // email domains that match this provider + URL string // URL pattern ({email} replaced) +} + +// KnownProviders lists well-known CardDAV providers with their URL patterns. +var KnownProviders = []KnownCardDAVProvider{ + { + Name: "Google", + Domains: []string{"gmail.com", "googlemail.com"}, + URL: "https://www.googleapis.com/carddav/v1/principals/{email}/lists/default/", + }, + { + Name: "Fastmail", + Domains: []string{"fastmail.com", "fastmail.fm", "messagingengine.com"}, + URL: "https://carddav.fastmail.com/dav/addressbooks/user/{email}/Default/", + }, + { + Name: "iCloud", + Domains: []string{"icloud.com", "me.com", "mac.com"}, + URL: "https://contacts.icloud.com", + }, + { + Name: "Yahoo", + Domains: []string{"yahoo.com", "yahoo.co.uk", "ymail.com"}, + URL: "https://carddav.address.yahoo.com/dav/{email}/", + }, +} + +// ResolveProviderURL returns the CardDAV URL for a known provider, or empty string. +func ResolveProviderURL(email string) string { + parts := strings.SplitN(email, "@", 2) + if len(parts) != 2 { + return "" + } + domain := strings.ToLower(parts[1]) + + for _, p := range KnownProviders { + for _, d := range p.Domains { + if domain == d { + return strings.ReplaceAll(p.URL, "{email}", email) + } + } + } + // Google Workspace: any domain could be Google-hosted + // but we can't detect that — return empty, let auto-discovery try + return "" +} + +// DiscoverCardDAVURL attempts to find the CardDAV server URL for an email address. +// Tries in order: known providers, .well-known/carddav (GET + PROPFIND), DNS SRV. +func DiscoverCardDAVURL(email, username, password string, log zerolog.Logger) (string, error) { + parts := strings.SplitN(email, "@", 2) + if len(parts) != 2 { + return "", fmt.Errorf("invalid email address: %s", email) + } + domain := parts[1] + + // Try known providers first + if url := ResolveProviderURL(email); url != "" { + log.Info().Str("url", url).Msg("CardDAV URL resolved from known provider") + return url, nil + } + + // Try .well-known/carddav (HTTPS) + wellKnownURL := fmt.Sprintf("https://%s/.well-known/carddav", domain) + if url, err := tryWellKnown(wellKnownURL, username, password, log); err == nil { + return url, nil + } + + // Try HTTP fallback + wellKnownURL = fmt.Sprintf("http://%s/.well-known/carddav", domain) + if url, err := tryWellKnown(wellKnownURL, username, password, log); err == nil { + return url, nil + } + + // Try DNS SRV records + if url, err := trySRVDiscovery(domain, log); err == nil { + return url, nil + } + + return "", fmt.Errorf("CardDAV auto-discovery failed for %s (tried known providers, .well-known, and SRV)", domain) +} + +// tryWellKnown attempts CardDAV discovery via .well-known/carddav. +// Tries GET first (most servers redirect), then PROPFIND. +func tryWellKnown(wellKnownURL, username, password string, log zerolog.Logger) (string, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + // Try GET first — most servers return a redirect + for _, method := range []string{"GET", "PROPFIND"} { + req, err := http.NewRequest(method, wellKnownURL, nil) + if err != nil { + continue + } + req.SetBasicAuth(username, password) + if method == "PROPFIND" { + req.Header.Set("Depth", "0") + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + } + + resp, err := client.Do(req) + if err != nil { + log.Debug().Err(err).Str("method", method).Str("url", wellKnownURL).Msg("CardDAV .well-known request failed") + continue + } + resp.Body.Close() + + // 3xx redirect: follow the Location header + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + location := resp.Header.Get("Location") + if location != "" { + log.Debug().Str("location", location).Str("method", method).Msg("CardDAV .well-known redirected") + return resolveRedirect(wellKnownURL, location), nil + } + } + + // 207 Multi-Status: the URL itself is the CardDAV endpoint + if resp.StatusCode == 207 { + return wellKnownURL, nil + } + } + + return "", fmt.Errorf(".well-known not available at %s", wellKnownURL) +} + +// resolveRedirect resolves a potentially relative redirect Location. +func resolveRedirect(baseURL, location string) string { + if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") { + return location + } + if strings.HasPrefix(location, "/") { + if idx := strings.Index(baseURL, "://"); idx >= 0 { + rest := baseURL[idx+3:] + if slashIdx := strings.Index(rest, "/"); slashIdx >= 0 { + return baseURL[:idx+3+slashIdx] + location + } + } + } + return location +} + +// trySRVDiscovery looks up _carddavs._tcp and _carddav._tcp SRV records. +func trySRVDiscovery(domain string, log zerolog.Logger) (string, error) { + // Try TLS first + _, addrs, err := net.LookupSRV("carddavs", "tcp", domain) + if err == nil && len(addrs) > 0 { + target := strings.TrimRight(addrs[0].Target, ".") + port := addrs[0].Port + url := fmt.Sprintf("https://%s:%d", target, port) + log.Debug().Str("url", url).Msg("CardDAV SRV record found (_carddavs._tcp)") + return url, nil + } + + // Fall back to plain + _, addrs, err = net.LookupSRV("carddav", "tcp", domain) + if err == nil && len(addrs) > 0 { + target := strings.TrimRight(addrs[0].Target, ".") + port := addrs[0].Port + url := fmt.Sprintf("http://%s:%d", target, port) + log.Debug().Str("url", url).Msg("CardDAV SRV record found (_carddav._tcp)") + return url, nil + } + + return "", fmt.Errorf("no CardDAV SRV records for %s", domain) +} diff --git a/pkg/connector/external_carddav_test.go b/pkg/connector/external_carddav_test.go new file mode 100644 index 00000000..d293c84d --- /dev/null +++ b/pkg/connector/external_carddav_test.go @@ -0,0 +1,146 @@ +package connector + +import ( + "testing" + + "github.com/lrhodin/imessage/imessage" +) + +func TestResolveProviderURL(t *testing.T) { + tests := []struct { + email string + want string + }{ + {"user@gmail.com", "https://www.googleapis.com/carddav/v1/principals/user@gmail.com/lists/default/"}, + {"user@googlemail.com", "https://www.googleapis.com/carddav/v1/principals/user@googlemail.com/lists/default/"}, + {"user@fastmail.com", "https://carddav.fastmail.com/dav/addressbooks/user/user@fastmail.com/Default/"}, + {"user@fastmail.fm", "https://carddav.fastmail.com/dav/addressbooks/user/user@fastmail.fm/Default/"}, + {"user@icloud.com", "https://contacts.icloud.com"}, + {"user@me.com", "https://contacts.icloud.com"}, + {"user@mac.com", "https://contacts.icloud.com"}, + {"user@yahoo.com", "https://carddav.address.yahoo.com/dav/user@yahoo.com/"}, + {"user@unknown-provider.com", ""}, + {"invalid-email", ""}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.email, func(t *testing.T) { + got := ResolveProviderURL(tt.email) + if got != tt.want { + t.Errorf("ResolveProviderURL(%q) = %q, want %q", tt.email, got, tt.want) + } + }) + } +} + +func TestResolveRedirect(t *testing.T) { + tests := []struct { + name string + baseURL string + location string + want string + }{ + {"absolute URL", "https://example.com/.well-known/carddav", "https://other.com/dav/", "https://other.com/dav/"}, + {"http absolute", "https://example.com/path", "http://other.com/dav/", "http://other.com/dav/"}, + {"relative path", "https://example.com/.well-known/carddav", "/dav/principals/", "https://example.com/dav/principals/"}, + {"just path no match", "https://example.com", "relative", "relative"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveRedirect(tt.baseURL, tt.location) + if got != tt.want { + t.Errorf("resolveRedirect(%q, %q) = %q, want %q", tt.baseURL, tt.location, got, tt.want) + } + }) + } +} + +func TestExternalCardDAVClient_ResolveURL(t *testing.T) { + client := &externalCardDAVClient{ + baseURL: "https://carddav.example.com/dav", + } + + tests := []struct { + href string + want string + }{ + {"https://other.com/path", "https://other.com/path"}, + {"http://other.com/path", "http://other.com/path"}, + {"/dav/addressbooks/user/", "https://carddav.example.com/dav/addressbooks/user/"}, + {"/path", "https://carddav.example.com/path"}, + } + for _, tt := range tests { + t.Run(tt.href, func(t *testing.T) { + got := client.resolveURL(tt.href) + if got != tt.want { + t.Errorf("resolveURL(%q) = %q, want %q", tt.href, got, tt.want) + } + }) + } +} + +func TestExternalCardDAVClient_GetContactInfo_Empty(t *testing.T) { + client := &externalCardDAVClient{ + byPhone: make(map[string]*imessage.Contact), + byEmail: make(map[string]*imessage.Contact), + } + contact, err := client.GetContactInfo("+15551234567") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contact != nil { + t.Error("expected nil contact for empty directory") + } +} + +func TestExternalCardDAVClient_GetContactInfo_ByPhone(t *testing.T) { + alice := &imessage.Contact{FirstName: "Alice", Phones: []string{"+15551234567"}} + client := &externalCardDAVClient{ + byPhone: map[string]*imessage.Contact{ + "+15551234567": alice, + "15551234567": alice, + "5551234567": alice, + "1234567": alice, + }, + byEmail: make(map[string]*imessage.Contact), + } + + contact, err := client.GetContactInfo("+15551234567") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contact == nil || contact.FirstName != "Alice" { + t.Error("expected Alice contact") + } +} + +func TestExternalCardDAVClient_GetContactInfo_ByEmail(t *testing.T) { + bob := &imessage.Contact{FirstName: "Bob", Emails: []string{"bob@example.com"}} + client := &externalCardDAVClient{ + byPhone: make(map[string]*imessage.Contact), + byEmail: map[string]*imessage.Contact{ + "bob@example.com": bob, + }, + } + + contact, err := client.GetContactInfo("bob@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contact == nil || contact.FirstName != "Bob" { + t.Error("expected Bob contact") + } +} + +func TestExternalCardDAVClient_GetAllContacts(t *testing.T) { + alice := &imessage.Contact{FirstName: "Alice"} + bob := &imessage.Contact{FirstName: "Bob"} + client := &externalCardDAVClient{ + contacts: []*imessage.Contact{alice, bob}, + } + + all := client.GetAllContacts() + if len(all) != 2 { + t.Fatalf("got %d contacts, want 2", len(all)) + } +} diff --git a/pkg/connector/facetime.go b/pkg/connector/facetime.go new file mode 100644 index 00000000..43189833 --- /dev/null +++ b/pkg/connector/facetime.go @@ -0,0 +1,1305 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "bytes" + "context" + cryptoRand "crypto/rand" + "encoding/base64" + "fmt" + "html" + "net/url" + "regexp" + "strings" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +// cmdFaceTime generates a shareable FaceTime link via the rustpush FaceTime +// client. The link is associated with the bridge's iMessage handle so any +// Matrix user can share it with their iMessage contacts to start a FaceTime +// call. Subsequent calls with the same handle return the same persistent link +// until it is cleared with !facetime-clear. +// +// Usage: +// +// !facetime — link for the primary handle +// !facetime [handle] — link for a specific registered handle +var cmdFaceTime = &commands.FullHandler{ + Name: "facetime", + Aliases: []string{"ft"}, + Func: fnFaceTime, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "In a DM portal: ring the contact and post a FaceTime web link anyone can tap (iOS, macOS, Android, Windows, Linux) to join the call. In the management room: print a shareable FaceTime web link for your account.", + Args: "[handle]", + }, + RequiresLogin: true, +} + +// cmdFaceTimeSend generates a FaceTime link and sends it as an iMessage to +// the contact in the current portal. Runs in a portal room only; the link is +// delivered transparently to the iMessage contact without appearing as a +// regular Matrix message. +var cmdFaceTimeSend = &commands.FullHandler{ + Name: "facetime-send", + Func: fnFaceTimeSend, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Generate a FaceTime link and iMessage it to the contact in this portal so they can tap to join.", + }, + RequiresLogin: true, +} + +// cmdFaceTimeClear revokes all bridge FaceTime links so that the next +// !facetime call generates a fresh one. +var cmdFaceTimeClear = &commands.FullHandler{ + Name: "facetime-clear", + Func: fnFaceTimeClear, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Revoke every bridge-created FaceTime link so the next `facetime` call mints a fresh one.", + }, + RequiresLogin: true, +} + +var cmdFaceTimeState = &commands.FullHandler{ + Name: "facetime-state", + Func: fnFaceTimeState, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Dump raw FaceTime client state (sessions, links, pending requests) as JSON — debugging only.", + }, + RequiresLogin: true, +} + +var cmdFaceTimeSessionLink = &commands.FullHandler{ + Name: "facetime-session-link", + Func: fnFaceTimeSessionLink, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Rebuild the join URL for an existing FaceTime session from its GUID.", + Args: "", + }, + RequiresLogin: true, +} + +var cmdFaceTimeUseLink = &commands.FullHandler{ + Name: "facetime-use-link", + Func: fnFaceTimeUseLink, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Reassign a FaceTime link from one usage tag to another (e.g. 'personal' → 'work').", + Args: " ", + }, + RequiresLogin: true, +} + +var cmdFaceTimeDeleteLink = &commands.FullHandler{ + Name: "facetime-delete-link", + Func: fnFaceTimeDeleteLink, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Delete a specific FaceTime link by its pseud identifier.", + Args: "", + }, + RequiresLogin: true, +} + +var cmdFaceTimeLetMeIn = &commands.FullHandler{ + Name: "facetime-letmein", + Func: fnFaceTimeLetMeIn, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "List FaceTime Let-Me-In requests that are pending delegated approval from this bridge.", + }, + RequiresLogin: true, +} + +var cmdFaceTimeLetMeInApprove = &commands.FullHandler{ + Name: "facetime-letmein-approve", + Func: fnFaceTimeLetMeInApprove, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Approve a pending Let-Me-In request by delegation UUID (optionally restrict access to a named group).", + Args: " [approved-group]", + }, + RequiresLogin: true, +} + +var cmdFaceTimeLetMeInDeny = &commands.FullHandler{ + Name: "facetime-letmein-deny", + Func: fnFaceTimeLetMeInDeny, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Deny a pending Let-Me-In request by delegation UUID.", + Args: "", + }, + RequiresLogin: true, +} + +var cmdFaceTimeCreateSession = &commands.FullHandler{ + Name: "facetime-create-session", + Func: fnFaceTimeCreateSession, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Create a new FaceTime session for a given group ID and list of participant handles.", + Args: " ", + }, + RequiresLogin: true, +} + +var cmdFaceTimeRing = &commands.FullHandler{ + Name: "facetime-ring", + Func: fnFaceTimeRing, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Ring the listed targets in an existing FaceTime session; pass --letmein to include a LetMeIn push.", + Args: " [--letmein]", + }, + RequiresLogin: true, +} + +var cmdFaceTimeAddMembers = &commands.FullHandler{ + Name: "facetime-add-members", + Func: fnFaceTimeAddMembers, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Add participants to an existing FaceTime session; pass --letmein to send a LetMeIn push.", + Args: " [--letmein]", + }, + RequiresLogin: true, +} + +var cmdFaceTimeRemoveMembers = &commands.FullHandler{ + Name: "facetime-remove-members", + Func: fnFaceTimeRemoveMembers, + Help: commands.HelpMeta{ + Section: HelpSectionFaceTime, + Description: "Remove participants from an existing FaceTime session.", + Args: " ", + }, + RequiresLogin: true, +} + +// bridgeFaceTimeLinkUsage is the opaque usage tag stored in the FaceTime link +// record on Apple's servers. Keeping this stable means repeated !facetime +// calls return the same URL until it is explicitly cleared. +const bridgeFaceTimeLinkUsage = "bridge" + +// armBridgeFaceTimeCall does the Rust-side dance shared by the outbound +// !im facetime command and the missed-call callback notice: mint a session +// with no ring, queue a pending ring against the target, mint a session- +// specific join link (no letmein indirection), and pre-fill the web FT join +// page's display-name field with the caller's handle. Tapping the returned +// link joins this specific session directly — upstream's ConversationParticipantDidJoin +// wire then fires the JoinEvent that triggers the pending ring to the target. +// +// ringTTLSecs is the pending-ring lifetime: 60s for the live `!im facetime` +// flow (caller is actively waiting), much longer for missed-call callbacks +// (the user may not see the notice for a while). +func armBridgeFaceTimeCall( + ft *rustpushgo.WrappedFaceTimeClient, + callerHandle string, + targetHandle string, + ringTTLSecs uint64, +) (webLink string, sessionID string, err error) { + sessionID, err = newFaceTimeSessionID() + if err != nil { + return "", "", fmt.Errorf("generate session ID: %w", err) + } + + createErr := retryOnAPNsFlap(func() error { + return ft.CreateSessionNoRing(sessionID, callerHandle, []string{targetHandle}) + }) + if createErr != nil { + return "", sessionID, fmt.Errorf("create_session_no_ring: %w", createErr) + } + + if pendErr := ft.RegisterPendingRing(sessionID, callerHandle, []string{targetHandle}, ringTTLSecs); pendErr != nil { + return "", sessionID, fmt.Errorf("register_pending_ring: %w", pendErr) + } + + var link string + linkErr := retryOnAPNsFlap(func() error { + var innerErr error + link, innerErr = ft.GetSessionLink(sessionID) + return innerErr + }) + if linkErr != nil { + return "", sessionID, fmt.Errorf("get_session_link: %w", linkErr) + } + + link = appendFaceTimeLinkName(link, stripIdentifierPrefix(callerHandle)) + return link, sessionID, nil +} + +// retryOnAPNsFlap retries an APNs-dependent operation up to three times +// across 3s total when a transient APNs flap manifests as SendTimedOut or +// "Send timeout; try again". APNs' reconnect grace is 30s on our side, so a +// short retry almost always lands on the restored connection. +func retryOnAPNsFlap(op func() error) error { + var err error + for attempt := 0; attempt < 3; attempt++ { + err = op() + if err == nil { + return nil + } + if !isLikelyDeliveredSendTimeout(err) { + return err + } + time.Sleep(time.Duration(1+attempt) * time.Second) + } + return err +} + +var faceTimeURLRegex = regexp.MustCompile(`(?i)(?:facetime://[^\s<>")']+|(?:https?://)?(?:www\.)?facetime\.apple\.com/[^\s<>")']+)`) + +func fnFaceTime(ce *commands.Event) { + // In a DM portal with no explicit handle arg, `!facetime` acts as + // "call the contact": it creates a fresh session, posts the join link + // as a silent bot notice, and queues a ring so the contact's phone + // only rings once the caller actually taps the link. Group portals and + // explicit-handle usage fall through to the link-only behavior below. + if ce.Portal != nil && len(ce.Args) == 0 { + if handled := fnFaceTimeCallInPortal(ce); handled { + return + } + } + + client, handles, explicit, ok := faceTimeClientAndCandidates(ce) + if !ok { + return + } + + ft, err := client.client.GetFacetimeClient() + if err != nil { + ce.Reply("Failed to initialize FaceTime client: %v", err) + return + } + + var lastErr error + for _, handle := range handles { + link, linkErr := getFaceTimeLinkWithRecovery(ft, handle) + if linkErr == nil { + ce.Reply("FaceTime link for **%s**: %s\n\nShare this link to start a FaceTime call. Use `!im facetime-clear` to revoke it.", handle, link) + return + } + lastErr = linkErr + if explicit { + break + } + } + + if lastErr == nil { + lastErr = fmt.Errorf("no usable FaceTime handles found") + } + ce.Reply("Failed to get FaceTime link: %v\n\nAvailable handles: `%s`", lastErr, strings.Join(handles, "`, `")) +} + +// fnFaceTimeCallInPortal handles the portal-room variant of !facetime. It +// mirrors the OpenBubbles outgoing-call flow (openbubbles-app +// rustpush_service.dart#placeOutgoingCall): +// +// 1. Generate a fresh session UUID. +// 2. ft.CreateSession(uuid, bridge.handle, [target]) — rings the target +// via upstream's prop_up_conv(ring=true) Invitation push. +// 3. ft.GetLinkForUsage(bridge.handle, "bridge") — fetch the bridge's +// persistent personal FaceTime web link (a stable +// https://facetime.apple.com/join URL that works in any browser: +// Chrome on Android, Firefox on Linux, Edge on Windows, Safari on +// iOS/macOS). +// 4. Post the web URL to the user. facetime.apple.com is an Apple +// Universal Link, so on iOS / macOS the OS intercepts the domain +// and hands the URL off to the FaceTime app directly — no browser +// round-trip. On Android / Windows / Linux the same URL opens in +// the default browser and runs the FaceTime web client. One link +// covers every platform. +// +// When the user taps the web URL: +// - They authenticate against the personal link's pseudonym by sending a +// let-me-in request to the bridge. +// - The bridge's APNs recv loop dispatches the request through +// auto_approve_bridge_letmein (pkg/rustpushgo/src/lib.rs:2924), which +// walks the bridge's sessions looking for one owned by the link's +// handle that has is_ringing_inaccurate=true — the session we just +// created in step 2 — and routes the let-me-in there. +// - Upstream respond_letmein (facetime.rs:1057) detects the OneOnOne-mode +// bug that was breaking web-joiner connections and calls +// prop_up_conv(ring=false) as the bridge to bump the active-participant +// count, so Apple's callservicesd correctly exits OneOnOne mode before +// the web client joins. Without that step, upstream's own comment +// describes the failure as "If said device is the only one in the call +// (ringing), it sees zero (0) remote participants, thus the condition +// fails; OneOnOne mode is not exited, and the call fails." — the exact +// symptom the bridge was exhibiting with the old get_session_link flow. +// +// Why not ft.GetSessionLink? Session links bypass the let-me-in path and +// therefore the OneOnOne-mode workaround. The web joiner enters the +// session directly via the session's pseudonym, the bridge has no +// opportunity to bump the participant count, and the call stalls on a +// locked OneOnOne state — this was the "rings, I join, they pick up, it +// never connects" bug. +// +// Returns true if the command was handled; false to fall through to the +// generic link-only branch (group portals, no usable target handle, etc.). +func fnFaceTimeCallInPortal(ce *commands.Event) bool { + portalID := string(ce.Portal.ID) + // Group portals fall through — the outgoing-call flow only targets a + // single contact for now. + if strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") { + return false + } + + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return true + } + client, isClient := login.Client.(*IMClient) + if !isClient || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return true + } + if client.handle == "" { + ce.Reply("No iMessage handle configured. Please complete bridge setup first.") + return true + } + + conv := client.portalToConversation(ce.Portal) + var target string + for _, p := range conv.Participants { + if !client.isMyHandle(p) { + target = p + break + } + } + if target == "" { + return false + } + + ft, err := client.client.GetFacetimeClient() + if err != nil { + ce.Reply("Failed to initialize FaceTime client: %v", err) + return true + } + + webLink, sessionID, err := armBridgeFaceTimeCall(ft, client.handle, target, 60) + if err != nil { + switch { + case isNonRetryableResourceClosed(err): + ce.Reply("Failed to start FaceTime call: %v\n\nThe bridge's iMessage connection is in a terminal state — retrying this command won't help. The bridge needs to reconnect (try again in a minute, or log out and back in if it persists).", err) + case isLikelyDeliveredSendTimeout(err): + ce.Reply("Failed to start FaceTime call: %v\n\nSend-ack timeouts usually clear on a second try — run `!im facetime` again.", err) + default: + ce.Reply("Failed to start FaceTime call: %v", err) + } + return true + } + _ = sessionID + + bare := stripIdentifierPrefix(target) + + // One URL for everyone. facetime.apple.com is an Apple Universal Link: + // iOS / macOS intercept the domain and hand the URL off to the FaceTime + // app directly (no browser round-trip). Android / Windows / Linux just + // open the web FaceTime client in the default browser. Same link, + // platform-appropriate handling. + ce.Reply( + "📞 **FaceTime call ready for %s.**\n\n"+ + "[**🌐 Join FaceTime call**](%s)\n\n"+ + "⚠️ **Tapping this link will ring %s's phone.** The ring fires the moment you join — open the link when you're ready to be on camera. If you don't tap within 60 seconds the session is dropped and nothing rings. Works on iOS, macOS, Android, Windows, and Linux.\n\n"+ + "Raw URL: %s", + bare, webLink, bare, webLink, + ) + return true +} + +// appendFaceTimeLinkName appends &n= to a FaceTime web join +// link so Apple's join page pre-fills the display-name field. +// +// Apple's web FT page base64-decodes the &n= value (matching the &k= and &p= +// pattern, both of which are URL-safe base64 of binary data — see upstream +// facetime.rs:100/557). Sending raw text caused the page to atob() it: e.g. +// "+18454996730" base64-decodes to 9 bytes (0xFB 0x5F 0x38 0xE7 0x9E 0x3D +// 0xF7 0xAE 0xF7) which renders as "?_8?[??" — exactly the gibberish the +// previous attempt (commit f168c0d, reverted in 8d9c8f2) produced. +// +// Use URL-safe base64 with no padding to match the encoding upstream uses +// for the other fragment params. +func appendFaceTimeLinkName(link, name string) string { + if name == "" { + return link + } + encoded := base64.RawURLEncoding.EncodeToString([]byte(name)) + if strings.Contains(link, "#") { + return link + "&n=" + encoded + } + return link + "#n=" + encoded +} + +// newFaceTimeSessionID returns a random uppercase UUID v4 — Apple's FaceTime +// session GUID wire format. +func newFaceTimeSessionID() (string, error) { + var b [16]byte + if _, err := cryptoRand.Read(b[:]); err != nil { + return "", err + } + b[6] = (b[6] & 0x0f) | 0x40 // RFC 4122 version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 1 + return strings.ToUpper(fmt.Sprintf( + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15], + )), nil +} + +// fnFaceTimeSend is the handler for !facetime-send. It generates a FaceTime +// link for the bridge's primary handle and sends it as an iMessage to the +// contact in the current portal. Must be run from inside a bridged portal room. +func fnFaceTimeSend(ce *commands.Event) { + if ce.Portal == nil { + ce.Reply("This command must be run from inside a bridged portal room.") + return + } + + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return + } + client, isOK := login.Client.(*IMClient) + if !isOK || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return + } + if client.handle == "" { + ce.Reply("No iMessage handle configured. Please complete bridge setup first.") + return + } + + ft, err := client.client.GetFacetimeClient() + if err != nil { + ce.Reply("Failed to initialize FaceTime client: %v", err) + return + } + + link, linkErr := getFaceTimeLinkWithRecovery(ft, client.handle) + if linkErr != nil { + ce.Reply("Failed to get FaceTime link: %v", linkErr) + return + } + + conv := client.portalToConversation(ce.Portal) + if _, sendErr := client.client.SendMessage(conv, link, nil, client.handle, nil, nil, nil); sendErr != nil { + recipient := stripIdentifierPrefix(string(ce.Portal.ID)) + if isLikelyDeliveredSendTimeout(sendErr) { + ce.Reply("FaceTime link send timed out waiting for Apple ACK, but it may have already delivered to **%s**.\n\nCheck with them before retrying to avoid duplicates.", recipient) + return + } + ce.Reply("Failed to send FaceTime link via iMessage: %v", sendErr) + return + } + + recipient := stripIdentifierPrefix(string(ce.Portal.ID)) + ce.Reply("FaceTime link sent to **%s** via iMessage.", recipient) +} + +func getFaceTimeLinkWithRecovery(ft *rustpushgo.WrappedFaceTimeClient, handle string) (string, error) { + link, err := safeFaceTimeGetLink(ft, handle) + if err == nil { + return link, nil + } + if !isRecoverableFaceTimeStateError(err) { + return "", err + } + if clearErr := ft.ClearLinks(); clearErr != nil { + return "", fmt.Errorf("%w (failed to clear stale FaceTime links: %v)", err, clearErr) + } + return safeFaceTimeGetLink(ft, handle) +} + +func safeFaceTimeGetLink(ft *rustpushgo.WrappedFaceTimeClient, handle string) (link string, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + return ft.GetLinkForUsage(handle, bridgeFaceTimeLinkUsage) +} + +func isRecoverableFaceTimeStateError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "no entry found for key") || + strings.Contains(msg, "No link??") || + strings.Contains(msg, "Failed to validate pseudonym") +} + +func isLikelyDeliveredSendTimeout(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "send timeout; try again") || + strings.Contains(msg, "sendtimedout") +} + +// isNonRetryableResourceClosed matches upstream's +// PushError::DoNotRetry(ResourceClosed) — a terminal failure of the bridge's +// IdentityManager resource loop (util.rs ResourceManager goes to +// ResourceState::Closed after a DoNotRetry generate error). Once that +// happens, every identity.cache_keys / send_message call fails identically +// until the bridge reconnects; retrying the same command is pointless. +func isNonRetryableResourceClosed(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "Resource has been closed") || + strings.Contains(msg, "Do not retry") +} + +func faceTimeClientAndCandidates(ce *commands.Event) (client *IMClient, handles []string, explicit bool, ok bool) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return nil, nil, false, false + } + var isOK bool + client, isOK = login.Client.(*IMClient) + if !isOK || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return nil, nil, false, false + } + + if len(client.allHandles) == 0 && client.handle == "" { + ce.Reply("No iMessage handle configured. Please complete bridge setup first.") + return nil, nil, false, false + } + + if len(ce.Args) > 0 { + explicit = true + requested := strings.TrimSpace(ce.Args[0]) + resolved, found := resolveFaceTimeHandle(requested, client.allHandles) + if !found { + ce.Reply("Handle `%s` is not registered on this account. Available handles: `%s`", requested, strings.Join(client.allHandles, "`, `")) + return nil, nil, true, false + } + return client, []string{resolved}, true, true + } + + seen := make(map[string]struct{}, len(client.allHandles)+1) + appendHandle := func(handle string) { + if handle == "" { + return + } + if _, exists := seen[handle]; exists { + return + } + seen[handle] = struct{}{} + handles = append(handles, handle) + } + appendHandle(client.handle) + for _, handle := range client.allHandles { + appendHandle(handle) + } + if len(handles) == 0 { + ce.Reply("No iMessage handle configured. Please complete bridge setup first.") + return nil, nil, false, false + } + return client, handles, false, true +} + +func resolveFaceTimeHandle(requested string, available []string) (string, bool) { + normalized := normalizeIdentifierForPortalID(addIdentifierPrefix(requested)) + for _, handle := range available { + if normalizeIdentifierForPortalID(handle) == normalized { + return handle, true + } + } + return "", false +} + +func fnFaceTimeClear(ce *commands.Event) { + client, _, ok := faceTimeClientAndHandle(ce) + if !ok { + return + } + + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + err = ft.ClearLinks() + }() + + if err != nil { + ce.Reply("Failed to clear FaceTime links: %v", err) + return + } + + ce.Reply("All FaceTime links have been revoked. Use `!facetime` to generate a new one.") +} + +// faceTimeClientAndHandle resolves the IMClient and target handle from a +// command event. Replies with an error message and returns ok=false on failure. +func faceTimeClientAndHandle(ce *commands.Event) (client *IMClient, handle string, ok bool) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return nil, "", false + } + var isOK bool + client, isOK = login.Client.(*IMClient) + if !isOK || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return nil, "", false + } + + // Explicit handle arg takes precedence over the primary bridge handle. + handle = client.handle + if len(ce.Args) > 0 { + if arg := strings.TrimSpace(ce.Args[0]); arg != "" { + handle = arg + } + } + + if handle == "" { + ce.Reply("No iMessage handle configured. Please complete bridge setup first.") + return nil, "", false + } + + return client, handle, true +} + +// maybeNotifyIncomingFaceTimeInvite scans inbound messages for FaceTime join +// links and emits a bot notice to the corresponding Matrix chat. If the portal +// isn't available yet, a fallback notice is sent to the management room. +func (c *IMClient) maybeNotifyIncomingFaceTimeInvite(log zerolog.Logger, msg *rustpushgo.WrappedMessage, portalKey networkid.PortalKey, senderIsFromMe bool, createPortal bool) { + if msg == nil || senderIsFromMe || msg.IsStoredMessage { + return + } + + link := extractFaceTimeJoinLink(msg) + if link == "" { + return + } + + sender := strings.TrimSpace(ptrStringOr(msg.Sender, "")) + if sender == "" { + sender = "someone" + } + + go c.sendFaceTimeInviteNotice(log, portalKey, sender, link, createPortal) +} + +func (c *IMClient) sendFaceTimeInviteNotice(log zerolog.Logger, portalKey networkid.PortalKey, sender string, link string, createPortal bool) { + ctx := context.Background() + markdown := fmt.Sprintf("📞 **Incoming FaceTime invite** from **%s**\\n\\n[Join FaceTime](%s)", sender, link) + content := format.RenderMarkdown(markdown, true, false) + + attempts := 1 + if createPortal { + attempts = 4 + } + + for attempt := 0; attempt < attempts; attempt++ { + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if err == nil && portal != nil && portal.MXID != "" { + _, sendErr := c.Main.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{Parsed: content}, nil) + if sendErr == nil { + log.Info().Str("portal_id", string(portalKey.ID)).Str("facetime_link", link).Msg("Sent FaceTime invite notice to portal") + return + } + log.Warn().Err(sendErr).Str("portal_mxid", string(portal.MXID)).Msg("Failed to send FaceTime invite notice to portal") + break + } + if attempt < attempts-1 { + time.Sleep(1500 * time.Millisecond) + } + } + + mgmtRoom, err := c.UserLogin.User.GetManagementRoom(ctx) + if err != nil { + log.Warn().Err(err).Str("portal_id", string(portalKey.ID)).Msg("Failed to get management room for FaceTime invite notice") + return + } + + _, sendErr := c.Main.Bridge.Bot.SendMessage(ctx, mgmtRoom, event.EventMessage, &event.Content{Parsed: content}, nil) + if sendErr != nil { + log.Warn().Err(sendErr).Str("management_room", string(mgmtRoom)).Msg("Failed to send FaceTime invite notice to management room") + return + } + log.Info().Str("management_room", string(mgmtRoom)).Str("facetime_link", link).Msg("Sent FaceTime invite notice to management room") +} + +func extractFaceTimeJoinLink(msg *rustpushgo.WrappedMessage) string { + if msg == nil { + return "" + } + + texts := []string{ + ptrStringOr(msg.Text, ""), + ptrStringOr(msg.Html, ""), + } + for _, text := range texts { + if link := firstFaceTimeLinkInText(text); link != "" { + return link + } + if unescaped := html.UnescapeString(text); unescaped != text { + if link := firstFaceTimeLinkInText(unescaped); link != "" { + return link + } + } + } + + for i := range msg.Attachments { + att := &msg.Attachments[i] + if att.MimeType != "x-richlink/meta" || att.InlineData == nil { + continue + } + fields := bytes.SplitN(*att.InlineData, []byte{0x01}, 5) + for _, f := range fields { + if link := firstFaceTimeLinkInText(string(f)); link != "" { + return link + } + } + } + + return "" +} + +// extractFaceTimeGuid pulls the session guid from a FACETIME_RING / FACETIME_MISSED +// marker string. The Rust-side facetime_event_to_wrapped formats these as +// "[[FACETIME_RING]] guid= []". +func extractFaceTimeGuid(text string) string { + const prefix = "guid=" + idx := strings.Index(text, prefix) + if idx < 0 { + return "" + } + rest := text[idx+len(prefix):] + if sp := strings.IndexByte(rest, ' '); sp > 0 { + return rest[:sp] + } + return strings.TrimSpace(rest) +} + +func firstFaceTimeLinkInText(text string) string { + for _, candidate := range faceTimeURLRegex.FindAllString(text, -1) { + if normalized := normalizeFaceTimeLink(candidate); normalized != "" { + return normalized + } + } + for _, candidate := range urlRegex.FindAllString(text, -1) { + if normalized := normalizeFaceTimeLink(candidate); normalized != "" { + return normalized + } + } + return "" +} + +func normalizeFaceTimeLink(candidate string) string { + link := strings.TrimSpace(candidate) + link = strings.TrimRight(link, ".,;:!?)]}\"'") + if link == "" { + return "" + } + + lower := strings.ToLower(link) + if strings.HasPrefix(lower, "facetime://") { + return link + } + + if !strings.Contains(link, "://") && strings.HasPrefix(lower, "facetime.apple.com/") { + link = "https://" + link + } + if strings.HasPrefix(strings.ToLower(link), "www.facetime.apple.com/") { + link = "https://" + link + } + + parsed, err := url.Parse(link) + if err != nil { + return "" + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "" + } + host := strings.ToLower(parsed.Hostname()) + if host != "facetime.apple.com" && host != "www.facetime.apple.com" { + return "" + } + if parsed.Path == "" || parsed.Path == "/" { + return "" + } + + return link +} + +func faceTimeClientOnly(ce *commands.Event) (*IMClient, bool) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return nil, false + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return nil, false + } + return client, true +} + +func parseListArgs(args []string) []string { + if len(args) == 0 { + return nil + } + joined := strings.Join(args, " ") + parts := strings.FieldsFunc(joined, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' + }) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func fnFaceTimeState(ce *commands.Event) { + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + var state string + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + state, err = ft.ExportStateJson() + }() + if err != nil { + ce.Reply("Failed to export FaceTime state: %v", err) + return + } + if len(state) > 12000 { + state = state[:12000] + "\n... (truncated)" + } + ce.Reply("```json\n%s\n```", state) +} + +func fnFaceTimeSessionLink(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!facetime-session-link `") + return + } + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + sessionID := strings.TrimSpace(ce.Args[0]) + var link string + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + link, err = ft.GetSessionLink(sessionID) + }() + if err != nil { + ce.Reply("Failed to get session link: %v", err) + return + } + ce.Reply("FaceTime session link: %s", link) +} + +func fnFaceTimeUseLink(ce *commands.Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `!facetime-use-link `") + return + } + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + oldUsage := strings.TrimSpace(ce.Args[0]) + newUsage := strings.TrimSpace(ce.Args[1]) + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + err = ft.UseLinkFor(oldUsage, newUsage) + }() + if err != nil { + ce.Reply("Failed to move link usage: %v", err) + return + } + ce.Reply("Moved FaceTime link usage from `%s` to `%s`.", oldUsage, newUsage) +} + +func fnFaceTimeDeleteLink(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!facetime-delete-link `") + return + } + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + pseud := strings.TrimSpace(ce.Args[0]) + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + err = ft.DeleteLink(pseud) + }() + if err != nil { + ce.Reply("Failed to delete FaceTime link: %v", err) + return + } + ce.Reply("Deleted FaceTime link pseud `%s`.", pseud) +} + +func fnFaceTimeLetMeIn(ce *commands.Event) { + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + var reqs []rustpushgo.WrappedLetMeInRequest + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + reqs = ft.ListDelegatedLetmeinRequests() + }() + if err != nil { + ce.Reply("Failed to list Let Me In requests: %v", err) + return + } + if len(reqs) == 0 { + ce.Reply("No pending delegated Let Me In requests.") + return + } + var sb strings.Builder + sb.WriteString("**Pending Let Me In Requests**\n\n") + for i := range reqs { + r := reqs[i] + nick := ptrStringOr(r.Nickname, "") + usage := ptrStringOr(r.Usage, "") + sb.WriteString(fmt.Sprintf("%d. requestor=`%s` delegation=`%s` pseud=`%s`", i+1, r.Requestor, r.DelegationUuid, r.Pseud)) + if nick != "" { + sb.WriteString(fmt.Sprintf(" nickname=`%s`", nick)) + } + if usage != "" { + sb.WriteString(fmt.Sprintf(" usage=`%s`", usage)) + } + sb.WriteString("\n") + } + ce.Reply(sb.String()) +} + +func fnFaceTimeLetMeInApprove(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!facetime-letmein-approve [approved-group]`") + return + } + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + delegationUUID := strings.TrimSpace(ce.Args[0]) + approvedGroup := "" + if len(ce.Args) > 1 { + approvedGroup = strings.TrimSpace(ce.Args[1]) + } + var approvedPtr *string + if approvedGroup != "" { + approvedPtr = &approvedGroup + } + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + err = ft.RespondDelegatedLetmein(delegationUUID, approvedPtr) + }() + if err != nil { + ce.Reply("Failed to approve Let Me In request: %v", err) + return + } + ce.Reply("Approved Let Me In request `%s`.", delegationUUID) +} + +func fnFaceTimeLetMeInDeny(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!facetime-letmein-deny `") + return + } + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + delegationUUID := strings.TrimSpace(ce.Args[0]) + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + err = ft.RespondDelegatedLetmein(delegationUUID, nil) + }() + if err != nil { + ce.Reply("Failed to deny Let Me In request: %v", err) + return + } + ce.Reply("Denied Let Me In request `%s`.", delegationUUID) +} + +func fnFaceTimeCreateSession(ce *commands.Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `!facetime-create-session `") + return + } + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + groupID := strings.TrimSpace(ce.Args[0]) + participants := parseListArgs(ce.Args[1:]) + if len(participants) == 0 { + ce.Reply("Please provide at least one participant handle.") + return + } + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + err = ft.CreateSession(groupID, client.handle, participants) + }() + if err != nil { + ce.Reply("Failed to create FaceTime session: %v", err) + return + } + ce.Reply("Created FaceTime session for group `%s` with %d participant(s).", groupID, len(participants)) +} + +func fnFaceTimeRing(ce *commands.Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `!facetime-ring [--letmein]`") + return + } + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + sessionID := strings.TrimSpace(ce.Args[0]) + letmein := false + rawTargets := make([]string, 0, len(ce.Args)-1) + for _, arg := range ce.Args[1:] { + if strings.EqualFold(arg, "--letmein") { + letmein = true + continue + } + rawTargets = append(rawTargets, arg) + } + targets := parseListArgs(rawTargets) + if len(targets) == 0 { + ce.Reply("Please provide at least one ring target.") + return + } + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + err = ft.Ring(sessionID, targets, letmein) + }() + if err != nil { + ce.Reply("Failed to ring FaceTime session: %v", err) + return + } + ce.Reply("Rang %d target(s) in FaceTime session `%s`.", len(targets), sessionID) +} + +func fnFaceTimeAddMembers(ce *commands.Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `!facetime-add-members [--letmein]`") + return + } + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + sessionID := strings.TrimSpace(ce.Args[0]) + letmein := false + rawHandles := make([]string, 0, len(ce.Args)-1) + for _, arg := range ce.Args[1:] { + if strings.EqualFold(arg, "--letmein") { + letmein = true + continue + } + rawHandles = append(rawHandles, arg) + } + handles := parseListArgs(rawHandles) + if len(handles) == 0 { + ce.Reply("Please provide at least one handle to add.") + return + } + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + err = ft.AddMembers(sessionID, handles, letmein, nil) + }() + if err != nil { + ce.Reply("Failed to add FaceTime members: %v", err) + return + } + ce.Reply("Added %d member(s) to FaceTime session `%s`.", len(handles), sessionID) +} + +func fnFaceTimeRemoveMembers(ce *commands.Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `!facetime-remove-members `") + return + } + client, ok := faceTimeClientOnly(ce) + if !ok { + return + } + sessionID := strings.TrimSpace(ce.Args[0]) + handles := parseListArgs(ce.Args[1:]) + if len(handles) == 0 { + ce.Reply("Please provide at least one handle to remove.") + return + } + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("facetime client panicked: %v", r) + } + }() + ft, ftErr := client.client.GetFacetimeClient() + if ftErr != nil { + err = ftErr + return + } + err = ft.RemoveMembers(sessionID, handles) + }() + if err != nil { + ce.Reply("Failed to remove FaceTime members: %v", err) + return + } + ce.Reply("Removed %d member(s) from FaceTime session `%s`.", len(handles), sessionID) +} diff --git a/pkg/connector/findmy.go b/pkg/connector/findmy.go new file mode 100644 index 00000000..e6965532 --- /dev/null +++ b/pkg/connector/findmy.go @@ -0,0 +1,318 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "encoding/json" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + + "maunium.net/go/mautrix/bridgev2/commands" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +// cmdFindMy syncs Find My item positions and displays them as a list of +// accessories with their last-known coordinates. Only AirTags and other +// accessories registered to the Apple ID are shown; "Find My Friends" person +// locations are in a separate share_state structure that is not surfaced here. +// +// Usage: +// +// !findmy +var cmdFindMy = &commands.FullHandler{ + Name: "findmy", + Func: fnFindMy, + Help: commands.HelpMeta{ + Section: HelpSectionFindMy, + Description: "List your Find My accessories (AirTags, etc.) with current location, accuracy, and a Maps link.", + }, + RequiresLogin: true, +} + +var cmdFindMyAcceptShare = &commands.FullHandler{ + Name: "findmy-accept-share", + Func: fnFindMyAcceptShare, + Help: commands.HelpMeta{ + Section: HelpSectionFindMy, + Description: "Accept a pending Find My item share by its circle ID.", + Args: "", + }, + RequiresLogin: true, +} + +var cmdFindMyDeleteItem = &commands.FullHandler{ + Name: "findmy-delete-item", + Func: fnFindMyDeleteItem, + Help: commands.HelpMeta{ + Section: HelpSectionFindMy, + Description: "Delete a shared Find My item; pass --remove-beacon to also forget the associated beacon.", + Args: " [--remove-beacon]", + }, + RequiresLogin: true, +} + +var cmdFindMyRenameBeacon = &commands.FullHandler{ + Name: "findmy-rename-beacon", + Func: fnFindMyRenameBeacon, + Help: commands.HelpMeta{ + Section: HelpSectionFindMy, + Description: "Rename a Find My beacon or item by its associated-beacon + role ID; optional emoji.", + Args: " [emoji]", + }, + RequiresLogin: true, +} + +var cmdFindMyStateJSON = &commands.FullHandler{ + Name: "findmy-state-json", + Func: fnFindMyStateJSON, + Help: commands.HelpMeta{ + Section: HelpSectionFindMy, + Description: "Dump raw Find My state as JSON — debugging only.", + }, + RequiresLogin: true, +} + +// ---- JSON schema types ------------------------------------------------------- +// These mirror the serde-serialised FindMyState from rustpush/findmy.rs. +// Only fields we actually display are declared; unknown keys are ignored. + +type findMyState struct { + Accessories map[string]findMyAccessory `json:"accessories"` +} + +type findMyAccessory struct { + Naming findMyNaming `json:"naming"` + LastReport *findMyLocationReport `json:"last_report"` +} + +// BeaconNamingRecord in findmy.rs serialises its fields in snake_case (no +// #[serde(rename_all = "camelCase")] attribute on the struct itself). +type findMyNaming struct { + Name string `json:"name"` + Emoji string `json:"emoji"` +} + +// LocationReport in findmy.rs also uses snake_case field names. +type findMyLocationReport struct { + Lat float64 `json:"lat"` + Long float64 `json:"long"` + HorizontalAccuracy uint8 `json:"horizontal_accuracy"` + Confidence uint8 `json:"confidence"` +} + +// ---- Command handler --------------------------------------------------------- + +func fnFindMy(ce *commands.Event) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return + } + + var ( + stateJSON string + syncErr error + exportErr error + ) + func() { + defer func() { + if r := recover(); r != nil { + syncErr = fmt.Errorf("findmy client panicked: %v", r) + } + }() + fm, initErr := client.client.GetFindmyClient() + if initErr != nil { + syncErr = initErr + return + } + if err := fm.SyncItemPositions(); err != nil { + syncErr = err + return + } + stateJSON, exportErr = fm.ExportStateJson() + }() + + if syncErr != nil { + ce.Reply("Failed to sync Find My positions: %v", syncErr) + return + } + if exportErr != nil { + ce.Reply("Failed to export Find My state: %v", exportErr) + return + } + + var state findMyState + if err := json.Unmarshal([]byte(stateJSON), &state); err != nil { + ce.Reply("Failed to parse Find My state: %v", err) + return + } + + if len(state.Accessories) == 0 { + ce.Reply("No Find My accessories found. Make sure you have AirTags or other Find My items registered to your Apple ID.") + return + } + + // Sort accessor keys so output is stable across calls. + keys := make([]string, 0, len(state.Accessories)) + for k := range state.Accessories { + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("**Find My Accessories (%d)**\n\n", len(state.Accessories))) + for _, k := range keys { + acc := state.Accessories[k] + name := acc.Naming.Name + if name == "" { + name = k + } + emoji := acc.Naming.Emoji + if emoji == "" { + emoji = "📡" + } + sb.WriteString(fmt.Sprintf("%s **%s**\n", emoji, name)) + if acc.LastReport != nil { + sb.WriteString(fmt.Sprintf( + " 📍 %.6f, %.6f (accuracy ±%dm, confidence %d%%)\n", + acc.LastReport.Lat, acc.LastReport.Long, + int(acc.LastReport.HorizontalAccuracy), + int(acc.LastReport.Confidence), + )) + sb.WriteString(fmt.Sprintf( + " [Open in Maps](https://maps.apple.com/?ll=%.6f,%.6f&q=%s)\n", + acc.LastReport.Lat, acc.LastReport.Long, url.QueryEscape(name), + )) + } else { + sb.WriteString(" _No location reported_\n") + } + sb.WriteString("\n") + } + + ce.Reply(sb.String()) +} + +func findMyClientFromEvent(ce *commands.Event) (*rustpushgo.WrappedFindMyClient, bool) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return nil, false + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return nil, false + } + fm, err := client.client.GetFindmyClient() + if err != nil { + ce.Reply("Failed to initialize Find My client: %v", err) + return nil, false + } + return fm, true +} + +func fnFindMyAcceptShare(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!findmy-accept-share `") + return + } + fm, ok := findMyClientFromEvent(ce) + if !ok { + return + } + circleID := strings.TrimSpace(ce.Args[0]) + if err := fm.AcceptItemShare(circleID); err != nil { + ce.Reply("Failed to accept Find My share: %v", err) + return + } + ce.Reply("Accepted Find My share `%s`.", circleID) +} + +func fnFindMyDeleteItem(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!findmy-delete-item [--remove-beacon]`") + return + } + fm, ok := findMyClientFromEvent(ce) + if !ok { + return + } + itemID := strings.TrimSpace(ce.Args[0]) + removeBeacon := false + for _, arg := range ce.Args[1:] { + if strings.EqualFold(strings.TrimSpace(arg), "--remove-beacon") { + removeBeacon = true + } + } + if err := fm.DeleteSharedItem(itemID, removeBeacon); err != nil { + ce.Reply("Failed to delete Find My shared item: %v", err) + return + } + ce.Reply("Deleted shared item `%s` (remove_beacon=%v).", itemID, removeBeacon) +} + +func fnFindMyRenameBeacon(ce *commands.Event) { + if len(ce.Args) < 3 { + ce.Reply("Usage: `!findmy-rename-beacon [emoji]`") + return + } + fm, ok := findMyClientFromEvent(ce) + if !ok { + return + } + associatedBeacon := strings.TrimSpace(ce.Args[0]) + roleID, err := strconv.ParseInt(strings.TrimSpace(ce.Args[1]), 10, 64) + if err != nil { + ce.Reply("Invalid role-id: %v", err) + return + } + name := strings.TrimSpace(ce.Args[2]) + emoji := "" + if len(ce.Args) > 3 { + emoji = strings.TrimSpace(ce.Args[3]) + } + if err := fm.UpdateBeaconName(associatedBeacon, roleID, name, emoji); err != nil { + ce.Reply("Failed to rename beacon: %v", err) + return + } + ce.Reply("Updated beacon name for `%s` (role=%d).", associatedBeacon, roleID) +} + +func fnFindMyStateJSON(ce *commands.Event) { + fm, ok := findMyClientFromEvent(ce) + if !ok { + return + } + state, err := fm.ExportStateJson() + if err != nil { + ce.Reply("Failed to export Find My state JSON: %v", err) + return + } + if len(state) > 12000 { + state = state[:12000] + "\n... (truncated)" + } + ce.Reply("```json\n%s\n```", state) +} diff --git a/pkg/connector/findmydevices.go b/pkg/connector/findmydevices.go new file mode 100644 index 00000000..645b29b7 --- /dev/null +++ b/pkg/connector/findmydevices.go @@ -0,0 +1,216 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "encoding/json" + "fmt" + "math" + "net/url" + "sort" + "strings" + + "maunium.net/go/mautrix/bridgev2/commands" +) + +// cmdFindMyDevices fetches the list of iCloud-linked Apple devices via the +// Find My iPhone/Mac API and displays their location and status. +// +// Usage: +// +// !findmy-devices +var cmdFindMyDevices = &commands.FullHandler{ + Name: "findmy-devices", + Func: fnFindMyDevices, + Help: commands.HelpMeta{ + Section: HelpSectionFindMy, + Description: "List iCloud-linked Apple devices with current location, battery, charging state, and a Maps link.", + }, + RequiresLogin: true, +} + +// ---- JSON schema types ------------------------------------------------------- +// FoundDevice from rustpush findmy.rs uses #[serde(rename_all = "camelCase")]. + +type foundDevice struct { + Name *string `json:"name"` + DeviceDisplayName *string `json:"deviceDisplayName"` + ModelDisplayName *string `json:"modelDisplayName"` + DeviceModel *string `json:"deviceModel"` + DeviceClass *string `json:"deviceClass"` + BatteryStatus *string `json:"batteryStatus"` + BatteryLevel *float64 `json:"batteryLevel"` + Location *foundDeviceLoc `json:"location"` + LocationEnabled *bool `json:"locationEnabled"` + LostModeEnabled *bool `json:"lostModeEnabled"` + LowPowerMode *bool `json:"lowPowerMode"` + IsMac *bool `json:"isMac"` + ThisDevice *bool `json:"thisDevice"` +} + +// Location fields match rustpush's Location struct (camelCase). +type foundDeviceLoc struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + HorizontalAccuracy float64 `json:"horizontalAccuracy"` + IsOld *bool `json:"isOld"` +} + +// ---- Command handler --------------------------------------------------------- + +func fnFindMyDevices(ce *commands.Event) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return + } + + var ( + devicesJSON string + fetchErr error + ) + func() { + defer func() { + if r := recover(); r != nil { + fetchErr = fmt.Errorf("findmy devices client panicked: %v", r) + } + }() + devicesJSON, fetchErr = client.client.FindmyPhoneRefreshJson() + }() + + if fetchErr != nil { + ce.Reply("Failed to fetch Find My devices: %v", fetchErr) + return + } + + var devices []foundDevice + if err := json.Unmarshal([]byte(devicesJSON), &devices); err != nil { + ce.Reply("Failed to parse Find My devices response: %v", err) + return + } + + if len(devices) == 0 { + ce.Reply("No iCloud devices found. Make sure your Apple ID has devices enrolled in Find My.") + return + } + + // Sort: this device first, then alphabetically by name. + sort.Slice(devices, func(i, j int) bool { + iThis := devices[i].ThisDevice != nil && *devices[i].ThisDevice + jThis := devices[j].ThisDevice != nil && *devices[j].ThisDevice + if iThis != jThis { + return iThis + } + ni := ptrStringOr(devices[i].Name, ptrStringOr(devices[i].DeviceDisplayName, "")) + nj := ptrStringOr(devices[j].Name, ptrStringOr(devices[j].DeviceDisplayName, "")) + return strings.ToLower(ni) < strings.ToLower(nj) + }) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("**Find My Devices (%d)**\n\n", len(devices))) + for _, d := range devices { + name := ptrStringOr(d.Name, ptrStringOr(d.DeviceDisplayName, "Unknown device")) + model := ptrStringOr(d.ModelDisplayName, ptrStringOr(d.DeviceModel, "")) + class := ptrStringOr(d.DeviceClass, "") + emoji := deviceClassEmoji(class, d.IsMac) + + if d.ThisDevice != nil && *d.ThisDevice { + sb.WriteString(fmt.Sprintf("%s **%s** _(this device)_\n", emoji, name)) + } else { + sb.WriteString(fmt.Sprintf("%s **%s**\n", emoji, name)) + } + if model != "" { + sb.WriteString(fmt.Sprintf(" Model: %s\n", model)) + } + if d.LowPowerMode != nil && *d.LowPowerMode { + sb.WriteString(" 🔋 Low Power Mode\n") + } + if d.BatteryLevel != nil && d.BatteryStatus != nil { + pct := int(math.Round(*d.BatteryLevel * 100)) + bEmoji := batteryEmoji(pct, d.BatteryStatus) + sb.WriteString(fmt.Sprintf(" %s %d%% (%s)\n", bEmoji, pct, *d.BatteryStatus)) + } + if d.LostModeEnabled != nil && *d.LostModeEnabled { + sb.WriteString(" ⚠️ Lost Mode enabled\n") + } + if d.Location != nil { + loc := d.Location + ageNote := "" + if loc.IsOld != nil && *loc.IsOld { + ageNote = " _(old)_" + } + accNote := "" + if loc.HorizontalAccuracy > 0 { + accNote = fmt.Sprintf(", ±%dm", int(math.Round(loc.HorizontalAccuracy))) + } + sb.WriteString(fmt.Sprintf( + " 📍 %.6f, %.6f%s%s\n", + loc.Latitude, loc.Longitude, accNote, ageNote, + )) + sb.WriteString(fmt.Sprintf( + " [Open in Maps](https://maps.apple.com/?ll=%.6f,%.6f&q=%s)\n", + loc.Latitude, loc.Longitude, url.QueryEscape(name), + )) + } else if d.LocationEnabled != nil && !*d.LocationEnabled { + sb.WriteString(" _Location sharing disabled_\n") + } else { + sb.WriteString(" _Location unavailable_\n") + } + sb.WriteString("\n") + } + + ce.Reply(sb.String()) +} + +func deviceClassEmoji(class string, isMac *bool) string { + if isMac != nil && *isMac { + return "💻" + } + switch strings.ToLower(class) { + case "iphone": + return "📱" + case "ipad": + return "📲" + case "mac": + return "💻" + case "watch": + return "⌚" + case "airpods", "beats": + return "🎧" + case "appletv": + return "📺" + case "homepod": + return "🔊" + default: + return "📡" + } +} + +func batteryEmoji(pct int, status *string) string { + if status != nil && strings.EqualFold(*status, "Charging") { + return "🔋⚡" + } + if pct <= 20 { + return "🪫" + } + return "🔋" +} diff --git a/pkg/connector/findmyfriends.go b/pkg/connector/findmyfriends.go new file mode 100644 index 00000000..c685d715 --- /dev/null +++ b/pkg/connector/findmyfriends.go @@ -0,0 +1,231 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "encoding/json" + "fmt" + "strings" + + "maunium.net/go/mautrix/bridgev2/commands" +) + +// cmdFindMyFriends shows followers/following from the Find My Friends (FMF) +// service — the list of people whose location you share and who share theirs +// with you. Note: actual coordinates are not surfaced here (Apple only delivers +// them via live APNs updates, not the init snapshot). +// +// Usage: +// +// !findmy-friends — use app mode (default) +// !findmy-friends --daemon — use daemon (fmfd) mode for background-compatible requests +var cmdFindMyFriends = &commands.FullHandler{ + Name: "findmy-friends", + Func: fnFindMyFriends, + Help: commands.HelpMeta{ + Section: HelpSectionFindMy, + Description: "List your Find My Friends followers and followings; --daemon uses fmfd for background requests.", + Args: "[--daemon]", + }, + RequiresLogin: true, +} + +// cmdFindMyFriendsImport imports a Find My Friends location-share URL +// (e.g. from an iMessage deep-link) into the bridge's location-sharing state. +// +// Usage: +// +// !findmy-friends-import +var cmdFindMyFriendsImport = &commands.FullHandler{ + Name: "findmy-friends-import", + Func: fnFindMyFriendsImport, + Help: commands.HelpMeta{ + Section: HelpSectionFindMy, + Description: "Accept a Find My Friends location share by pasting the URL from an iMessage deep-link.", + Args: " [--daemon]", + }, + RequiresLogin: true, +} + +// ---- JSON schema types ------------------------------------------------------- +// FriendsSnapshot is the serde-serialised struct from lib.rs +// findmy_friends_refresh_json. Fields use snake_case (no rename_all). + +type friendsSnapshot struct { + SelectedFriend *string `json:"selected_friend"` + Followers []friendFollow `json:"followers"` + Following []friendFollow `json:"following"` +} + +// Follow from findmy.rs uses #[serde(rename_all = "camelCase")]. +type friendFollow struct { + ID string `json:"id"` + InvitationFromHandles []string `json:"invitationFromHandles"` + InvitationAcceptedHandles []string `json:"invitationAcceptedHandles"` + IsFromMessages bool `json:"isFromMessages"` + OptedNotToShare *bool `json:"optedNotToShare"` + Source string `json:"source"` +} + +// ---- Helpers ----------------------------------------------------------------- + +func fmfClientFromEvent(ce *commands.Event) (*IMClient, bool) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return nil, false + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return nil, false + } + return client, true +} + +// primaryHandle returns the most useful handle string for a Follow entry: +// prefer invitation_accepted_handles (they accepted the invite) then +// invitation_from_handles (who sent it), falling back to the opaque ID. +func primaryHandle(f friendFollow) string { + if len(f.InvitationAcceptedHandles) > 0 { + return f.InvitationAcceptedHandles[0] + } + if len(f.InvitationFromHandles) > 0 { + return f.InvitationFromHandles[0] + } + return f.ID +} + +// ---- Command handlers -------------------------------------------------------- + +func fnFindMyFriends(ce *commands.Event) { + client, ok := fmfClientFromEvent(ce) + if !ok { + return + } + + daemon := false + for _, arg := range ce.Args { + if strings.EqualFold(strings.TrimSpace(arg), "--daemon") { + daemon = true + } + } + + var ( + snapshotJSON string + fetchErr error + ) + func() { + defer func() { + if r := recover(); r != nil { + fetchErr = fmt.Errorf("findmy friends client panicked: %v", r) + } + }() + snapshotJSON, fetchErr = client.client.FindmyFriendsRefreshJson(daemon) + }() + + if fetchErr != nil { + ce.Reply("Failed to fetch Find My Friends: %v", fetchErr) + return + } + + var snap friendsSnapshot + if err := json.Unmarshal([]byte(snapshotJSON), &snap); err != nil { + ce.Reply("Failed to parse Find My Friends response: %v", err) + return + } + + if len(snap.Followers) == 0 && len(snap.Following) == 0 { + ce.Reply("No Find My Friends relationships found. Use `!findmy-friends-import ` to add one from an iMessage location-share link.") + return + } + + var sb strings.Builder + sb.WriteString("**Find My Friends**\n\n") + + if len(snap.Following) > 0 { + sb.WriteString(fmt.Sprintf("**Following** (%d — you can see their location):\n", len(snap.Following))) + for _, f := range snap.Following { + handle := primaryHandle(f) + opted := "" + if f.OptedNotToShare != nil && *f.OptedNotToShare { + opted = " _(opted not to share location)_" + } + fromMsg := "" + if f.IsFromMessages { + fromMsg = " _(via iMessage)_" + } + sb.WriteString(fmt.Sprintf(" 👤 %s%s%s\n", handle, opted, fromMsg)) + } + sb.WriteString("\n") + } + + if len(snap.Followers) > 0 { + sb.WriteString(fmt.Sprintf("**Followers** (%d — they can see your location):\n", len(snap.Followers))) + for _, f := range snap.Followers { + handle := primaryHandle(f) + fromMsg := "" + if f.IsFromMessages { + fromMsg = " _(via iMessage)_" + } + sb.WriteString(fmt.Sprintf(" 👤 %s%s\n", handle, fromMsg)) + } + sb.WriteString("\n") + } + + if snap.SelectedFriend != nil && *snap.SelectedFriend != "" { + sb.WriteString(fmt.Sprintf("_Currently selected friend: %s_\n", *snap.SelectedFriend)) + } + + sb.WriteString("_Note: actual coordinates are not available via this API — Apple only delivers locations via live APNs updates._") + ce.Reply(sb.String()) +} + +func fnFindMyFriendsImport(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!findmy-friends-import [--daemon]`") + return + } + client, ok := fmfClientFromEvent(ce) + if !ok { + return + } + + shareURL := strings.TrimSpace(ce.Args[0]) + daemon := false + for _, arg := range ce.Args[1:] { + if strings.EqualFold(strings.TrimSpace(arg), "--daemon") { + daemon = true + } + } + + var importErr error + func() { + defer func() { + if r := recover(); r != nil { + importErr = fmt.Errorf("findmy friends import panicked: %v", r) + } + }() + importErr = client.client.FindmyFriendsImport(daemon, shareURL) + }() + + if importErr != nil { + ce.Reply("Failed to import Find My Friends URL: %v", importErr) + return + } + ce.Reply("Location share imported. Run `!findmy-friends` to see the updated relationship list.") +} diff --git a/pkg/connector/ford_cache.go b/pkg/connector/ford_cache.go new file mode 100644 index 00000000..99f80800 --- /dev/null +++ b/pkg/connector/ford_cache.go @@ -0,0 +1,124 @@ +// Ford key cache — reimplementation of the 94f7b8e Ford cross-batch +// deduplication fix in Go, so the refactor branch can consume upstream +// OpenBubbles/rustpush unchanged. See _todo/20260304-attachment-fix.md +// for the original analysis and protocol details. +// +// Background: +// - CloudKit iMessage videos are Ford-encrypted. Every CloudAttachment +// record carries its own 32-byte Ford key in `lqa.protection_info`. +// - MMCS deduplicates identical content at the storage layer, so when +// the same video is sent multiple times, MMCS serves ONE encrypted +// blob (encrypted with the original uploader's Ford key) for many +// CloudAttachment records. The non-original records' own Ford keys +// cannot decrypt the served blob. +// - The fix: every Ford key seen during attachment sync is registered +// in a global cache keyed by `fordChecksum = 0x01 || SHA1(key)`. +// Before a download, the caller computes the record's checksum and +// consults the cache. If a different key is cached for the same +// chunk's checksum (discovered via the MMCS response), we pass the +// cached key to the download wrapper so `get_mmcs` decrypts with +// the original uploader's key. +// +// The cache is process-local and populated aggressively during sync. +// Nothing is persisted — if the bridge restarts, cache is rebuilt on +// the next sync cycle. + +package connector + +import ( + "crypto/sha1" + "encoding/hex" + "sync" + + "github.com/rs/zerolog/log" +) + +// FordKeyCache stores Ford keys keyed by their `fordChecksum` +// (`0x01 || SHA1(key)`). Safe for concurrent Register/Lookup from +// multiple goroutines. +type FordKeyCache struct { + mu sync.RWMutex + store map[string][]byte // hex(fordChecksum) → 32-byte Ford key +} + +// NewFordKeyCache returns an empty, ready-to-use cache. +func NewFordKeyCache() *FordKeyCache { + return &FordKeyCache{store: make(map[string][]byte)} +} + +// Register caches a Ford key under its fordChecksum. No-op for +// zero-length input (some records may not carry a Ford key — e.g. +// image attachments that use V1 per-chunk encryption). +func (c *FordKeyCache) Register(fordKey []byte) { + if len(fordKey) == 0 { + return + } + checksum := FordChecksumOf(fordKey) + key := hex.EncodeToString(checksum) + c.mu.Lock() + if _, exists := c.store[key]; exists { + c.mu.Unlock() + return + } + // Copy the slice so a later mutation by the caller can't + // corrupt the cached value. + stored := make([]byte, len(fordKey)) + copy(stored, fordKey) + c.store[key] = stored + size := len(c.store) + c.mu.Unlock() + // Mirror the `register_ford_key` log line from the original Rust + // fix so operators can grep logs for cache population. + log.Debug(). + Str("ford_checksum", hex.EncodeToString(checksum)). + Int("cache_size", size). + Msg("register_ford_key: cached Ford key for dedup fallback") +} + +// Lookup returns the Ford key cached under the given fordChecksum, +// or nil + false if none is cached. Returned slices must not be +// mutated by the caller. +func (c *FordKeyCache) Lookup(fordChecksum []byte) ([]byte, bool) { + if len(fordChecksum) == 0 { + return nil, false + } + keyHex := hex.EncodeToString(fordChecksum) + c.mu.RLock() + v, ok := c.store[keyHex] + size := len(c.store) + c.mu.RUnlock() + if ok { + // Matches the "Ford SIV succeeded with cached key (dedup + // resolved)" log intent from the original Rust fix. + log.Info(). + Str("ford_checksum", keyHex). + Int("cache_size", size). + Msg("Ford key cache hit — dedup resolved via cross-batch lookup") + } else { + log.Debug(). + Str("ford_checksum", keyHex). + Int("cache_size", size). + Msg("Ford key cache miss — no cached key for fordChecksum") + } + return v, ok +} + +// Len returns the number of cached keys. Diagnostic only. +func (c *FordKeyCache) Len() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.store) +} + +// FordChecksumOf computes the `fordChecksum` used as the cache key: +// `0x01 || SHA1(ford_key)`. The 0x01 prefix matches the format that +// rustpush emits during Ford upload (`mmcs.rs` ford_signature +// construction) and that the MMCS download response returns for each +// deduplicated chunk. +func FordChecksumOf(fordKey []byte) []byte { + h := sha1.Sum(fordKey) + out := make([]byte, 1+len(h)) + out[0] = 0x01 + copy(out[1:], h[:]) + return out +} diff --git a/pkg/connector/ford_cache_test.go b/pkg/connector/ford_cache_test.go new file mode 100644 index 00000000..faff92e8 --- /dev/null +++ b/pkg/connector/ford_cache_test.go @@ -0,0 +1,105 @@ +package connector + +import ( + "bytes" + "crypto/sha1" + "testing" +) + +func TestFordChecksumFormat(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, 32) + got := FordChecksumOf(key) + if len(got) != 21 { + t.Fatalf("fordChecksum length: want 21, got %d", len(got)) + } + if got[0] != 0x01 { + t.Fatalf("fordChecksum prefix: want 0x01, got 0x%02x", got[0]) + } + h := sha1.Sum(key) + if !bytes.Equal(got[1:], h[:]) { + t.Fatalf("fordChecksum body mismatch: want %x, got %x", h[:], got[1:]) + } +} + +func TestFordKeyCacheRoundTrip(t *testing.T) { + cache := NewFordKeyCache() + key := bytes.Repeat([]byte{0x42}, 32) + cache.Register(key) + + checksum := FordChecksumOf(key) + got, ok := cache.Lookup(checksum) + if !ok { + t.Fatalf("cache miss for registered key") + } + if !bytes.Equal(got, key) { + t.Fatalf("cache value mismatch: want %x, got %x", key, got) + } +} + +func TestFordKeyCacheCrossBatchSimulation(t *testing.T) { + // Upload batch A registers key K_a; batch B later tries to download + // a deduplicated blob encrypted with K_a (not with B's own key). + // The cache must return K_a when consulted with fordChecksum(K_a). + cache := NewFordKeyCache() + keyA := bytes.Repeat([]byte{0xAA}, 32) + keyB := bytes.Repeat([]byte{0xBB}, 32) + cache.Register(keyA) + cache.Register(keyB) + + checksumA := FordChecksumOf(keyA) + got, ok := cache.Lookup(checksumA) + if !ok { + t.Fatalf("cross-batch cache miss") + } + if !bytes.Equal(got, keyA) { + t.Fatalf("cross-batch lookup returned wrong key: want %x, got %x", keyA, got) + } + + // Different checksums produce different hits — no cross-pollution. + checksumB := FordChecksumOf(keyB) + gotB, _ := cache.Lookup(checksumB) + if bytes.Equal(gotB, keyA) { + t.Fatalf("lookup for keyB's checksum returned keyA — cache is broken") + } +} + +func TestFordKeyCacheIgnoresEmpty(t *testing.T) { + cache := NewFordKeyCache() + cache.Register(nil) + cache.Register([]byte{}) + if cache.Len() != 0 { + t.Fatalf("empty key should be ignored, cache len = %d", cache.Len()) + } + if _, ok := cache.Lookup(nil); ok { + t.Fatalf("empty checksum should miss") + } +} + +func TestFordKeyCacheRegisterIdempotent(t *testing.T) { + cache := NewFordKeyCache() + key := bytes.Repeat([]byte{0x11}, 32) + cache.Register(key) + cache.Register(key) + cache.Register(key) + if cache.Len() != 1 { + t.Fatalf("duplicate registrations: want 1 entry, got %d", cache.Len()) + } +} + +func TestFordKeyCacheIsolatesCallerMutation(t *testing.T) { + cache := NewFordKeyCache() + key := bytes.Repeat([]byte{0xCC}, 32) + cache.Register(key) + + // Mutate the original — cache must not observe the change. + key[0] = 0xFF + + checksum := FordChecksumOf(bytes.Repeat([]byte{0xCC}, 32)) + got, ok := cache.Lookup(checksum) + if !ok { + t.Fatalf("cache miss after caller mutation") + } + if got[0] == 0xFF { + t.Fatalf("cache aliased the caller's slice") + } +} diff --git a/pkg/connector/group_identity.go b/pkg/connector/group_identity.go new file mode 100644 index 00000000..a05d37f2 --- /dev/null +++ b/pkg/connector/group_identity.go @@ -0,0 +1,35 @@ +package connector + +import "strings" + +func isGroupPortalID(portalID string) bool { + return strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") +} + +// normalizeUUID strips dashes and lowercases a UUID for comparison. +// APNs sends dashless (520464eb701340d7bd9e7ae51684e430) while CloudKit +// uses dashed (520464eb-7013-40d7-bd9e-7ae51684e430). This normalizes both. +func normalizeUUID(s string) string { + return strings.ToLower(strings.ReplaceAll(s, "-", "")) +} + +// groupPortalDedupKey returns a stable dedupe key for group portals. +// Prefer protocol group UUID when available; otherwise fall back to a +// normalized participant signature. +func groupPortalDedupKey(portalID, groupID string, participants []string) string { + groupID = strings.TrimSpace(groupID) + if groupID != "" { + return "group:" + normalizeUUID(groupID) + } + if strings.HasPrefix(portalID, "gid:") { + return "group:" + normalizeUUID(strings.TrimPrefix(portalID, "gid:")) + } + normalized := normalizeRecoverableParticipants(participants) + if len(normalized) == 0 && strings.Contains(portalID, ",") { + normalized = normalizeRecoverableParticipants(strings.Split(portalID, ",")) + } + if len(normalized) > 0 { + return "parts:" + strings.Join(normalized, ",") + } + return "portal:" + portalID +} diff --git a/pkg/connector/heic.go b/pkg/connector/heic.go new file mode 100644 index 00000000..a3bfd8ca --- /dev/null +++ b/pkg/connector/heic.go @@ -0,0 +1,563 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +/* +#cgo pkg-config: libheif +#include +#include +#include + +// extractAllMetadataFromHEIC extracts EXIF, ICC color profile, and XMP metadata +// from HEIC data in a single parse pass. Callers must free any non-NULL output +// pointers. +static void extractAllMetadataFromHEIC( + const void* data, size_t data_len, + unsigned char** exif_out, size_t* exif_len, + unsigned char** icc_out, size_t* icc_len, + unsigned char** xmp_out, size_t* xmp_len) +{ + *exif_out = NULL; *exif_len = 0; + *icc_out = NULL; *icc_len = 0; + *xmp_out = NULL; *xmp_len = 0; + + struct heif_context* ctx = heif_context_alloc(); + if (!ctx) return; + + struct heif_error err = heif_context_read_from_memory(ctx, data, data_len, NULL); + if (err.code != heif_error_Ok) { + heif_context_free(ctx); + return; + } + + struct heif_image_handle* handle = NULL; + err = heif_context_get_primary_image_handle(ctx, &handle); + if (err.code != heif_error_Ok) { + heif_context_free(ctx); + return; + } + + // ── EXIF ──────────────────────────────────────────────────── + int n = heif_image_handle_get_number_of_metadata_blocks(handle, "Exif"); + if (n > 0) { + heif_item_id exif_id; + heif_image_handle_get_list_of_metadata_block_IDs(handle, "Exif", &exif_id, 1); + size_t exif_size = heif_image_handle_get_metadata_size(handle, exif_id); + if (exif_size > 4) { + unsigned char* raw = (unsigned char*)malloc(exif_size); + if (raw) { + err = heif_image_handle_get_metadata(handle, exif_id, raw); + if (err.code == heif_error_Ok) { + // The first 4 bytes are a big-endian offset to the TIFF header. + // Per libheif docs, skip them to get the raw TIFF/EXIF data. + uint32_t tiff_offset = ((uint32_t)raw[0] << 24) | + ((uint32_t)raw[1] << 16) | + ((uint32_t)raw[2] << 8) | + (uint32_t)raw[3]; + size_t skip = 4 + tiff_offset; + if (skip < exif_size) { + size_t tiff_len = exif_size - skip; + *exif_out = (unsigned char*)malloc(tiff_len); + if (*exif_out) { + memcpy(*exif_out, raw + skip, tiff_len); + *exif_len = tiff_len; + } + } + } + free(raw); + } + } + } + + // ── ICC color profile ─────────────────────────────────────── + size_t icc_size = heif_image_handle_get_raw_color_profile_size(handle); + if (icc_size > 0) { + *icc_out = (unsigned char*)malloc(icc_size); + if (*icc_out) { + err = heif_image_handle_get_raw_color_profile(handle, *icc_out); + if (err.code == heif_error_Ok) { + *icc_len = icc_size; + } else { + free(*icc_out); + *icc_out = NULL; + } + } + } + + // ── XMP (stored as "mime" metadata with XML content type) ─── + int mn = heif_image_handle_get_number_of_metadata_blocks(handle, "mime"); + if (mn > 0) { + heif_item_id* ids = (heif_item_id*)malloc(mn * sizeof(heif_item_id)); + if (ids) { + heif_image_handle_get_list_of_metadata_block_IDs(handle, "mime", ids, mn); + for (int i = 0; i < mn; i++) { + const char* ct = heif_image_handle_get_metadata_content_type(handle, ids[i]); + if (ct && strstr(ct, "xml")) { + size_t xmp_size = heif_image_handle_get_metadata_size(handle, ids[i]); + if (xmp_size > 0) { + *xmp_out = (unsigned char*)malloc(xmp_size); + if (*xmp_out) { + err = heif_image_handle_get_metadata(handle, ids[i], *xmp_out); + if (err.code == heif_error_Ok) { + *xmp_len = xmp_size; + } else { + free(*xmp_out); + *xmp_out = NULL; + } + } + } + break; + } + } + free(ids); + } + } + + heif_image_handle_release(handle); + heif_context_free(ctx); +} + +// getTopLevelImageCount returns the number of top-level images in the HEIC. +static int getTopLevelImageCount(const void* data, size_t data_len) { + struct heif_context* ctx = heif_context_alloc(); + if (!ctx) return -1; + + struct heif_error err = heif_context_read_from_memory(ctx, data, data_len, NULL); + if (err.code != heif_error_Ok) { + heif_context_free(ctx); + return -1; + } + + int n = heif_context_get_number_of_top_level_images(ctx); + heif_context_free(ctx); + return n; +} + +// decodeHEICToRGBA decodes a HEIC image to interleaved RGBA pixel data. +// NULL decoding options are used so that all ISOBMFF transforms (rotation, +// mirroring) are applied automatically — the output pixels match the +// intended visual orientation. The caller must free *pixels_out. +static int decodeHEICToRGBA( + const void* data, size_t data_len, + unsigned char** pixels_out, int* width_out, int* height_out, int* stride_out) +{ + *pixels_out = NULL; + *width_out = 0; + *height_out = 0; + *stride_out = 0; + + struct heif_context* ctx = heif_context_alloc(); + if (!ctx) return -1; + + struct heif_error err = heif_context_read_from_memory(ctx, data, data_len, NULL); + if (err.code != heif_error_Ok) { + heif_context_free(ctx); + return -1; + } + + struct heif_image_handle* handle = NULL; + err = heif_context_get_primary_image_handle(ctx, &handle); + if (err.code != heif_error_Ok) { + heif_context_free(ctx); + return -1; + } + + struct heif_image* img = NULL; + // NULL options → apply all ISOBMFF transforms (irot/imir) during decode. + err = heif_decode_image(handle, &img, + heif_colorspace_RGB, heif_chroma_interleaved_RGBA, NULL); + if (err.code != heif_error_Ok) { + heif_image_handle_release(handle); + heif_context_free(ctx); + return -1; + } + + int w = heif_image_get_width(img, heif_channel_interleaved); + int h = heif_image_get_height(img, heif_channel_interleaved); + int stride; + const uint8_t* plane = heif_image_get_plane_readonly( + img, heif_channel_interleaved, &stride); + if (!plane || w <= 0 || h <= 0) { + heif_image_release(img); + heif_image_handle_release(handle); + heif_context_free(ctx); + return -1; + } + + size_t total = (size_t)h * (size_t)stride; + *pixels_out = (unsigned char*)malloc(total); + if (!*pixels_out) { + heif_image_release(img); + heif_image_handle_release(handle); + heif_context_free(ctx); + return -1; + } + memcpy(*pixels_out, plane, total); + *width_out = w; + *height_out = h; + *stride_out = stride; + + heif_image_release(img); + heif_image_handle_release(handle); + heif_context_free(ctx); + return 0; +} +*/ +import "C" + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + "image/jpeg" + "path/filepath" + "strings" + "unsafe" + + "github.com/rs/zerolog" +) + +// maxHEICInputSize is the maximum HEIC file size we'll attempt to convert (100 MB). +const maxHEICInputSize = 100 * 1024 * 1024 + +// isHEIC returns true if the MIME type indicates an HEIC/HEIF image. +func isHEIC(mimeType string) bool { + return mimeType == "image/heic" || mimeType == "image/heif" +} + +// heicMetadata holds all metadata extracted from a HEIC file. +type heicMetadata struct { + exif []byte // Raw TIFF/EXIF data (without the 4-byte offset prefix) + icc []byte // Raw ICC color profile + xmp []byte // Raw XMP/RDF-XML data +} + +// extractMetadata extracts EXIF, ICC, and XMP metadata from HEIC data using the +// libheif C API in a single parse pass. Returns empty fields for any metadata +// type that is not present. +func extractMetadata(data []byte) heicMetadata { + var exifPtr, iccPtr, xmpPtr *C.uchar + var exifLen, iccLen, xmpLen C.size_t + + C.extractAllMetadataFromHEIC( + unsafe.Pointer(&data[0]), C.size_t(len(data)), + &exifPtr, &exifLen, + &iccPtr, &iccLen, + &xmpPtr, &xmpLen, + ) + + var meta heicMetadata + if exifPtr != nil { + meta.exif = C.GoBytes(unsafe.Pointer(exifPtr), C.int(exifLen)) + C.free(unsafe.Pointer(exifPtr)) + } + if iccPtr != nil { + meta.icc = C.GoBytes(unsafe.Pointer(iccPtr), C.int(iccLen)) + C.free(unsafe.Pointer(iccPtr)) + } + if xmpPtr != nil { + meta.xmp = C.GoBytes(unsafe.Pointer(xmpPtr), C.int(xmpLen)) + C.free(unsafe.Pointer(xmpPtr)) + } + return meta +} + +// decodeHEICImage decodes HEIC data to a Go image.NRGBA via the libheif C API. +// All ISOBMFF transforms (rotation, mirroring) are applied during decoding, +// so the returned image is in the correct visual orientation. +func decodeHEICImage(data []byte) (image.Image, error) { + var pixels *C.uchar + var width, height, stride C.int + + rc := C.decodeHEICToRGBA( + unsafe.Pointer(&data[0]), C.size_t(len(data)), + &pixels, &width, &height, &stride, + ) + if rc != 0 { + return nil, fmt.Errorf("libheif decode failed") + } + defer C.free(unsafe.Pointer(pixels)) + + w := int(width) + h := int(height) + s := int(stride) + + // libheif RGBA is non-premultiplied, matching Go's image.NRGBA format. + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + + // Create a Go slice view over the C pixel buffer (no copy yet). + pixelData := unsafe.Slice((*byte)(unsafe.Pointer(pixels)), h*s) + + if s == img.Stride { + copy(img.Pix, pixelData) + } else { + // Row-by-row copy when libheif stride differs from Go stride. + rowBytes := w * 4 + for y := 0; y < h; y++ { + copy(img.Pix[y*img.Stride:y*img.Stride+rowBytes], + pixelData[y*s:y*s+rowBytes]) + } + } + + return img, nil +} + +// resetEXIFOrientation sets the EXIF Orientation tag (0x0112) to 1 (Normal). +// This is necessary because libheif applies ISOBMFF transforms during +// decoding, so the pixels are already correctly oriented. Without resetting +// this tag, JPEG viewers that honor EXIF Orientation would rotate the image +// a second time. +func resetEXIFOrientation(exif []byte) { + if len(exif) < 8 { + return + } + + var bo binary.ByteOrder + switch string(exif[0:2]) { + case "II": + bo = binary.LittleEndian + case "MM": + bo = binary.BigEndian + default: + return + } + + // TIFF magic number + if bo.Uint16(exif[2:4]) != 42 { + return + } + + ifdOffset := int(bo.Uint32(exif[4:8])) + if ifdOffset+2 > len(exif) { + return + } + + entryCount := int(bo.Uint16(exif[ifdOffset : ifdOffset+2])) + for i := 0; i < entryCount; i++ { + entryStart := ifdOffset + 2 + i*12 + if entryStart+12 > len(exif) { + return + } + tag := bo.Uint16(exif[entryStart : entryStart+2]) + if tag == 0x0112 { // Orientation + bo.PutUint16(exif[entryStart+8:entryStart+10], 1) // 1 = Normal + return + } + } +} + +// maxICCChunkData is the maximum ICC profile data per APP2 chunk. +// APP2 overhead: 2 (length) + 12 ("ICC_PROFILE\0") + 1 (chunk#) + 1 (total) = 16. +const maxICCChunkData = 0xFFFF - 2 - 12 - 1 - 1 + +// injectMetadataIntoJPEG splices EXIF, ICC, and XMP metadata into a JPEG +// immediately after the SOI marker. +// +// JPEG marker layout after injection: +// +// FF D8 (SOI) +// FF E1 [len] Exif\0\0 [TIFF] (APP1 — EXIF, if present) +// FF E1 [len] XMP-ns\0 [XMP] (APP1 — XMP, if present) +// FF E2 [len] ICC_PROFILE\0 (APP2 — ICC chunk 1, if present) +// FF E2 [len] ICC_PROFILE\0 (APP2 — ICC chunk N, if present) +// [rest of original JPEG] +func injectMetadataIntoJPEG(jpegData []byte, meta heicMetadata, log *zerolog.Logger) []byte { + if len(jpegData) < 2 || jpegData[0] != 0xFF || jpegData[1] != 0xD8 { + return jpegData // not a valid JPEG + } + + // Nothing to inject + if meta.exif == nil && meta.icc == nil && meta.xmp == nil { + return jpegData + } + + // Estimate capacity + extra := 0 + if meta.exif != nil { + extra += len(meta.exif) + 10 // APP1 marker + length + "Exif\0\0" + } + if meta.xmp != nil { + extra += len(meta.xmp) + 33 // APP1 marker + length + XMP namespace + } + if meta.icc != nil { + numChunks := (len(meta.icc) + maxICCChunkData - 1) / maxICCChunkData + extra += numChunks * (2 + 2 + 14) + len(meta.icc) // markers + headers + data + } + + var buf bytes.Buffer + buf.Grow(len(jpegData) + extra) + + // SOI + buf.Write(jpegData[:2]) + + // ── APP1 — EXIF ───────────────────────────────────────────── + if meta.exif != nil { + exifHeader := []byte("Exif\x00\x00") + app1PayloadLen := len(exifHeader) + len(meta.exif) + app1Len := 2 + app1PayloadLen // length field includes its own 2 bytes + + if app1Len > 0xFFFF { + log.Warn().Int("exif_bytes", len(meta.exif)). + Msg("EXIF data too large for JPEG APP1 segment, dropping EXIF") + } else { + buf.Write([]byte{0xFF, 0xE1}) // APP1 marker + lenBytes := make([]byte, 2) //nolint:mnd + binary.BigEndian.PutUint16(lenBytes, uint16(app1Len)) //nolint:gosec + buf.Write(lenBytes) // length + buf.Write(exifHeader) // "Exif\0\0" + buf.Write(meta.exif) // raw TIFF data + } + } + + // ── APP1 — XMP ────────────────────────────────────────────── + if meta.xmp != nil { + xmpHeader := []byte("http://ns.adobe.com/xap/1.0/\x00") + app1PayloadLen := len(xmpHeader) + len(meta.xmp) + app1Len := 2 + app1PayloadLen + + if app1Len > 0xFFFF { + log.Warn().Int("xmp_bytes", len(meta.xmp)). + Msg("XMP data too large for JPEG APP1 segment, dropping XMP") + } else { + buf.Write([]byte{0xFF, 0xE1}) // APP1 marker + lenBytes := make([]byte, 2) //nolint:mnd + binary.BigEndian.PutUint16(lenBytes, uint16(app1Len)) //nolint:gosec + buf.Write(lenBytes) // length + buf.Write(xmpHeader) // XMP namespace + buf.Write(meta.xmp) // XMP payload + } + } + + // ── APP2 — ICC Profile (chunked if >65519 bytes) ──────────── + if meta.icc != nil { + totalChunks := (len(meta.icc) + maxICCChunkData - 1) / maxICCChunkData + if totalChunks > 255 { + log.Warn().Int("icc_bytes", len(meta.icc)).Int("chunks_needed", totalChunks). + Msg("ICC profile too large for JPEG (>255 APP2 chunks), dropping ICC") + } else { + iccSignature := []byte("ICC_PROFILE\x00") + for chunk := 0; chunk < totalChunks; chunk++ { + start := chunk * maxICCChunkData + end := start + maxICCChunkData + if end > len(meta.icc) { + end = len(meta.icc) + } + chunkData := meta.icc[start:end] + + // Length = 2 (self) + 12 (signature) + 1 (chunk#) + 1 (total) + data + segLen := 2 + len(iccSignature) + 2 + len(chunkData) + + buf.Write([]byte{0xFF, 0xE2}) // APP2 marker + lenBytes := make([]byte, 2) //nolint:mnd + binary.BigEndian.PutUint16(lenBytes, uint16(segLen)) //nolint:gosec + buf.Write(lenBytes) // length + buf.Write(iccSignature) // "ICC_PROFILE\0" + buf.WriteByte(byte(chunk + 1)) // 1-based chunk number + buf.WriteByte(byte(totalChunks)) // total chunks + buf.Write(chunkData) // profile data + } + } + } + + // Rest of original JPEG (everything after SOI) + buf.Write(jpegData[2:]) + return buf.Bytes() +} + +// convertHEICToJPEG decodes HEIC/HEIF bytes and re-encodes as JPEG, +// preserving EXIF, ICC color profile, and XMP metadata if present. +// Returns the JPEG bytes, updated MIME type, updated filename, the decoded +// image (for callers that need dimensions/thumbnails), and any error. +func convertHEICToJPEG(data []byte, mimeType, fileName string, quality int, log *zerolog.Logger) ([]byte, string, string, image.Image, error) { + if len(data) > maxHEICInputSize { + return nil, mimeType, fileName, nil, fmt.Errorf("HEIC input too large: %d bytes (max %d)", len(data), maxHEICInputSize) + } + + // Clamp quality to valid range + if quality < 1 || quality > 100 { + quality = 95 + } + + // Extract all metadata via C API + meta := extractMetadata(data) + if meta.exif != nil { + log.Debug().Int("exif_bytes", len(meta.exif)).Msg("Extracted EXIF from HEIC") + // Reset EXIF Orientation to 1 (Normal) since libheif applies ISOBMFF + // transforms during decoding — the pixels are already correctly + // oriented. Without this, viewers would double-rotate the image. + resetEXIFOrientation(meta.exif) + } + if meta.icc != nil { + log.Debug().Int("icc_bytes", len(meta.icc)).Msg("Extracted ICC profile from HEIC") + } + if meta.xmp != nil { + log.Debug().Int("xmp_bytes", len(meta.xmp)).Msg("Extracted XMP from HEIC") + } + + // Decode HEIC to RGBA pixels via C API. NULL decoding options apply all + // ISOBMFF transforms (rotation, mirroring) automatically. + goImg, err := decodeHEICImage(data) + if err != nil { + return nil, mimeType, fileName, nil, fmt.Errorf("decodeHEICImage: %w", err) + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, goImg, &jpeg.Options{Quality: quality}); err != nil { + return nil, mimeType, fileName, nil, fmt.Errorf("jpeg.Encode: %w", err) + } + + jpegBytes := injectMetadataIntoJPEG(buf.Bytes(), meta, log) + + newName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".jpg" + return jpegBytes, "image/jpeg", newName, goImg, nil +} + +// maybeConvertHEIC handles HEIC/HEIF images. When conversion is enabled, it +// converts to JPEG and returns the updated data, MIME type, filename, and the +// decoded image. When conversion is disabled but the MIME type is HEIC/HEIF, +// it decodes the image for dimension/thumbnail extraction while returning the +// original data unchanged. Returns a nil image only for non-HEIC types or on +// decode failure. +func maybeConvertHEIC(log *zerolog.Logger, data []byte, mimeType, fileName string, quality int, enabled bool) ([]byte, string, string, image.Image) { + if !isHEIC(mimeType) || len(data) == 0 { + return data, mimeType, fileName, nil + } + + if !enabled { + // Decode for dimensions/thumbnail only, keep original HEIC data + if len(data) > maxHEICInputSize { + log.Warn().Int("heic_bytes", len(data)).Int("max_bytes", maxHEICInputSize). + Msg("HEIC input too large to decode for dimensions, skipping") + return data, mimeType, fileName, nil + } + img, err := decodeHEICImage(data) + if err != nil { + log.Warn().Err(err).Msg("Failed to decode HEIC for dimensions") + } + return data, mimeType, fileName, img + } + + // Check for animated/multi-frame HEIC + if n := C.getTopLevelImageCount(unsafe.Pointer(&data[0]), C.size_t(len(data))); n > 1 { + log.Warn().Int("frame_count", int(n)). + Msg("Animated/multi-frame HEIC detected, only primary image will be converted") + } + + jpegData, newMime, newName, decodedImg, err := convertHEICToJPEG(data, mimeType, fileName, quality, log) + if err != nil { + log.Warn().Err(err).Msg("HEIC to JPEG conversion failed, uploading original") + return data, mimeType, fileName, nil + } + log.Info(). + Int("original_bytes", len(data)). + Int("jpeg_bytes", len(jpegData)). + Msg("Converted HEIC to JPEG") + return jpegData, newMime, newName, decodedImg +} diff --git a/pkg/connector/identity_store.go b/pkg/connector/identity_store.go new file mode 100644 index 00000000..58fb4435 --- /dev/null +++ b/pkg/connector/identity_store.go @@ -0,0 +1,246 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + + "github.com/rs/zerolog" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +// PersistedSessionState holds all the session data that needs to survive +// database resets (DB deletion, config wipes, etc.). Persisted to a JSON file +// at ~/.local/share/mautrix-imessage/session.json. +// +// On re-authentication, the bridge reads this file to reuse: +// - IDSIdentity: cryptographic device keys (avoids new key generation) +// - APSState: APS push connection state (preserves push token) +// - IDSUsers: IDS registration data (avoids calling register() endpoint) +// +// Together these prevent Apple from treating re-login as a "new device", +// which would trigger "X added a new Mac" notifications to contacts. +type PersistedSessionState struct { + IDSIdentity string `json:"ids_identity,omitempty"` + APSState string `json:"aps_state,omitempty"` + IDSUsers string `json:"ids_users,omitempty"` + PreferredHandle string `json:"preferred_handle,omitempty"` + + // Login platform and device identity (needed for auto-restore on Linux) + Platform string `json:"platform,omitempty"` + HardwareKey string `json:"hardware_key,omitempty"` + DeviceID string `json:"device_id,omitempty"` + + // iCloud account persist data (for TokenProvider restoration across restarts) + AccountUsername string `json:"account_username,omitempty"` + AccountHashedPasswordHex string `json:"account_hashed_password_hex,omitempty"` + AccountPET string `json:"account_pet,omitempty"` + AccountADSID string `json:"account_adsid,omitempty"` + AccountDSID string `json:"account_dsid,omitempty"` + AccountSPDBase64 string `json:"account_spd_base64,omitempty"` + + // Cached MobileMe delegate for seeding on restore + MmeDelegateJSON string `json:"mme_delegate_json,omitempty"` +} + +// sessionFilePath returns the path to the persisted session state file: +// ~/.local/share/mautrix-imessage/session.json +func sessionFilePath() (string, error) { + dataDir := os.Getenv("XDG_DATA_HOME") + if dataDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dataDir = filepath.Join(home, ".local", "share") + } + return filepath.Join(dataDir, "mautrix-imessage", "session.json"), nil +} + +// legacyIdentityFilePath returns the old v1 identity file path for migration: +// ~/.local/share/mautrix-imessage/identity.plist +func legacyIdentityFilePath() (string, error) { + dataDir := os.Getenv("XDG_DATA_HOME") + if dataDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dataDir = filepath.Join(home, ".local", "share") + } + return filepath.Join(dataDir, "mautrix-imessage", "identity.plist"), nil +} + +// trustedPeersFilePath returns the keychain trust state path: +// ~/.local/share/mautrix-imessage/trustedpeers.plist +func trustedPeersFilePath() (string, error) { + dataDir := os.Getenv("XDG_DATA_HOME") + if dataDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dataDir = filepath.Join(home, ".local", "share") + } + return filepath.Join(dataDir, "mautrix-imessage", "trustedpeers.plist"), nil +} + +// hasKeychainCliqueState returns true if trustedpeers.plist appears to contain +// a keychain user identity (i.e. trust circle has been joined). +func hasKeychainCliqueState(log zerolog.Logger) bool { + path, err := trustedPeersFilePath() + if err != nil { + log.Debug().Err(err).Msg("Failed to determine trusted peers file path") + return false + } + data, err := os.ReadFile(path) + if err != nil { + return false + } + // trustedpeers.plist is written by Rust as XML plist, where a joined clique + // includes either userIdentity or user_identity key. + if bytes.Contains(data, []byte("userIdentity")) || bytes.Contains(data, []byte("user_identity")) { + return true + } + log.Info().Str("path", path).Msg("Trusted peers state exists but has no user identity (not in clique)") + return false +} + +// saveSessionState writes the full session state to the JSON file. +// Creates parent directories if needed. Errors are logged but not fatal. +func saveSessionState(log zerolog.Logger, state PersistedSessionState) { + path, err := sessionFilePath() + if err != nil { + log.Warn().Err(err).Msg("Failed to determine session file path, skipping save") + return + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + log.Warn().Err(err).Str("path", path).Msg("Failed to create session file directory") + return + } + data, err := json.Marshal(state) + if err != nil { + log.Warn().Err(err).Msg("Failed to marshal session state") + return + } + if err := os.WriteFile(path, data, 0600); err != nil { + log.Warn().Err(err).Str("path", path).Msg("Failed to write session file") + return + } + log.Info().Str("path", path). + Bool("has_identity", state.IDSIdentity != ""). + Bool("has_aps_state", state.APSState != ""). + Bool("has_ids_users", state.IDSUsers != ""). + Msg("Saved session state to file") +} + +// loadSessionState reads the persisted session state from the JSON file. +// Falls back to the legacy identity.plist file (v1 format) if the new file +// doesn't exist. Returns a zero-value struct if nothing is found. +func loadSessionState(log zerolog.Logger) PersistedSessionState { + // Try new JSON format first + path, err := sessionFilePath() + if err != nil { + log.Debug().Err(err).Msg("Failed to determine session file path") + return PersistedSessionState{} + } + data, err := os.ReadFile(path) + if err == nil && len(data) > 0 { + var state PersistedSessionState + if err := json.Unmarshal(data, &state); err != nil { + log.Warn().Err(err).Str("path", path).Msg("Failed to parse session file") + return PersistedSessionState{} + } + log.Info().Str("path", path). + Bool("has_identity", state.IDSIdentity != ""). + Bool("has_aps_state", state.APSState != ""). + Bool("has_ids_users", state.IDSUsers != ""). + Msg("Loaded session state from file") + return state + } + + // Fall back to legacy identity.plist (v1 format — identity only) + legacyPath, err := legacyIdentityFilePath() + if err != nil { + return PersistedSessionState{} + } + legacyData, err := os.ReadFile(legacyPath) + if err != nil || len(legacyData) == 0 { + return PersistedSessionState{} + } + log.Info().Str("path", legacyPath).Msg("Migrating legacy identity file to new session format") + state := PersistedSessionState{ + IDSIdentity: string(legacyData), + } + // Migrate: save in new format and remove old file + saveSessionState(log, state) + _ = os.Remove(legacyPath) + return state +} + +// ListHandles returns the available iMessage handles (phone numbers and +// email addresses) from the backup session state. Returns nil if no valid +// session state is found. Intended for CLI use (list-handles subcommand). +// Reads session.json directly to avoid any logger output. +func ListHandles() []string { + // InitLogger must be called before any Rust FFI (NewWrappedIdsUsers). + // Without it the Rust side has no logger and may panic on Linux. + rustpushgo.InitLogger() + + path, err := sessionFilePath() + if err != nil { + return nil + } + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return nil + } + var state PersistedSessionState + if err := json.Unmarshal(data, &state); err != nil || state.IDSUsers == "" { + return nil + } + users := rustpushgo.NewWrappedIdsUsers(&state.IDSUsers) + return users.GetHandles() +} + +// CheckSessionRestore validates that backup session state (session.json + +// keystore) exists and the IDS user keys are present in the keystore. +// Returns true if login can be auto-restored without re-authentication. +// This is intended to be called from the CLI (check-restore subcommand) +// before starting the bridge. +func CheckSessionRestore() bool { + log := zerolog.New(zerolog.NewConsoleWriter()).With().Timestamp().Logger() + + // Initialize keystore (loads from XDG path, migrates if needed) + rustpushgo.InitLogger() + + state := loadSessionState(log) + if state.IDSUsers == "" || state.IDSIdentity == "" || state.APSState == "" { + return false + } + + session := &cachedSessionState{ + IDSIdentity: state.IDSIdentity, + APSState: state.APSState, + IDSUsers: state.IDSUsers, + source: "backup file (check-restore)", + } + if !session.validate(log) { + return false + } + if !hasKeychainCliqueState(log) { + log.Info().Msg("Session restore check failed: keychain trust circle not initialized") + return false + } + return true +} diff --git a/pkg/connector/ids.go b/pkg/connector/ids.go new file mode 100644 index 00000000..9aa4abc2 --- /dev/null +++ b/pkg/connector/ids.go @@ -0,0 +1,24 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "maunium.net/go/mautrix/bridgev2/networkid" +) + +// makeUserID creates a networkid.UserID from an iMessage identifier +// (e.g., "tel:+1234567890" or "mailto:user@example.com"). +func makeUserID(identifier string) networkid.UserID { + return networkid.UserID(identifier) +} + +// makeMessageID creates a networkid.MessageID from an iMessage message UUID. +func makeMessageID(guid string) networkid.MessageID { + return networkid.MessageID(guid) +} diff --git a/pkg/connector/ids_test.go b/pkg/connector/ids_test.go new file mode 100644 index 00000000..7e57aefc --- /dev/null +++ b/pkg/connector/ids_test.go @@ -0,0 +1,19 @@ +package connector + +import ( + "testing" +) + +func TestMakeUserID(t *testing.T) { + got := makeUserID("tel:+15551234567") + if string(got) != "tel:+15551234567" { + t.Errorf("makeUserID = %q, want %q", string(got), "tel:+15551234567") + } +} + +func TestMakeMessageID(t *testing.T) { + got := makeMessageID("abc-def-123") + if string(got) != "abc-def-123" { + t.Errorf("makeMessageID = %q, want %q", string(got), "abc-def-123") + } +} diff --git a/pkg/connector/login.go b/pkg/connector/login.go new file mode 100644 index 00000000..55554d0d --- /dev/null +++ b/pkg/connector/login.go @@ -0,0 +1,888 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/status" + "maunium.net/go/mautrix/id" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +const ( + LoginFlowIDAppleID = "apple-id" + LoginFlowIDExternalKey = "external-key" + LoginStepAppleIDPassword = "fi.mau.imessage.login.appleid" + LoginStepExternalKey = "fi.mau.imessage.login.externalkey" + LoginStepTwoFactor = "fi.mau.imessage.login.2fa" + LoginStepSelectDevice = "fi.mau.imessage.login.select_device" + LoginStepDevicePasscode = "fi.mau.imessage.login.device_passcode" + LoginStepSelectHandle = "fi.mau.imessage.login.select_handle" + LoginStepComplete = "fi.mau.imessage.login.complete" +) + +// AppleIDLogin implements the multi-step login flow: +// Apple ID + password → 2FA code → IDS registration → device selection → passcode → handle selection → connected. +type AppleIDLogin struct { + User *bridgev2.User + Main *IMConnector + username string + cfg *rustpushgo.WrappedOsConfig + conn *rustpushgo.WrappedApsConnection + session *rustpushgo.LoginSession + result *rustpushgo.IdsUsersWithIdentityRecord // set after IDS registration + handle string // chosen handle + devices []rustpushgo.EscrowDeviceInfo // escrow devices (fetched after IDS registration) + selectedDevice int // index into devices (-1 = not yet selected) +} + +var _ bridgev2.LoginProcessUserInput = (*AppleIDLogin)(nil) + +func (l *AppleIDLogin) Cancel() {} + +func (l *AppleIDLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + rustpushgo.InitLogger() + + cfg, err := rustpushgo.CreateLocalMacosConfig() + if err != nil { + return nil, fmt.Errorf("failed to initialize local NAC config: %w", err) + } + l.cfg = cfg + + // Reuse existing session state if available and keystore matches + log := l.Main.Bridge.Log.With().Str("component", "imessage").Logger() + session := loadCachedSession(l.User, log) + if !session.validate(log) { + session = nil + } + apsState := getExistingAPSState(session, log) + l.conn = rustpushgo.Connect(cfg, apsState) + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepAppleIDPassword, + Instructions: "Enter your Apple ID credentials. " + + "Registration uses local NAC (no relay needed).", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypeEmail, + ID: "username", + Name: "Apple ID", + }, { + Type: bridgev2.LoginInputFieldTypePassword, + ID: "password", + Name: "Password", + }}, + }, + }, nil +} + +func (l *AppleIDLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + // Device passcode step (after device selection, before handle selection) + if passcode, ok := input["passcode"]; ok && l.result != nil { + return l.handlePasscodeAndContinue(ctx, passcode) + } + + // Device selection step (after IDS registration, before passcode) + if device, ok := input["device"]; ok && l.result != nil { + return l.handleDeviceSelection(device) + } + + // Handle selection step (after device passcode) + if l.result != nil { + l.handle = input["handle"] + return l.completeLogin(ctx) + } + + // Step 1: Apple ID + password + if l.session == nil { + username := input["username"] + if username == "" { + return nil, fmt.Errorf("Apple ID is required") + } + password := input["password"] + if password == "" { + return nil, fmt.Errorf("Password is required") + } + l.username = username + + session, err := rustpushgo.LoginStart(username, password, l.cfg, l.conn) + if err != nil { + l.Main.Bridge.Log.Error().Err(err).Str("username", username).Msg("Login failed") + return nil, fmt.Errorf("login failed: %w", err) + } + l.session = session + + if session.Needs2fa() { + l.Main.Bridge.Log.Info().Str("username", username).Msg("Login succeeded, waiting for 2FA") + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepTwoFactor, + Instructions: "Enter your Apple ID verification code.\n\n" + + "You may see a notification on your trusted Apple devices. " + + "If not, you can generate a code manually:\n" + + "• iPhone/iPad: Settings → [Your Name] → Sign-In & Security → Two-Factor Authentication → Get Verification Code\n" + + "• Mac: System Settings → [Your Name] → Sign-In & Security → Two-Factor Authentication → Get Verification Code", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + ID: "code", + Name: "2FA Code", + }}, + }, + }, nil + } + + // No 2FA needed — skip straight to IDS registration + l.Main.Bridge.Log.Info().Str("username", username).Msg("Login succeeded without 2FA, finishing registration") + return l.finishLogin(ctx) + } + + // Step 2: 2FA code + code := input["code"] + if code == "" { + return nil, fmt.Errorf("2FA code is required") + } + + success, err := l.session.Submit2fa(code) + if err != nil { + return nil, fmt.Errorf("2FA verification failed: %w", err) + } + if !success { + return nil, fmt.Errorf("2FA verification failed — invalid code") + } + + return l.finishLogin(ctx) +} + +func (l *AppleIDLogin) finishLogin(ctx context.Context) (*bridgev2.LoginStep, error) { + log := l.Main.Bridge.Log.With().Str("component", "imessage").Logger() + + // Reuse existing session state if available and keystore matches + session := loadCachedSession(l.User, log) + if !session.validate(log) { + session = nil + } + + // Reuse existing identity if available (avoids "new Mac" notifications) + var existingIdentityArg **rustpushgo.WrappedIdsngmIdentity + if existing := getExistingIdentity(session, log); existing != nil { + existingIdentityArg = &existing + } else { + log.Info().Msg("No existing identity found, will generate new one (first login)") + } + + // Reuse existing IDS users/registration if available (avoids register() call) + var existingUsersArg **rustpushgo.WrappedIdsUsers + if existing := getExistingUsers(session, log); existing != nil { + existingUsersArg = &existing + } else { + log.Info().Msg("No existing users found, will register fresh (first login)") + } + + result, err := l.session.Finish(l.cfg, l.conn, existingIdentityArg, existingUsersArg) + if err != nil { + l.Main.Bridge.Log.Error().Err(err).Msg("IDS registration failed during finishLogin") + return nil, fmt.Errorf("login completion failed: %w", err) + } + l.result = &result + l.selectedDevice = -1 + + // Skip device selection and passcode when CloudKit backfill is disabled — + // iCloud Keychain is only needed for decrypting CloudKit message records. + if !l.Main.Config.UseCloudKitBackfill() { + log.Info().Msg("CloudKit backfill disabled, skipping device selection and passcode") + handles := l.result.Users.GetHandles() + if step := handleSelectionStep(handles); step != nil { + return step, nil + } + if len(handles) > 0 { + l.handle = handles[0] + } + return l.completeLogin(ctx) + } + + return fetchDevicesAndPrompt(log, l.result.TokenProvider, &l.devices, &l.selectedDevice) +} + +func (l *AppleIDLogin) handleDeviceSelection(device string) (*bridgev2.LoginStep, error) { + l.selectedDevice = parseDeviceSelection(device, l.devices) + return devicePasscodeStepForDevice(l.devices, l.selectedDevice), nil +} + +func (l *AppleIDLogin) handlePasscodeAndContinue(ctx context.Context, passcode string) (*bridgev2.LoginStep, error) { + log := l.Main.Bridge.Log.With().Str("component", "imessage").Logger() + if err := joinKeychainWithPasscode(log, l.result.TokenProvider, passcode, l.devices, l.selectedDevice); err != nil { + return nil, err + } + + handles := l.result.Users.GetHandles() + if step := handleSelectionStep(handles); step != nil { + return step, nil + } + if len(handles) > 0 { + l.handle = handles[0] + } + return l.completeLogin(ctx) +} + +func (l *AppleIDLogin) completeLogin(ctx context.Context) (*bridgev2.LoginStep, error) { + meta := &UserLoginMetadata{ + Platform: "rustpush-local", + APSState: l.conn.State().ToString(), + IDSUsers: l.result.Users.ToString(), + IDSIdentity: l.result.Identity.ToString(), + DeviceID: l.cfg.GetDeviceId(), + PreferredHandle: l.handle, + } + + return completeLoginWithMeta(ctx, l.User, l.Main, l.username, l.cfg, l.conn, l.result, meta) +} + +// ============================================================================ +// External Key Login (cross-platform) +// ============================================================================ + +// ExternalKeyLogin implements the multi-step login flow for non-macOS platforms: +// Hardware key → Apple ID + password → 2FA code → IDS registration → device selection → passcode → handle selection → connected. +type ExternalKeyLogin struct { + User *bridgev2.User + Main *IMConnector + hardwareKey string + username string + cfg *rustpushgo.WrappedOsConfig + conn *rustpushgo.WrappedApsConnection + session *rustpushgo.LoginSession + result *rustpushgo.IdsUsersWithIdentityRecord // set after IDS registration + handle string // chosen handle + devices []rustpushgo.EscrowDeviceInfo // escrow devices (fetched after IDS registration) + selectedDevice int // index into devices (-1 = not yet selected) +} + +var _ bridgev2.LoginProcessUserInput = (*ExternalKeyLogin)(nil) + +func (l *ExternalKeyLogin) Cancel() {} + +func (l *ExternalKeyLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepExternalKey, + Instructions: "Enter your hardware key (base64-encoded JSON).\n\n" + + "This is extracted once from a real Mac using the key extraction tool.\n" + + "It contains hardware identifiers needed for iMessage registration.", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypePassword, + ID: "hardware_key", + Name: "Hardware Key (base64)", + }}, + }, + }, nil +} + +func (l *ExternalKeyLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + // Device passcode step (after device selection, before handle selection) + if passcode, ok := input["passcode"]; ok && l.result != nil { + return l.handlePasscodeAndContinue(ctx, passcode) + } + + // Device selection step (after IDS registration, before passcode) + if device, ok := input["device"]; ok && l.result != nil { + return l.handleDeviceSelection(device) + } + + // Handle selection step (after device passcode) + if l.result != nil { + l.handle = input["handle"] + return l.completeLogin(ctx) + } + + // Step 1: Hardware key + if l.cfg == nil { + hwKey := input["hardware_key"] + if hwKey == "" { + return nil, fmt.Errorf("hardware key is required") + } + l.hardwareKey = stripNonBase64(hwKey) + + rustpushgo.InitLogger() + + cfg, err := rustpushgo.CreateConfigFromHardwareKey(l.hardwareKey) + if err != nil { + return nil, fmt.Errorf("invalid hardware key: %w", err) + } + l.cfg = cfg + + // Reuse existing session state if available and keystore matches + extLog := l.Main.Bridge.Log.With().Str("component", "imessage").Logger() + session := loadCachedSession(l.User, extLog) + if !session.validate(extLog) { + session = nil + } + apsState := getExistingAPSState(session, extLog) + l.conn = rustpushgo.Connect(cfg, apsState) + + nacNote := "Registration uses the hardware key for NAC validation (no Mac needed at runtime)." + if cfg.RequiresNacRelay() { + nacNote = "Apple Silicon hardware key detected.\n" + + "The NAC relay server must be running on the Mac that provided this key during registration.\n" + + "Start it with: go run tools/nac-relay/main.go" + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepAppleIDPassword, + Instructions: "Enter your Apple ID credentials.\n" + nacNote, + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypeEmail, + ID: "username", + Name: "Apple ID", + }, { + Type: bridgev2.LoginInputFieldTypePassword, + ID: "password", + Name: "Password", + }}, + }, + }, nil + } + + // Step 2: Apple ID + password + if l.session == nil { + username := input["username"] + if username == "" { + return nil, fmt.Errorf("Apple ID is required") + } + password := input["password"] + if password == "" { + return nil, fmt.Errorf("Password is required") + } + l.username = username + + session, err := rustpushgo.LoginStart(username, password, l.cfg, l.conn) + if err != nil { + l.Main.Bridge.Log.Error().Err(err).Str("username", username).Msg("Login failed") + return nil, fmt.Errorf("login failed: %w", err) + } + l.session = session + + if session.Needs2fa() { + l.Main.Bridge.Log.Info().Str("username", username).Msg("Login succeeded, waiting for 2FA") + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepTwoFactor, + Instructions: "Enter your Apple ID verification code.\n\n" + + "You may see a notification on your trusted Apple devices.", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + ID: "code", + Name: "2FA Code", + }}, + }, + }, nil + } + + l.Main.Bridge.Log.Info().Str("username", username).Msg("Login succeeded without 2FA") + return l.finishLogin(ctx) + } + + // Step 3: 2FA code + code := input["code"] + if code == "" { + return nil, fmt.Errorf("2FA code is required") + } + + success, err := l.session.Submit2fa(code) + if err != nil { + return nil, fmt.Errorf("2FA verification failed: %w", err) + } + if !success { + return nil, fmt.Errorf("2FA verification failed — invalid code") + } + + return l.finishLogin(ctx) +} + +func (l *ExternalKeyLogin) finishLogin(ctx context.Context) (*bridgev2.LoginStep, error) { + log := l.Main.Bridge.Log.With().Str("component", "imessage").Logger() + + // Reuse existing session state if available and keystore matches + session := loadCachedSession(l.User, log) + if !session.validate(log) { + session = nil + } + + // Reuse existing identity if available (avoids "new Mac" notifications) + var existingIdentityArg **rustpushgo.WrappedIdsngmIdentity + if existing := getExistingIdentity(session, log); existing != nil { + existingIdentityArg = &existing + } else { + log.Info().Msg("No existing identity found, will generate new one (first login)") + } + + // Reuse existing IDS users/registration if available (avoids register() call) + var existingUsersArg **rustpushgo.WrappedIdsUsers + if existing := getExistingUsers(session, log); existing != nil { + existingUsersArg = &existing + } else { + log.Info().Msg("No existing users found, will register fresh (first login)") + } + + result, err := l.session.Finish(l.cfg, l.conn, existingIdentityArg, existingUsersArg) + if err != nil { + l.Main.Bridge.Log.Error().Err(err).Msg("IDS registration failed during finishLogin") + return nil, fmt.Errorf("login completion failed: %w", err) + } + l.result = &result + l.selectedDevice = -1 + + // Skip device selection and passcode when CloudKit backfill is disabled — + // iCloud Keychain is only needed for decrypting CloudKit message records. + if !l.Main.Config.UseCloudKitBackfill() { + log.Info().Msg("CloudKit backfill disabled, skipping device selection and passcode") + handles := l.result.Users.GetHandles() + if step := handleSelectionStep(handles); step != nil { + return step, nil + } + if len(handles) > 0 { + l.handle = handles[0] + } + return l.completeLogin(ctx) + } + + return fetchDevicesAndPrompt(log, l.result.TokenProvider, &l.devices, &l.selectedDevice) +} + +func (l *ExternalKeyLogin) handleDeviceSelection(device string) (*bridgev2.LoginStep, error) { + l.selectedDevice = parseDeviceSelection(device, l.devices) + return devicePasscodeStepForDevice(l.devices, l.selectedDevice), nil +} + +func (l *ExternalKeyLogin) handlePasscodeAndContinue(ctx context.Context, passcode string) (*bridgev2.LoginStep, error) { + log := l.Main.Bridge.Log.With().Str("component", "imessage").Logger() + if err := joinKeychainWithPasscode(log, l.result.TokenProvider, passcode, l.devices, l.selectedDevice); err != nil { + return nil, err + } + + handles := l.result.Users.GetHandles() + if step := handleSelectionStep(handles); step != nil { + return step, nil + } + if len(handles) > 0 { + l.handle = handles[0] + } + return l.completeLogin(ctx) +} + +func (l *ExternalKeyLogin) completeLogin(ctx context.Context) (*bridgev2.LoginStep, error) { + meta := &UserLoginMetadata{ + Platform: "rustpush-external-key", + APSState: l.conn.State().ToString(), + IDSUsers: l.result.Users.ToString(), + IDSIdentity: l.result.Identity.ToString(), + DeviceID: l.cfg.GetDeviceId(), + HardwareKey: l.hardwareKey, + PreferredHandle: l.handle, + } + + return completeLoginWithMeta(ctx, l.User, l.Main, l.username, l.cfg, l.conn, l.result, meta) +} + +// ============================================================================ +// Existing session state lookup +// ============================================================================ + +// cachedSessionState holds the raw strings for all three session components. +// They are validated as a group against the keystore before use, since they +// reference each other's keys and are only useful together. +type cachedSessionState struct { + IDSIdentity string + APSState string + IDSUsers string + PreferredHandle string + source string // "database" or "backup file", for logging +} + +// loadCachedSession looks up all three session components (identity, APS state, +// IDS users) from the bridge database or backup session file. Returns nil if +// nothing is found. The returned state has NOT been validated against the +// keystore yet — call validate() before using. +func loadCachedSession(user *bridgev2.User, log zerolog.Logger) *cachedSessionState { + // Check DB first + for _, login := range user.GetCachedUserLogins() { + if meta, ok := login.Metadata.(*UserLoginMetadata); ok { + if meta.IDSUsers != "" || meta.IDSIdentity != "" || meta.APSState != "" { + log.Info().Msg("Found existing session state in database") + return &cachedSessionState{ + IDSIdentity: meta.IDSIdentity, + APSState: meta.APSState, + IDSUsers: meta.IDSUsers, + PreferredHandle: meta.PreferredHandle, + source: "database", + } + } + } + } + // Fall back to session file (survives DB resets) + state := loadSessionState(log) + if state.IDSIdentity != "" || state.APSState != "" || state.IDSUsers != "" { + log.Info().Msg("Found existing session state in backup file") + return &cachedSessionState{ + IDSIdentity: state.IDSIdentity, + APSState: state.APSState, + IDSUsers: state.IDSUsers, + PreferredHandle: state.PreferredHandle, + source: "backup file", + } + } + return nil +} + +// validate checks that the cached IDS users state references keys that exist +// in the keystore. If the keystore was wiped, never migrated, or belongs to a +// different installation, this returns false and all cached state should be +// discarded (they are a coupled set). +func (c *cachedSessionState) validate(log zerolog.Logger) bool { + if c == nil || c.IDSUsers == "" { + return true // nothing to validate + } + users := rustpushgo.NewWrappedIdsUsers(&c.IDSUsers) + if !users.ValidateKeystore() { + log.Warn(). + Str("source", c.source). + Msg("Cached session state references missing keystore keys — discarding (will register fresh)") + return false + } + log.Info().Str("source", c.source).Msg("Cached session state validated against keystore") + return true +} + +// getExistingIdentity returns the cached IDSNGMIdentity for reuse during +// re-authentication (avoiding "new Mac" notifications). +// The session must have been validated before calling this. +func getExistingIdentity(session *cachedSessionState, log zerolog.Logger) *rustpushgo.WrappedIdsngmIdentity { + if session != nil && session.IDSIdentity != "" { + log.Info().Str("source", session.source).Msg("Reusing existing identity") + identityStr := session.IDSIdentity + return rustpushgo.NewWrappedIdsngmIdentity(&identityStr) + } + return nil +} + +// getExistingAPSState returns the cached APS connection state for reuse during +// re-authentication (preserves push token, avoids new device registration). +// The session must have been validated before calling this. +func getExistingAPSState(session *cachedSessionState, log zerolog.Logger) *rustpushgo.WrappedApsState { + if session != nil && session.APSState != "" { + log.Info().Str("source", session.source).Msg("Reusing existing APS state") + return rustpushgo.NewWrappedApsState(&session.APSState) + } + log.Info().Msg("No existing APS state found, will create new connection") + return rustpushgo.NewWrappedApsState(nil) +} + +// getExistingUsers returns the cached IDSUsers for reuse during +// re-authentication (avoids calling register() which triggers notifications). +// The session must have been validated before calling this. +func getExistingUsers(session *cachedSessionState, log zerolog.Logger) *rustpushgo.WrappedIdsUsers { + if session != nil && session.IDSUsers != "" { + log.Info().Str("source", session.source).Msg("Reusing existing IDS users") + return rustpushgo.NewWrappedIdsUsers(&session.IDSUsers) + } + return nil +} + +// ============================================================================ +// Shared login helpers +// ============================================================================ + +// formatDeviceLabel returns a human-readable label for a device, e.g. +// "Ludvig's iPhone (iPhone15,2)". +func formatDeviceLabel(d rustpushgo.EscrowDeviceInfo) string { + if d.DeviceName != "" && d.DeviceModel != "" { + return fmt.Sprintf("%s (%s)", d.DeviceName, d.DeviceModel) + } + if d.DeviceName != "" { + return d.DeviceName + } + if d.DeviceModel != "" { + return fmt.Sprintf("Device (%s)", d.DeviceModel) + } + return fmt.Sprintf("Device (serial: %s)", d.Serial) +} + +// fetchDevicesAndPrompt fetches escrow devices from the token provider and returns +// either a device selection step (multiple devices) or skips straight to the +// passcode step (single device, auto-selected). On failure, falls back to a +// generic passcode step. +func fetchDevicesAndPrompt(log zerolog.Logger, tp **rustpushgo.WrappedTokenProvider, devices *[]rustpushgo.EscrowDeviceInfo, selectedDevice *int) (*bridgev2.LoginStep, error) { + if tp == nil || *tp == nil { + log.Warn().Msg("No TokenProvider available, skipping device discovery") + return devicePasscodeStepForDevice(nil, -1), nil + } + + devs, err := (*tp).GetEscrowDevices() + if err != nil { + log.Warn().Err(err).Msg("Failed to fetch escrow devices, falling back to generic passcode prompt") + return devicePasscodeStepForDevice(nil, -1), nil + } + *devices = devs + + if len(devs) == 1 { + // Auto-select the only device and go straight to passcode + *selectedDevice = 0 + log.Info().Str("device", formatDeviceLabel(devs[0])).Msg("Single escrow device found, auto-selected") + return devicePasscodeStepForDevice(devs, 0), nil + } + + // Multiple devices — let the user choose + options := make([]string, len(devs)) + for i, d := range devs { + options[i] = formatDeviceLabel(d) + } + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepSelectDevice, + Instructions: "Multiple Apple devices were found on your account.\n" + + "Choose which device's passcode you want to use to join the iCloud Keychain.\n\n" + + "Pick the device whose lock-screen passcode you know.", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypeSelect, + ID: "device", + Name: "Device", + Options: options, + }}, + }, + }, nil +} + +// parseDeviceSelection converts the user's device selection (the label string) +// back to an index into the devices list. +func parseDeviceSelection(selected string, devices []rustpushgo.EscrowDeviceInfo) int { + for i, d := range devices { + if formatDeviceLabel(d) == selected { + return i + } + } + // Shouldn't happen with a select field, but default to first device + if len(devices) > 0 { + return 0 + } + return -1 +} + +// devicePasscodeStepForDevice returns a login step prompting for the passcode, +// with context about which device the passcode is for. +func devicePasscodeStepForDevice(devices []rustpushgo.EscrowDeviceInfo, selectedDevice int) *bridgev2.LoginStep { + var instructions string + if selectedDevice >= 0 && selectedDevice < len(devices) { + d := devices[selectedDevice] + instructions = fmt.Sprintf( + "Enter the passcode for %s.\n\n"+ + "This is the PIN or password you use to unlock this device. "+ + "It's needed to join the iCloud Keychain trust circle, which gives the bridge "+ + "access to your Messages in iCloud for backfilling chat history.\n\n"+ + "Your passcode is only used once during setup and is not stored.", + formatDeviceLabel(d), + ) + } else { + instructions = "Enter the passcode you use to unlock your iPhone or Mac.\n\n" + + "This is needed to join the iCloud Keychain trust circle, which gives the bridge " + + "access to your Messages in iCloud for backfilling chat history.\n\n" + + "Your passcode is only used once during setup and is not stored." + } + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepDevicePasscode, + Instructions: instructions, + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypePassword, + ID: "passcode", + Name: "Device Passcode", + }}, + }, + } +} + +// joinKeychainWithPasscode calls the Rust FFI to join the iCloud Keychain trust +// circle using the provided device passcode. This is required for PCS-encrypted +// CloudKit records (Messages in iCloud). +// If a specific device was selected, the corresponding bottle is tried first. +func joinKeychainWithPasscode(log zerolog.Logger, tp **rustpushgo.WrappedTokenProvider, passcode string, devices []rustpushgo.EscrowDeviceInfo, selectedDevice int) error { + if tp == nil || *tp == nil { + log.Warn().Msg("No TokenProvider available, skipping keychain join") + return nil + } + log.Info().Msg("Joining iCloud Keychain trust circle...") + + var result string + var err error + if selectedDevice >= 0 && selectedDevice < len(devices) { + deviceIndex := devices[selectedDevice].Index + log.Info().Uint32("device_index", deviceIndex).Str("device", formatDeviceLabel(devices[selectedDevice])).Msg("Using preferred device bottle") + result, err = (*tp).JoinKeychainCliqueForDevice(passcode, deviceIndex) + } else { + result, err = (*tp).JoinKeychainClique(passcode) + } + + if err != nil { + log.Error().Err(err).Msg("Failed to join keychain trust circle") + return fmt.Errorf("failed to join iCloud Keychain: %w", err) + } + log.Info().Str("result", result).Msg("Successfully joined iCloud Keychain trust circle") + return nil +} + +// handleSelectionStep returns a login step prompting the user to pick a handle, +// or nil if there are no handles. Always prompts (even with 1 handle) so the +// preferred handle is explicitly chosen and persisted. +func handleSelectionStep(handles []string) *bridgev2.LoginStep { + if len(handles) == 0 { + return nil + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepSelectHandle, + Instructions: "Choose which identity to use for outgoing iMessages.\n" + + "This is what recipients will see your messages \"from\".", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypeSelect, + ID: "handle", + Name: "Send messages as", + Options: handles, + }}, + }, + } +} + +// completeLoginWithMeta is the shared tail of both login flows: creates the +// IMClient, persists metadata, saves the identity backup file, and starts the +// bridge connection. +func completeLoginWithMeta( + ctx context.Context, + user *bridgev2.User, + main *IMConnector, + username string, + cfg *rustpushgo.WrappedOsConfig, + conn *rustpushgo.WrappedApsConnection, + result *rustpushgo.IdsUsersWithIdentityRecord, + meta *UserLoginMetadata, +) (*bridgev2.LoginStep, error) { + log := main.Bridge.Log.With().Str("component", "imessage").Logger() + + // Store iCloud account persist data for TokenProvider restoration + if result.AccountPersist != nil { + meta.AccountUsername = result.AccountPersist.Username + meta.AccountHashedPasswordHex = result.AccountPersist.HashedPasswordHex + meta.AccountPET = result.AccountPersist.Pet + meta.AccountADSID = result.AccountPersist.Adsid + meta.AccountDSID = result.AccountPersist.Dsid + meta.AccountSPDBase64 = result.AccountPersist.SpdBase64 + log.Info().Str("dsid", meta.AccountDSID).Msg("iCloud account credentials available for TokenProvider") + // Also capture the MobileMe delegate so it can be seeded on restore + if result.TokenProvider != nil && *result.TokenProvider != nil { + tp := *result.TokenProvider + if delegateJSON, mmeErr := tp.GetMmeDelegateJson(); mmeErr == nil && delegateJSON != nil { + meta.MmeDelegateJSON = *delegateJSON + log.Info().Msg("Captured MobileMe delegate for persistence") + } + } + } else { + log.Warn().Msg("No account persist data from login — cloud services will not be available") + } + + // Persist full session state to backup file so it survives DB resets. + saveSessionState(log, PersistedSessionState{ + IDSIdentity: meta.IDSIdentity, + APSState: meta.APSState, + IDSUsers: meta.IDSUsers, + PreferredHandle: meta.PreferredHandle, + Platform: meta.Platform, + HardwareKey: meta.HardwareKey, + DeviceID: meta.DeviceID, + AccountUsername: meta.AccountUsername, + AccountHashedPasswordHex: meta.AccountHashedPasswordHex, + AccountPET: meta.AccountPET, + AccountADSID: meta.AccountADSID, + AccountDSID: meta.AccountDSID, + AccountSPDBase64: meta.AccountSPDBase64, + MmeDelegateJSON: meta.MmeDelegateJSON, + }) + + loginID := networkid.UserLoginID(result.Users.LoginId(0)) + + client := &IMClient{ + Main: main, + config: cfg, + users: result.Users, + identity: result.Identity, + connection: conn, + tokenProvider: result.TokenProvider, + contactsReady: false, + contactsReadyCh: make(chan struct{}), + cloudStore: newCloudBackfillStore(main.Bridge.DB.Database, loginID), + sharedProfileStore: newSharedProfileStore(main.Bridge.DB.Database, loginID), + fordCache: NewFordKeyCache(), + recentUnsends: make(map[string]time.Time), + recentOutboundUnsends: make(map[string]time.Time), + recentSmsReactionEchoes: make(map[string]time.Time), + smsPortals: make(map[string]bool), + sharedStreamAssetCache: make(map[string]map[string]struct{}), + sharedAlbumRooms: make(map[string]id.RoomID), + imGroupNames: make(map[string]string), + imGroupGuids: make(map[string]string), + imGroupParticipants: make(map[string][]string), + gidAliases: make(map[string]string), + lastGroupForMember: make(map[string]networkid.PortalKey), + restorePipelines: make(map[string]bool), + forwardBackfillSem: make(chan struct{}, 3), + } + + ul, err := user.NewLogin(ctx, &database.UserLogin{ + ID: loginID, + RemoteName: username, + RemoteProfile: status.RemoteProfile{ + Name: username, + }, + Metadata: meta, + }, &bridgev2.NewLoginParams{ + DeleteOnConflict: true, + LoadUserLogin: func(ctx context.Context, login *bridgev2.UserLogin) error { + client.UserLogin = login + login.Client = client + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create user login: %w", err) + } + + go client.Connect(context.Background()) + + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeComplete, + StepID: LoginStepComplete, + Instructions: "Successfully logged in to iMessage. Bridge is starting.", + CompleteParams: &bridgev2.LoginCompleteParams{ + UserLoginID: ul.ID, + UserLogin: ul, + }, + }, nil +} diff --git a/pkg/connector/permissions_darwin.go b/pkg/connector/permissions_darwin.go new file mode 100644 index 00000000..dc35780e --- /dev/null +++ b/pkg/connector/permissions_darwin.go @@ -0,0 +1,76 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +//go:build darwin && !ios + +package connector + +import ( + "database/sql" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/rs/zerolog" + _ "github.com/mattn/go-sqlite3" +) + +func canReadChatDB(log zerolog.Logger) bool { + home, err := os.UserHomeDir() + if err != nil { + log.Warn().Err(err).Msg("canReadChatDB: failed to get home directory") + return false + } + dbPath := filepath.Join(home, "Library", "Messages", "chat.db") + if _, err := os.Stat(dbPath); err != nil { + log.Warn().Err(err).Str("path", dbPath).Msg("canReadChatDB: chat.db not found") + return false + } + db, err := sql.Open("sqlite3", dbPath+"?mode=ro") + if err != nil { + log.Warn().Err(err).Str("path", dbPath).Msg("canReadChatDB: failed to open chat.db") + return false + } + defer db.Close() + _, err = db.Query("SELECT 1 FROM message LIMIT 1") + if err != nil { + if isPermissionError(err) { + log.Warn().Err(err).Str("path", dbPath).Msg("canReadChatDB: permission denied querying chat.db (Full Disk Access not granted?)") + } else { + log.Warn().Err(err).Str("path", dbPath).Msg("canReadChatDB: test query failed") + } + return false + } + log.Debug().Str("path", dbPath).Msg("canReadChatDB: chat.db is accessible") + return true +} + +func isPermissionError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "operation not permitted") || + strings.Contains(msg, "permission denied") +} + +func showDialogAndOpenFDA(log zerolog.Logger) { + log.Warn().Msg("Full Disk Access not granted — prompting user") + script := `display dialog "mautrix-imessage needs Full Disk Access to read your iMessage history for backfill.\n\nClick OK to open System Settings, then enable the toggle for mautrix-imessage." with title "iMessage Bridge" buttons {"OK"} default button "OK"` + exec.Command("osascript", "-e", script).Run() + exec.Command("open", "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles").Run() +} + +func waitForFDA(log zerolog.Logger) { + for !canReadChatDB(log) { + time.Sleep(2 * time.Second) + } + log.Info().Msg("Full Disk Access restored") +} diff --git a/pkg/connector/permissions_other.go b/pkg/connector/permissions_other.go new file mode 100644 index 00000000..48597ce0 --- /dev/null +++ b/pkg/connector/permissions_other.go @@ -0,0 +1,9 @@ +//go:build !darwin + +package connector + +import "github.com/rs/zerolog" + +func canReadChatDB(_ zerolog.Logger) bool { return false } +func showDialogAndOpenFDA(_ zerolog.Logger) {} +func waitForFDA(_ zerolog.Logger) {} diff --git a/pkg/connector/recycle_bin_hints.go b/pkg/connector/recycle_bin_hints.go new file mode 100644 index 00000000..56296502 --- /dev/null +++ b/pkg/connector/recycle_bin_hints.go @@ -0,0 +1,179 @@ +package connector + +import ( + "context" + "encoding/base64" + "encoding/json" + "sort" + "strings" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +type recoverableMessageMetadata struct { + Version int `json:"v"` + RecordName string `json:"record_name"` + CloudChatID string `json:"cloud_chat_id"` + Sender string `json:"sender"` + IsFromMe bool `json:"is_from_me"` + Service string `json:"service"` + TimestampMS int64 `json:"timestamp_ms"` +} + +type recoverableMessagePortalHint struct { + PortalID string + CloudChatID string + Service string + Participants []string + NewestTS int64 + Count int +} + +func recoverableGUIDFromEntry(entry string) string { + if idx := strings.IndexByte(entry, '|'); idx >= 0 { + return entry[:idx] + } + return entry +} + +func parseRecoverableMessageMetadata(entry string) (recoverableMessageMetadata, bool) { + var metadata recoverableMessageMetadata + idx := strings.IndexByte(entry, '|') + if idx < 0 || idx >= len(entry)-1 { + return metadata, false + } + + payload, err := base64.StdEncoding.DecodeString(entry[idx+1:]) + if err != nil { + return metadata, false + } + if err = json.Unmarshal(payload, &metadata); err != nil { + return metadata, false + } + if metadata.Version != 1 { + return recoverableMessageMetadata{}, false + } + return metadata, true +} + +func (metadata recoverableMessageMetadata) wrappedMessage(guid string) rustpushgo.WrappedCloudSyncMessage { + return rustpushgo.WrappedCloudSyncMessage{ + RecordName: metadata.RecordName, + Guid: guid, + CloudChatId: metadata.CloudChatID, + Sender: metadata.Sender, + IsFromMe: metadata.IsFromMe, + Service: metadata.Service, + TimestampMs: metadata.TimestampMS, + } +} + +func normalizeRecoverableParticipants(participants []string) []string { + if len(participants) == 0 { + return nil + } + seen := make(map[string]struct{}, len(participants)) + normalized := make([]string, 0, len(participants)) + for _, participant := range participants { + value := normalizeIdentifierForPortalID(participant) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + sort.Strings(normalized) + return normalized +} + +func (c *IMClient) participantSeedsForRecoverableMessage(portalID string, metadata recoverableMessageMetadata) []string { + isGroup := strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") + if !isGroup { + return normalizeRecoverableParticipants([]string{portalID}) + } + + participants := make([]string, 0, 4) + if metadata.Sender != "" { + participants = append(participants, metadata.Sender) + } + if metadata.IsFromMe && c.handle != "" { + participants = append(participants, c.handle) + } + if strings.Contains(portalID, ",") { + participants = append(participants, strings.Split(portalID, ",")...) + } + return normalizeRecoverableParticipants(participants) +} + +func (c *IMClient) buildRecoverableMessagePortalHints(ctx context.Context, entries []string) []recoverableMessagePortalHint { + type portalAccumulator struct { + recoverableMessagePortalHint + participantSet map[string]struct{} + } + + byPortal := make(map[string]*portalAccumulator) + for _, entry := range entries { + metadata, ok := parseRecoverableMessageMetadata(entry) + if !ok { + continue + } + + guid := recoverableGUIDFromEntry(entry) + if guid == "" { + continue + } + + portalID := c.resolveConversationID(ctx, metadata.wrappedMessage(guid)) + if portalID == "" { + continue + } + + acc := byPortal[portalID] + if acc == nil { + acc = &portalAccumulator{ + recoverableMessagePortalHint: recoverableMessagePortalHint{ + PortalID: portalID, + }, + participantSet: make(map[string]struct{}), + } + byPortal[portalID] = acc + } + + acc.Count++ + if metadata.TimestampMS > acc.NewestTS { + acc.NewestTS = metadata.TimestampMS + } + if acc.CloudChatID == "" && metadata.CloudChatID != "" { + acc.CloudChatID = metadata.CloudChatID + } + if acc.Service == "" && metadata.Service != "" { + acc.Service = metadata.Service + } + for _, participant := range c.participantSeedsForRecoverableMessage(portalID, metadata) { + acc.participantSet[participant] = struct{}{} + } + } + + out := make([]recoverableMessagePortalHint, 0, len(byPortal)) + for _, acc := range byPortal { + if len(acc.participantSet) > 0 { + acc.Participants = make([]string, 0, len(acc.participantSet)) + for participant := range acc.participantSet { + acc.Participants = append(acc.Participants, participant) + } + sort.Strings(acc.Participants) + } + out = append(out, acc.recoverableMessagePortalHint) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].NewestTS == out[j].NewestTS { + return out[i].PortalID < out[j].PortalID + } + return out[i].NewestTS > out[j].NewestTS + }) + return out +} diff --git a/pkg/connector/shared_profile.go b/pkg/connector/shared_profile.go new file mode 100644 index 00000000..fccb8134 --- /dev/null +++ b/pkg/connector/shared_profile.go @@ -0,0 +1,403 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package connector + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + "maunium.net/go/mautrix/bridgev2/networkid" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +// Shared iMessage profile (Name & Photo Sharing) ingestion, caching, and +// persistence. These are the "Me card" shares an iPhone sends automatically +// when its user has Name and Photo Sharing enabled — they carry a CloudKit +// record key + decryption key that point at an encrypted blob containing the +// sender's first/last name, display name, and avatar bytes. +// +// User-provided contacts (CardDAV or iCloud) always win over shared profiles +// in the GetUserInfo fallback chain; shared profiles only surface when the +// sender's identifier is unknown to the address book entirely. +// +// Design: +// - `sharedProfileStore` persists each decrypted record plus the keys we +// need to re-fetch it, so the photo survives bridge restarts without the +// iPhone side re-sending a share. +// - An in-memory `sync.Map` fronts the DB for read hot paths. +// - `refreshSharedProfilesWithContacts` rides setContactsReady so the +// refresh runs on the same cadence as CardDAV (initial sync + every +// periodic re-sync). Profile edits that don't trigger a fresh +// ShareProfile message still propagate to Matrix on the next CardDAV +// tick. + +// sharedProfileStore persists decrypted iMessage shared profiles keyed by +// (login_id, identifier). +type sharedProfileStore struct { + db *dbutil.Database + loginID networkid.UserLoginID +} + +func newSharedProfileStore(db *dbutil.Database, loginID networkid.UserLoginID) *sharedProfileStore { + return &sharedProfileStore{db: db, loginID: loginID} +} + +func (s *sharedProfileStore) ensureSchema(ctx context.Context) error { + _, err := s.db.Exec(ctx, `CREATE TABLE IF NOT EXISTS shared_profiles ( + login_id TEXT NOT NULL, + identifier TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + avatar BLOB, + record_key TEXT NOT NULL, + decryption_key BLOB NOT NULL, + has_poster BOOLEAN NOT NULL DEFAULT FALSE, + updated_ts BIGINT NOT NULL, + PRIMARY KEY (login_id, identifier) + )`) + if err != nil { + return fmt.Errorf("failed to create shared_profiles table: %w", err) + } + return nil +} + +// sharedProfileRow is the on-disk representation; also used in-memory as the +// cache value so we always have the fetch keys at hand for periodic re-sync. +type sharedProfileRow struct { + Identifier string + DisplayName string + FirstName string + LastName string + Avatar []byte + RecordKey string + DecryptionKey []byte + HasPoster bool + UpdatedTS int64 +} + +func (r *sharedProfileRow) asProfileRecord() *rustpushgo.WrappedProfileRecord { + rec := &rustpushgo.WrappedProfileRecord{ + DisplayName: r.DisplayName, + FirstName: r.FirstName, + LastName: r.LastName, + } + if len(r.Avatar) > 0 { + avatar := append([]byte(nil), r.Avatar...) + rec.Avatar = &avatar + } + return rec +} + +func (s *sharedProfileStore) save(ctx context.Context, row *sharedProfileRow) error { + _, err := s.db.Exec(ctx, `INSERT INTO shared_profiles + (login_id, identifier, display_name, first_name, last_name, avatar, record_key, decryption_key, has_poster, updated_ts) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (login_id, identifier) DO UPDATE SET + display_name = excluded.display_name, + first_name = excluded.first_name, + last_name = excluded.last_name, + avatar = excluded.avatar, + record_key = excluded.record_key, + decryption_key = excluded.decryption_key, + has_poster = excluded.has_poster, + updated_ts = excluded.updated_ts`, + s.loginID, row.Identifier, row.DisplayName, row.FirstName, row.LastName, + row.Avatar, row.RecordKey, row.DecryptionKey, row.HasPoster, row.UpdatedTS) + if err != nil { + return fmt.Errorf("failed to upsert shared profile: %w", err) + } + return nil +} + +func (s *sharedProfileStore) loadAll(ctx context.Context) ([]*sharedProfileRow, error) { + rows, err := s.db.Query(ctx, `SELECT identifier, display_name, first_name, last_name, + avatar, record_key, decryption_key, has_poster, updated_ts + FROM shared_profiles WHERE login_id = $1`, s.loginID) + if err != nil { + return nil, fmt.Errorf("failed to query shared_profiles: %w", err) + } + defer rows.Close() + var out []*sharedProfileRow + for rows.Next() { + var r sharedProfileRow + var avatar sql.RawBytes + if err := rows.Scan(&r.Identifier, &r.DisplayName, &r.FirstName, &r.LastName, + &avatar, &r.RecordKey, &r.DecryptionKey, &r.HasPoster, &r.UpdatedTS); err != nil { + return nil, fmt.Errorf("failed to scan shared_profiles row: %w", err) + } + if len(avatar) > 0 { + r.Avatar = append([]byte(nil), avatar...) + } + out = append(out, &r) + } + return out, rows.Err() +} + +// -- IMClient wiring --------------------------------------------------------- + +// loadSharedProfilesIntoCache hydrates the in-memory cache from the DB so a +// bridge restart doesn't lose cached names/photos until a fresh share message +// arrives. Called once from Connect after ensureSharedProfileSchema. +// periodicSharedProfileSync handles pushing cached state (and any CloudKit +// edits) to Matrix ghosts on its initial pre-ticker sync. +func (c *IMClient) loadSharedProfilesIntoCache(ctx context.Context, log zerolog.Logger) { + if c.sharedProfileStore == nil { + return + } + rows, err := c.sharedProfileStore.loadAll(ctx) + if err != nil { + log.Warn().Err(err).Msg("Failed to load shared profiles from DB") + return + } + for _, r := range rows { + c.sharedProfiles.Store(r.Identifier, r) + } + if len(rows) > 0 { + log.Info().Int("count", len(rows)).Msg("Loaded shared iMessage profiles from DB") + } +} + +// ensureSharedProfileSchema runs the CREATE TABLE IF NOT EXISTS so existing +// bridge instances pick up the feature on first run without requiring a +// re-login or fresh database. +func (c *IMClient) ensureSharedProfileSchema(ctx context.Context) error { + if c.sharedProfileStore == nil { + return nil + } + return c.sharedProfileStore.ensureSchema(ctx) +} + +// handleSharedProfile processes an incoming ShareProfile or UpdateProfile +// message. The Rust receive loop has already fetched and decrypted the +// CloudKit record inline (mirroring how IconChange MMCS bytes are downloaded +// before the callback fires), so this handler just persists what's on the +// wrapped message and pushes name/avatar to the Matrix ghost. +// +// Falls back to a Go-side FetchProfile call only when the Rust inline +// download didn't succeed (no display name on the wrapped message) and we +// still have the record/decryption keys to retry. +func (c *IMClient) handleSharedProfile(log zerolog.Logger, msg rustpushgo.WrappedMessage) { + if msg.Sender == nil || *msg.Sender == "" { + return + } + sender := *msg.Sender + log = log.With().Str("sender", sender).Logger() + + if msg.ShareProfileRecordKey == nil || msg.ShareProfileDecryptionKey == nil { + log.Debug().Msg("ShareProfile message has no record/decryption key, ignoring") + return + } + if c.client == nil { + return + } + + keyPrefix := *msg.ShareProfileRecordKey + if len(keyPrefix) > 8 { + keyPrefix = keyPrefix[:8] + } + + row := &sharedProfileRow{ + Identifier: sender, + RecordKey: *msg.ShareProfileRecordKey, + DecryptionKey: append([]byte(nil), *msg.ShareProfileDecryptionKey...), + HasPoster: msg.ShareProfileHasPoster, + UpdatedTS: time.Now().UnixMilli(), + } + if msg.ShareProfileDisplayName != nil { + row.DisplayName = *msg.ShareProfileDisplayName + } + if msg.ShareProfileFirstName != nil { + row.FirstName = *msg.ShareProfileFirstName + } + if msg.ShareProfileLastName != nil { + row.LastName = *msg.ShareProfileLastName + } + if msg.ShareProfileAvatar != nil { + row.Avatar = append([]byte(nil), *msg.ShareProfileAvatar...) + } + + // Inline download missed (e.g. ProfilesClient init failed mid-receive) — + // retry via the standalone FFI so we don't lose the share entirely. + if row.DisplayName == "" && row.FirstName == "" && row.LastName == "" && len(row.Avatar) == 0 { + log.Info(). + Str("record_key_prefix", keyPrefix). + Bool("has_poster", msg.ShareProfileHasPoster). + Msg("Inline ShareProfile fields empty — falling back to FetchProfile") + record, err := c.client.FetchProfile(*msg.ShareProfileRecordKey, *msg.ShareProfileDecryptionKey, msg.ShareProfileHasPoster) + if err != nil { + log.Warn().Err(err). + Str("record_key_prefix", keyPrefix). + Bool("has_poster", msg.ShareProfileHasPoster). + Msg("Failed to fetch shared profile") + return + } + row.DisplayName = record.DisplayName + row.FirstName = record.FirstName + row.LastName = record.LastName + if record.Avatar != nil { + row.Avatar = append([]byte(nil), *record.Avatar...) + } + } + + c.sharedProfiles.Store(sender, row) + if c.sharedProfileStore != nil { + if err := c.sharedProfileStore.save(context.Background(), row); err != nil { + log.Warn().Err(err).Msg("Failed to persist shared profile") + } + } + + log.Info(). + Str("record_key_prefix", keyPrefix). + Str("display_name", row.DisplayName). + Str("first_name", row.FirstName). + Str("last_name", row.LastName). + Bool("has_avatar", len(row.Avatar) > 0). + Msg("Cached shared iMessage profile") + + c.refreshGhostFromSharedProfile(log, sender) +} + +// refreshGhostFromSharedProfile pushes updated name/avatar to the Matrix +// ghost for the given identifier. GetUserInfo internally enforces the +// CardDAV/iCloud > shared-profile priority rule, so this is a no-op from the +// user's perspective when they already have the contact in their address +// book. +func (c *IMClient) refreshGhostFromSharedProfile(log zerolog.Logger, sender string) { + ctx := context.Background() + userID := makeUserID(sender) + ghost, err := c.Main.Bridge.GetGhostByID(ctx, userID) + if err != nil { + log.Warn().Err(err).Msg("Failed to get ghost for shared-profile refresh") + return + } + info, err := c.GetUserInfo(ctx, ghost) + if err != nil || info == nil { + return + } + ghost.UpdateInfo(ctx, info) +} + +// lookupSharedProfile returns the cached shared profile for an identifier, or +// nil if none. Falls back to the DB on cache miss so a cold GetUserInfo call +// before the bootstrap load completes still finds the row. +func (c *IMClient) lookupSharedProfile(identifier string) *rustpushgo.WrappedProfileRecord { + if v, ok := c.sharedProfiles.Load(identifier); ok { + switch r := v.(type) { + case *sharedProfileRow: + return r.asProfileRecord() + case *rustpushgo.WrappedProfileRecord: + // Compat path for any pre-existing cache writes. + return r + } + } + if c.sharedProfileStore == nil { + return nil + } + rows, err := c.sharedProfileStore.loadAll(context.Background()) + if err != nil { + return nil + } + for _, r := range rows { + c.sharedProfiles.Store(r.Identifier, r) + if r.Identifier == identifier { + return r.asProfileRecord() + } + } + return nil +} + +// refreshSharedProfilesOnConnect runs the startup share-profile refresh +// independently of CardDAV: first pushes every cached row to its Matrix +// ghost (no network — handles warm restarts), then re-fetches each row +// from CloudKit so profile edits we missed while offline propagate. The +// CloudKit fetch only needs ProfilesClient (keychain), not contacts. +// periodicCloudContactSync re-runs refreshAllSharedProfiles on each tick +// so we keep one ticker but don't gate the share-profile path behind +// CardDAV success (which can lag for minutes if MobileMe delegate +// expired and we're on the retry path). +func (c *IMClient) refreshSharedProfilesOnConnect(log zerolog.Logger) { + c.applyCachedSharedProfilesToGhosts(log) + c.refreshAllSharedProfiles(log) +} + +// applyCachedSharedProfilesToGhosts pushes every cached shared profile to +// its corresponding Matrix ghost without any CloudKit fetch. Runs once on +// connect so warm restarts re-render names + avatars immediately. +func (c *IMClient) applyCachedSharedProfilesToGhosts(log zerolog.Logger) { + applied := 0 + c.sharedProfiles.Range(func(key, _ any) bool { + identifier, ok := key.(string) + if !ok || identifier == "" { + return true + } + c.refreshGhostFromSharedProfile(log, identifier) + applied++ + return true + }) + if applied > 0 { + log.Info().Int("count", applied).Msg("Re-applied cached shared profiles to ghosts on connect") + } +} + +// refreshAllSharedProfiles re-fetches every persisted shared profile and +// refreshes the corresponding ghost when the record changed. +func (c *IMClient) refreshAllSharedProfiles(log zerolog.Logger) { + if c.sharedProfileStore == nil || c.client == nil { + return + } + rows, err := c.sharedProfileStore.loadAll(context.Background()) + if err != nil { + log.Warn().Err(err).Msg("Failed to load shared profiles for periodic sync") + return + } + var refreshed, changed int + for _, r := range rows { + record, err := c.client.FetchProfile(r.RecordKey, r.DecryptionKey, r.HasPoster) + if err != nil { + log.Debug().Err(err).Str("identifier", r.Identifier). + Msg("Periodic shared-profile fetch failed") + continue + } + refreshed++ + newAvatar := []byte(nil) + if record.Avatar != nil { + newAvatar = *record.Avatar + } + if record.DisplayName == r.DisplayName && + record.FirstName == r.FirstName && + record.LastName == r.LastName && + bytes.Equal(newAvatar, r.Avatar) { + continue + } + r.DisplayName = record.DisplayName + r.FirstName = record.FirstName + r.LastName = record.LastName + r.Avatar = append([]byte(nil), newAvatar...) + r.UpdatedTS = time.Now().UnixMilli() + if err := c.sharedProfileStore.save(context.Background(), r); err != nil { + log.Warn().Err(err).Str("identifier", r.Identifier). + Msg("Failed to persist refreshed shared profile") + } + c.sharedProfiles.Store(r.Identifier, r) + c.refreshGhostFromSharedProfile(log, r.Identifier) + changed++ + } + if refreshed > 0 { + log.Debug().Int("refreshed", refreshed).Int("changed", changed). + Msg("Periodic shared-profile sync completed") + } +} + diff --git a/pkg/connector/sharedstreams.go b/pkg/connector/sharedstreams.go new file mode 100644 index 00000000..b9735279 --- /dev/null +++ b/pkg/connector/sharedstreams.go @@ -0,0 +1,929 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "bytes" + "context" + "fmt" + "image" + "image/jpeg" + "path/filepath" + "runtime/debug" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog" + log "github.com/rs/zerolog/log" + "go.mau.fi/util/ffmpeg" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +// --------------------------------------------------------------------------- +// Command definitions +// --------------------------------------------------------------------------- + +var cmdSharedAlbums = &commands.FullHandler{ + Name: "shared-albums", + Func: fnSharedAlbums, + Help: commands.HelpMeta{ + Section: HelpSectionSharedStreams, + Description: "Browse shared albums — pick an album, then pick assets to download.", + }, + RequiresLogin: true, +} + +var cmdSharedSubscribe = &commands.FullHandler{ + Name: "shared-subscribe", + Func: fnSharedSubscribe, + Help: commands.HelpMeta{ + Section: HelpSectionSharedStreams, + Description: "Subscribe to a shared album by its album ID so the bridge watches it for new assets.", + Args: "", + }, + RequiresLogin: true, +} + +var cmdSharedSubscribeToken = &commands.FullHandler{ + Name: "shared-subscribe-token", + Func: fnSharedSubscribeToken, + Help: commands.HelpMeta{ + Section: HelpSectionSharedStreams, + Description: "Subscribe to a shared album using the one-time invitation token from an iCloud share URL.", + Args: "", + }, + RequiresLogin: true, +} + +var cmdSharedUnsubscribe = &commands.FullHandler{ + Name: "shared-unsubscribe", + Func: fnSharedUnsubscribe, + Help: commands.HelpMeta{ + Section: HelpSectionSharedStreams, + Description: "Unsubscribe from a shared album by album ID so the bridge stops watching it.", + Args: "", + }, + RequiresLogin: true, +} + +var cmdSharedState = &commands.FullHandler{ + Name: "shared-state", + Func: fnSharedState, + Help: commands.HelpMeta{ + Section: HelpSectionSharedStreams, + Description: "Dump raw Shared Streams state (subscriptions, asset metadata) as JSON — debugging only.", + }, + RequiresLogin: true, +} + +var cmdSharedAssetsJSON = &commands.FullHandler{ + Name: "shared-assets-json", + Func: fnSharedAssetsJSON, + Help: commands.HelpMeta{ + Section: HelpSectionSharedStreams, + Description: "Export full asset metadata as JSON for specific assets in an album — debugging only.", + Args: " ", + }, + RequiresLogin: true, +} + +var cmdSharedDeleteAssets = &commands.FullHandler{ + Name: "shared-delete-assets", + Func: fnSharedDeleteAssets, + Help: commands.HelpMeta{ + Section: HelpSectionSharedStreams, + Description: "Delete specific assets from a shared album by asset GUID.", + Args: " ", + }, + RequiresLogin: true, +} + +// --------------------------------------------------------------------------- +// Step 1: !shared-albums — numbered album picker +// --------------------------------------------------------------------------- + +func fnSharedAlbums(ce *commands.Event) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return + } + + var albums []rustpushgo.SharedAlbumInfo + var err error + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("shared streams client panicked: %v", r) + } + }() + ss, initErr := client.client.GetSharedstreamsClient() + if initErr != nil { + err = initErr + return + } + albums = ss.ListAlbums() + }() + + if err != nil { + ce.Reply("Failed to get shared albums: %v", err) + return + } + + if len(albums) == 0 { + ce.Reply("You are not subscribed to any shared albums.") + return + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("**Shared Albums (%d)**\n\n", len(albums))) + for i, a := range albums { + label := a.Albumguid + if a.Name != nil && *a.Name != "" { + label = *a.Name + } + extra := "" + if a.Email != nil && *a.Email != "" { + extra = fmt.Sprintf(" (shared by %s)", *a.Email) + } + sb.WriteString(fmt.Sprintf("%d. **%s**%s\n", i+1, label, extra)) + } + sb.WriteString("\nReply with a number to browse, or `$cmdprefix cancel` to cancel.") + ce.Reply(sb.String()) + + commands.StoreCommandState(ce.User, &commands.CommandState{ + Action: "select shared album", + Next: commands.MinimalCommandHandlerFunc(func(ce *commands.Event) { + handleAlbumSelection(ce, client, albums) + }), + Cancel: func() {}, + }) +} + +// --------------------------------------------------------------------------- +// Step 2: asset list after album selection +// --------------------------------------------------------------------------- + +func handleAlbumSelection(ce *commands.Event, client *IMClient, albums []rustpushgo.SharedAlbumInfo) { + n, err := strconv.Atoi(strings.TrimSpace(ce.RawArgs)) + if err != nil || n < 1 || n > len(albums) { + ce.Reply("Please reply with a number between 1 and %d, or `$cmdprefix cancel` to cancel.", len(albums)) + return + } + + commands.StoreCommandState(ce.User, nil) + + chosen := albums[n-1] + albumGUID := chosen.Albumguid + albumName := chosen.Albumguid + if chosen.Name != nil && *chosen.Name != "" { + albumName = *chosen.Name + } + + ss, ssOk := sharedStreamsClientFromEvent(ce) + if !ssOk { + return + } + + var assets []rustpushgo.SharedAssetInfo + func() { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("shared streams client panicked: %v", r) + } + }() + assets, err = ss.GetAlbumAssets(albumGUID) + }() + + if err != nil { + ce.Reply("Failed to fetch assets for **%s**: %v", albumName, err) + return + } + if len(assets) == 0 { + ce.Reply("**%s** is empty.", albumName) + return + } + + shown := assets + truncated := false + if len(shown) > 50 { + shown = shown[len(shown)-50:] + truncated = true + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("**%s** \u2014 %d asset(s)", albumName, len(assets))) + if truncated { + sb.WriteString(fmt.Sprintf(" (showing latest 50 of %d)", len(assets))) + } + sb.WriteString("\n\n") + + for i, a := range shown { + dims := "" + if a.Width != "" && a.Height != "" && a.Width != "0" && a.Height != "0" { + dims = fmt.Sprintf(", %s\u00d7%s", a.Width, a.Height) + } + date := "" + if a.DateCreated != "" { + if t, parseErr := time.Parse(time.RFC3339, a.DateCreated); parseErr == nil { + date = fmt.Sprintf(", %s", t.Format("2006-01-02")) + } + } + mediaLabel := a.MediaType + if mediaLabel == "" { + mediaLabel = "file" + } + sb.WriteString(fmt.Sprintf("%d. **%s** (%s%s%s)\n", i+1, a.Filename, mediaLabel, dims, date)) + } + sb.WriteString("\nReply with number(s), a range (1-5), or `all` to download. `$cmdprefix cancel` to cancel.") + ce.Reply(sb.String()) + + commands.StoreCommandState(ce.User, &commands.CommandState{ + Action: "select shared album assets", + Next: commands.MinimalCommandHandlerFunc(func(ce *commands.Event) { + handleAssetSelection(ce, client, ss, albumGUID, albumName, shown) + }), + Cancel: func() {}, + }) +} + +// --------------------------------------------------------------------------- +// Step 3: download selected assets +// --------------------------------------------------------------------------- + +func handleAssetSelection(ce *commands.Event, client *IMClient, ss *rustpushgo.WrappedSharedStreamsClient, albumGUID, albumName string, assets []rustpushgo.SharedAssetInfo) { + commands.StoreCommandState(ce.User, nil) + + input := strings.TrimSpace(ce.RawArgs) + indices, err := parseAssetSelection(input, len(assets)) + if err != nil { + ce.Reply("%v", err) + return + } + + selected := make([]rustpushgo.SharedAssetInfo, 0, len(indices)) + for _, idx := range indices { + selected = append(selected, assets[idx]) + } + + roomID, err := client.getOrCreateAlbumRoom(ce.Ctx, albumGUID, albumName) + if err != nil { + ce.Reply("Failed to create album room: %v", err) + return + } + + ce.Reply("Downloading %d asset(s) from **%s** \u2014 check the dedicated room for progress.", len(selected), albumName) + + // Use background context — ce.Ctx is cancelled when the command handler returns. + go client.downloadSharedAlbumAssets(context.Background(), ss, albumGUID, albumName, selected, roomID) +} + +// parseAssetSelection parses user input like "1", "1,3,5", "1-5", or "all" +// into zero-based indices. +func parseAssetSelection(input string, total int) ([]int, error) { + input = strings.ToLower(strings.TrimSpace(input)) + if input == "all" { + indices := make([]int, total) + for i := range indices { + indices[i] = i + } + return indices, nil + } + + seen := make(map[int]bool) + var indices []int + + for _, part := range strings.Split(input, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if dashIdx := strings.Index(part, "-"); dashIdx > 0 { + startStr := strings.TrimSpace(part[:dashIdx]) + endStr := strings.TrimSpace(part[dashIdx+1:]) + start, err1 := strconv.Atoi(startStr) + end, err2 := strconv.Atoi(endStr) + if err1 != nil || err2 != nil || start < 1 || end < 1 || start > total || end > total { + return nil, fmt.Errorf("Invalid range `%s`. Use numbers between 1 and %d.", part, total) + } + if start > end { + start, end = end, start + } + for i := start; i <= end; i++ { + if !seen[i-1] { + seen[i-1] = true + indices = append(indices, i-1) + } + } + } else { + n, err := strconv.Atoi(part) + if err != nil || n < 1 || n > total { + return nil, fmt.Errorf("Invalid selection `%s`. Use numbers between 1 and %d, ranges (1-5), or `all`.", part, total) + } + if !seen[n-1] { + seen[n-1] = true + indices = append(indices, n-1) + } + } + } + + if len(indices) == 0 { + return nil, fmt.Errorf("No assets selected. Reply with number(s), a range (1-5), or `all`.") + } + return indices, nil +} + +// --------------------------------------------------------------------------- +// Dedicated album room +// --------------------------------------------------------------------------- + +func (c *IMClient) getOrCreateAlbumRoom(ctx context.Context, albumGUID, albumName string) (id.RoomID, error) { + c.sharedAlbumRoomsMu.Lock() + defer c.sharedAlbumRoomsMu.Unlock() + + if roomID, ok := c.sharedAlbumRooms[albumGUID]; ok { + return roomID, nil + } + + displayName := albumName + if displayName == "" || displayName == albumGUID { + displayName = "Shared Album" + } + + roomID, err := c.Main.Bridge.Bot.CreateRoom(ctx, &mautrix.ReqCreateRoom{ + Name: displayName, + Topic: fmt.Sprintf("Shared album download \u2014 %s", displayName), + IsDirect: true, + Preset: "trusted_private_chat", + Invite: []id.UserID{c.UserLogin.User.MXID}, + BeeperAutoJoinInvites: true, + }) + if err != nil { + return "", fmt.Errorf("create room: %w", err) + } + + c.sharedAlbumRooms[albumGUID] = roomID + return roomID, nil +} + +// --------------------------------------------------------------------------- +// Background download loop +// --------------------------------------------------------------------------- + +func (c *IMClient) downloadSharedAlbumAssets(ctx context.Context, ss *rustpushgo.WrappedSharedStreamsClient, albumGUID, albumName string, assets []rustpushgo.SharedAssetInfo, roomID id.RoomID) { + logger := c.Main.Bridge.Log.With(). + Str("component", "shared_album_download"). + Str("album", albumGUID). + Logger() + + intent := c.Main.Bridge.Bot + var failed int + + for i, asset := range assets { + logger.Info(). + Int("index", i+1). + Int("total", len(assets)). + Str("filename", asset.Filename). + Msg("Downloading shared album asset") + + data, err := safeSharedAlbumDownload(ss, albumGUID, asset.Assetguid) + if err != nil { + logger.Warn().Err(err).Str("asset", asset.Assetguid).Msg("Failed to download shared album asset") + failed++ + continue + } + if len(data) == 0 { + logger.Warn().Str("asset", asset.Assetguid).Msg("Shared album asset returned empty data") + failed++ + continue + } + + content := c.processSharedAlbumAsset(ctx, logger, intent, data, asset) + if content == nil { + failed++ + continue + } + + _, sendErr := intent.SendMessage(ctx, roomID, event.EventMessage, &event.Content{ + Parsed: content, + }, nil) + if sendErr != nil { + logger.Warn().Err(sendErr).Str("asset", asset.Assetguid).Msg("Failed to send asset to album room") + failed++ + } + } + + var summary string + if failed == 0 { + summary = fmt.Sprintf("Downloaded all %d asset(s) from **%s**.", len(assets), albumName) + } else { + summary = fmt.Sprintf("Downloaded %d of %d asset(s) from **%s** (%d failed).", len(assets)-failed, len(assets), albumName, failed) + } + + notice := format.RenderMarkdown(summary, true, false) + notice.MsgType = event.MsgNotice + _, _ = intent.SendMessage(ctx, roomID, event.EventMessage, &event.Content{ + Parsed: notice, + }, nil) +} + +// safeSharedAlbumDownload wraps the FFI download with panic recovery and a 90s timeout. +func safeSharedAlbumDownload(ss *rustpushgo.WrappedSharedStreamsClient, albumGUID, assetGUID string) ([]byte, error) { + type dlResult struct { + data []byte + err error + } + ch := make(chan dlResult, 1) + go func() { + var res dlResult + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + log.Error().Str("ffi_method", "SharedAlbumDownloadFile"). + Str("album", albumGUID). + Str("asset", assetGUID). + Str("stack", stack). + Msgf("FFI panic recovered: %v", r) + res = dlResult{err: fmt.Errorf("FFI panic in SharedAlbumDownloadFile: %v", r)} + } + ch <- res + }() + d, e := ss.DownloadFile(albumGUID, assetGUID) + res = dlResult{data: d, err: e} + }() + select { + case res := <-ch: + return res.data, res.err + case <-time.After(90 * time.Second): + log.Error().Str("ffi_method", "SharedAlbumDownloadFile"). + Str("album", albumGUID). + Str("asset", assetGUID). + Msg("SharedAlbumDownloadFile timed out after 90s") + return nil, fmt.Errorf("SharedAlbumDownloadFile timed out after 90s") + } +} + +// processSharedAlbumAsset runs the bridge's standard media pipeline on raw +// downloaded bytes: HEIC→JPEG, video transcoding, thumbnail generation, and +// Matrix upload. +func (c *IMClient) processSharedAlbumAsset(ctx context.Context, logger zerolog.Logger, intent interface { + UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (id.ContentURIString, *event.EncryptedFileInfo, error) +}, data []byte, asset rustpushgo.SharedAssetInfo) *event.MessageEventContent { + fileName := asset.Filename + if fileName == "" { + fileName = "attachment" + } + + // Infer MIME from filename extension + mimeType := extensionToMIME(fileName) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + // Audio conversion: CAF → OGG Opus + var durationMs int + if mimeType == "audio/x-caf" { + data, mimeType, fileName, durationMs = convertAudioForMatrix(data, mimeType, fileName) + } + + // Video transcoding: non-MP4 → MP4 + if c.Main.Config.VideoTranscoding && ffmpeg.Supported() && strings.HasPrefix(mimeType, "video/") && mimeType != "video/mp4" { + origMime := mimeType + method := "remux" + converted, convertErr := ffmpeg.ConvertBytes(ctx, data, ".mp4", nil, + []string{"-c", "copy", "-movflags", "+faststart"}, mimeType) + if convertErr != nil { + method = "re-encode" + converted, convertErr = ffmpeg.ConvertBytes(ctx, data, ".mp4", nil, + []string{"-c:v", "libx264", "-preset", "fast", "-crf", "23", + "-c:a", "aac", "-movflags", "+faststart"}, mimeType) + } + if convertErr != nil { + logger.Warn().Err(convertErr).Str("original_mime", origMime).Msg("FFmpeg video conversion failed, uploading original") + } else { + logger.Info().Str("original_mime", origMime).Str("method", method).Msg("Video transcoded to MP4") + data = converted + mimeType = "video/mp4" + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".mp4" + } + } + + // HEIC → JPEG + var heicImg image.Image + data, mimeType, fileName, heicImg = maybeConvertHEIC(&logger, data, mimeType, fileName, c.Main.Config.HEICJPEGQuality, c.Main.Config.HEICConversion) + + // Image dimension extraction and thumbnail generation + var imgWidth, imgHeight int + var thumbData []byte + var thumbW, thumbH int + if heicImg != nil { + b := heicImg.Bounds() + imgWidth, imgHeight = b.Dx(), b.Dy() + if imgWidth > 800 || imgHeight > 800 { + thumbData, thumbW, thumbH = scaleAndEncodeThumb(heicImg, imgWidth, imgHeight) + } + } else if strings.HasPrefix(mimeType, "image/") || looksLikeImage(data) { + if mimeType == "image/gif" { + if cfg, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + imgWidth, imgHeight = cfg.Width, cfg.Height + } + } else if img, fmtName, _ := decodeImageData(data); img != nil { + b := img.Bounds() + imgWidth, imgHeight = b.Dx(), b.Dy() + if fmtName == "tiff" { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err == nil { + data = buf.Bytes() + mimeType = "image/jpeg" + fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".jpg" + } + } + if imgWidth > 800 || imgHeight > 800 { + thumbData, thumbW, thumbH = scaleAndEncodeThumb(img, imgWidth, imgHeight) + } + } + } + + msgType := mimeToMsgType(mimeType) + content := &event.MessageEventContent{ + MsgType: msgType, + Body: fileName, + Info: &event.FileInfo{ + MimeType: mimeType, + Size: len(data), + Width: imgWidth, + Height: imgHeight, + }, + } + + if durationMs > 0 { + content.MSC3245Voice = &event.MSC3245Voice{} + content.MSC1767Audio = &event.MSC1767Audio{ + Duration: durationMs, + } + } + + url, encFile, uploadErr := intent.UploadMedia(ctx, "", data, fileName, mimeType) + if uploadErr != nil { + logger.Warn().Err(uploadErr).Str("filename", fileName).Msg("Failed to upload shared album asset to Matrix") + return nil + } + if encFile != nil { + content.File = encFile + } else { + content.URL = url + } + + if thumbData != nil { + thumbURL, thumbEnc, thumbErr := intent.UploadMedia(ctx, "", thumbData, "thumbnail.jpg", "image/jpeg") + if thumbErr != nil { + logger.Warn().Err(thumbErr).Msg("Failed to upload asset thumbnail") + } else { + if thumbEnc != nil { + content.Info.ThumbnailFile = thumbEnc + } else { + content.Info.ThumbnailURL = thumbURL + } + content.Info.ThumbnailInfo = &event.FileInfo{ + MimeType: "image/jpeg", + Size: len(thumbData), + Width: thumbW, + Height: thumbH, + } + } + } + + return content +} + +// extensionToMIME maps common file extensions to MIME types. Falls back to +// empty string for unknown extensions. +func extensionToMIME(fileName string) string { + ext := strings.ToLower(filepath.Ext(fileName)) + switch ext { + case ".heic", ".heif": + return "image/heic" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".tiff", ".tif": + return "image/tiff" + case ".webp": + return "image/webp" + case ".mp4", ".m4v": + return "video/mp4" + case ".mov": + return "video/quicktime" + case ".avi": + return "video/x-msvideo" + case ".caf": + return "audio/x-caf" + case ".m4a": + return "audio/mp4" + case ".mp3": + return "audio/mpeg" + case ".aac": + return "audio/aac" + default: + return "" + } +} + +// --------------------------------------------------------------------------- +// Watcher (notify-only, unchanged behaviour) +// --------------------------------------------------------------------------- + +// startSharedStreamsWatcher polls GetChanges() every 10 minutes and posts a +// notice to the management room when subscribed albums receive new content. +// Must be called as a goroutine; exits when c.stopChan is closed. +func (c *IMClient) startSharedStreamsWatcher(log zerolog.Logger) { + // Wait 2 minutes before first poll so the bridge finishes initialising. + select { + case <-c.stopChan: + return + case <-time.After(2 * time.Minute): + } + + if err := c.pollSharedStreams(log); err != nil { + log.Warn().Err(err).Msg("Initial shared streams poll failed") + } + + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for { + select { + case <-c.stopChan: + return + case <-ticker.C: + if err := c.pollSharedStreams(log); err != nil { + log.Warn().Err(err).Msg("Shared streams poll failed") + } + } + } +} + +// pollSharedStreams calls GetChanges and posts a management-room notice for +// each batch of changed albums. Returns the first error encountered, if any. +func (c *IMClient) pollSharedStreams(log zerolog.Logger) (retErr error) { + defer func() { + if r := recover(); r != nil { + retErr = fmt.Errorf("shared streams panic: %v", r) + } + }() + + if c.client == nil { + return nil + } + + ss, err := c.client.GetSharedstreamsClient() + if err != nil { + return fmt.Errorf("get shared streams client: %w", err) + } + + changedAlbums, err := ss.GetChanges() + if err != nil { + return fmt.Errorf("get changes: %w", err) + } + + if len(changedAlbums) == 0 { + return nil + } + + notifyAlbums := make([]string, 0, len(changedAlbums)) + for _, albumID := range changedAlbums { + assets, summaryErr := ss.GetAlbumSummary(albumID) + if summaryErr != nil { + return fmt.Errorf("get album summary for %s: %w", albumID, summaryErr) + } + if c.recordSharedStreamAssets(albumID, assets) { + notifyAlbums = append(notifyAlbums, albumID) + } + } + + if len(notifyAlbums) == 0 { + log.Debug().Strs("albums", changedAlbums).Msg("Shared albums changed, but no new visible assets were added") + return nil + } + + log.Info().Strs("albums", notifyAlbums).Msg("Detected new content in shared albums") + + ctx := context.Background() + mgmtRoom, err := c.UserLogin.User.GetManagementRoom(ctx) + if err != nil { + return fmt.Errorf("get management room: %w", err) + } + + var sb strings.Builder + if len(notifyAlbums) == 1 { + sb.WriteString(fmt.Sprintf("\U0001f4f8 New content in shared album `%s`.", notifyAlbums[0])) + } else { + sb.WriteString(fmt.Sprintf("\U0001f4f8 New content in %d shared albums:\n\n", len(notifyAlbums))) + for _, id := range notifyAlbums { + sb.WriteString(fmt.Sprintf("- `%s`\n", id)) + } + } + sb.WriteString("\n\nUse `!shared-albums` to browse and download.") + + content := format.RenderMarkdown(sb.String(), true, false) + content.MsgType = event.MsgNotice + _, sendErr := c.Main.Bridge.Bot.SendMessage(ctx, mgmtRoom, event.EventMessage, &event.Content{ + Parsed: content, + }, nil) + if sendErr != nil { + return fmt.Errorf("send shared streams notification: %w", sendErr) + } + + return nil +} + +func (c *IMClient) recordSharedStreamAssets(albumID string, assets []string) bool { + current := make(map[string]struct{}, len(assets)) + for _, assetID := range assets { + if assetID != "" { + current[assetID] = struct{}{} + } + } + + c.sharedStreamAssetCacheMu.Lock() + defer c.sharedStreamAssetCacheMu.Unlock() + + previous, hadBaseline := c.sharedStreamAssetCache[albumID] + c.sharedStreamAssetCache[albumID] = current + + if !hadBaseline { + return false + } + for assetID := range current { + if _, seen := previous[assetID]; !seen { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Helpers shared across commands +// --------------------------------------------------------------------------- + +func sharedStreamsClientFromEvent(ce *commands.Event) (*rustpushgo.WrappedSharedStreamsClient, bool) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return nil, false + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return nil, false + } + ss, err := client.client.GetSharedstreamsClient() + if err != nil { + ce.Reply("Failed to initialize shared streams client: %v", err) + return nil, false + } + return ss, true +} + +func fnSharedSubscribe(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!shared-subscribe `") + return + } + ss, ok := sharedStreamsClientFromEvent(ce) + if !ok { + return + } + albumID := strings.TrimSpace(ce.Args[0]) + if err := ss.Subscribe(albumID); err != nil { + ce.Reply("Failed to subscribe to album `%s`: %v", albumID, err) + return + } + ce.Reply("Subscribed to shared album `%s`.", albumID) +} + +func fnSharedSubscribeToken(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!shared-subscribe-token `") + return + } + ss, ok := sharedStreamsClientFromEvent(ce) + if !ok { + return + } + token := strings.TrimSpace(ce.Args[0]) + if err := ss.SubscribeToken(token); err != nil { + ce.Reply("Failed to subscribe using token: %v", err) + return + } + ce.Reply("Subscribed to shared album using token.") +} + +func fnSharedUnsubscribe(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!shared-unsubscribe `") + return + } + ss, ok := sharedStreamsClientFromEvent(ce) + if !ok { + return + } + albumID := strings.TrimSpace(ce.Args[0]) + if err := ss.Unsubscribe(albumID); err != nil { + ce.Reply("Failed to unsubscribe from album `%s`: %v", albumID, err) + return + } + ce.Reply("Unsubscribed from shared album `%s`.", albumID) +} + +func fnSharedState(ce *commands.Event) { + ss, ok := sharedStreamsClientFromEvent(ce) + if !ok { + return + } + state, err := ss.ExportStateJson() + if err != nil { + ce.Reply("Failed to export shared streams state: %v", err) + return + } + if len(state) > 12000 { + state = state[:12000] + "\n... (truncated)" + } + ce.Reply("```json\n%s\n```", state) +} + +func fnSharedAssetsJSON(ce *commands.Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `!shared-assets-json `") + return + } + ss, ok := sharedStreamsClientFromEvent(ce) + if !ok { + return + } + albumID := strings.TrimSpace(ce.Args[0]) + assets := parseListArgs(ce.Args[1:]) + if len(assets) == 0 { + ce.Reply("Please provide at least one asset GUID.") + return + } + assetsJSON, err := ss.GetAssetsJson(albumID, assets) + if err != nil { + ce.Reply("Failed to fetch assets JSON: %v", err) + return + } + if len(assetsJSON) > 12000 { + assetsJSON = assetsJSON[:12000] + "\n... (truncated)" + } + ce.Reply("```json\n%s\n```", assetsJSON) +} + +func fnSharedDeleteAssets(ce *commands.Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `!shared-delete-assets `") + return + } + ss, ok := sharedStreamsClientFromEvent(ce) + if !ok { + return + } + albumID := strings.TrimSpace(ce.Args[0]) + assets := parseListArgs(ce.Args[1:]) + if len(assets) == 0 { + ce.Reply("Please provide at least one asset GUID.") + return + } + if err := ss.DeleteAssets(albumID, assets); err != nil { + ce.Reply("Failed to delete assets: %v", err) + return + } + ce.Reply("Deleted %d asset(s) from `%s`.", len(assets), albumID) +} diff --git a/pkg/connector/statuskit_commands.go b/pkg/connector/statuskit_commands.go new file mode 100644 index 00000000..150677fd --- /dev/null +++ b/pkg/connector/statuskit_commands.go @@ -0,0 +1,318 @@ +// mautrix-imessage - A Matrix-iMessage puppeting bridge. +// Copyright (C) 2024 Ludvig Rhodin +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "fmt" + "strconv" + "strings" + + "maunium.net/go/mautrix/bridgev2/commands" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +// wellKnownFocusModes lists the standard iOS Focus/DND mode identifiers in a +// user-friendly order. Custom modes use opaque UUIDs and can still be passed +// directly as a raw argument. +var wellKnownFocusModes = []struct { + ID string + Label string +}{ + {"com.apple.donotdisturb.mode.default", "Do Not Disturb"}, + {"com.apple.donotdisturb.mode.sleep", "Sleep"}, + {"com.apple.focus.mode.driving", "Driving"}, + {"com.apple.focus.mode.personal", "Personal"}, + {"com.apple.focus.mode.work", "Work"}, +} + +var cmdStatuskitState = &commands.FullHandler{ + Name: "statuskit-state", + Func: fnStatuskitState, + Help: commands.HelpMeta{ + Section: HelpSectionStatusKit, + Description: "Dump raw StatusKit client state (channels, keys, interest tokens) as JSON — debugging only.", + }, + RequiresLogin: true, +} + +var cmdStatuskitShare = &commands.FullHandler{ + Name: "statuskit-share", + Func: fnStatuskitShare, + Help: commands.HelpMeta{ + Section: HelpSectionStatusKit, + Description: "Publish your StatusKit shared-status. Use `on` to mark yourself available; use `off` to pick a Focus mode from a list.", + Args: "on | off", + }, + RequiresLogin: true, +} + +var cmdStatuskitResetKeys = &commands.FullHandler{ + Name: "statuskit-reset-keys", + Func: fnStatuskitResetKeys, + Help: commands.HelpMeta{ + Section: HelpSectionStatusKit, + Description: "Wipe local StatusKit keys; the next publish will mint a fresh keyset.", + }, + RequiresLogin: true, +} + +var cmdStatuskitRollKeys = &commands.FullHandler{ + Name: "statuskit-roll-keys", + Func: fnStatuskitRollKeys, + Help: commands.HelpMeta{ + Section: HelpSectionStatusKit, + Description: "Rotate local StatusKit keys and reshare them with invited handles.", + }, + RequiresLogin: true, +} + +var cmdStatuskitRequestHandles = &commands.FullHandler{ + Name: "statuskit-request-handles", + Func: fnStatuskitRequestHandles, + Help: commands.HelpMeta{ + Section: HelpSectionStatusKit, + Description: "Ask Apple to push StatusKit updates for each listed handle.", + Args: "", + }, + RequiresLogin: true, +} + +var cmdStatuskitClearInterest = &commands.FullHandler{ + Name: "statuskit-clear-interest", + Func: fnStatuskitClearInterest, + Help: commands.HelpMeta{ + Section: HelpSectionStatusKit, + Description: "Drop all StatusKit interest tokens so Apple re-pushes them on the next subscribe.", + }, + RequiresLogin: true, +} + +var cmdStatuskitInviteToChannel = &commands.FullHandler{ + Name: "statuskit-invite-channel", + Func: fnStatuskitInviteToChannel, + Help: commands.HelpMeta{ + Section: HelpSectionStatusKit, + Description: "Invite handles to your StatusKit channel so they can decrypt your shared presence; optional =mode1|mode2 per handle.", + Args: " ", + }, + RequiresLogin: true, +} + +func statusKitClientFromEvent(ce *commands.Event) (*rustpushgo.WrappedStatusKitClient, bool) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("No active login found.") + return nil, false + } + client, ok := login.Client.(*IMClient) + if !ok || client == nil || client.client == nil { + ce.Reply("Bridge client not available.") + return nil, false + } + sk, err := client.client.GetStatuskitClient() + if err != nil { + ce.Reply("Failed to initialize StatusKit client: %v", err) + return nil, false + } + return sk, true +} + +func parseBoolish(value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "on", "yes", "y", "active": + return true, nil + case "0", "false", "off", "no", "n", "inactive": + return false, nil + default: + return false, fmt.Errorf("invalid boolean %q", value) + } +} + +func fnStatuskitState(ce *commands.Event) { + sk, ok := statusKitClientFromEvent(ce) + if !ok { + return + } + state, err := sk.ExportStateJson() + if err != nil { + ce.Reply("Failed to export StatusKit state: %v", err) + return + } + if len(state) > 12000 { + state = state[:12000] + "\n... (truncated)" + } + ce.Reply("```json\n%s\n```", state) +} + +func fnStatuskitShare(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `$cmdprefix statuskit-share `") + return + } + sk, ok := statusKitClientFromEvent(ce) + if !ok { + return + } + active, err := parseBoolish(ce.Args[0]) + if err != nil { + ce.Reply("%v", err) + return + } + + // "on" path — no mode needed. + if active { + if err := sk.ShareStatus(true, nil); err != nil { + ce.Reply("Failed to publish StatusKit share status: %v", err) + return + } + ce.Reply("Published StatusKit share state: available") + return + } + + // "off" — show numbered list of Focus modes. + var sb strings.Builder + sb.WriteString("Select a Focus mode:\n\n") + for i, m := range wellKnownFocusModes { + fmt.Fprintf(&sb, "%d. **%s**\n", i+1, m.Label) + } + sb.WriteString("\nReply with a number to select, or `$cmdprefix cancel` to cancel.") + ce.Reply(sb.String()) + + commands.StoreCommandState(ce.User, &commands.CommandState{ + Action: "select focus mode", + Next: commands.MinimalCommandHandlerFunc(func(ce *commands.Event) { + input := strings.TrimSpace(ce.RawArgs) + + n, err := strconv.Atoi(input) + if err != nil || n < 1 || n > len(wellKnownFocusModes) { + ce.Reply("Please reply with a number between 1 and %d, or `$cmdprefix cancel` to cancel.", len(wellKnownFocusModes)) + return + } + mode := wellKnownFocusModes[n-1].ID + + commands.StoreCommandState(ce.User, nil) + + sk, ok := statusKitClientFromEvent(ce) + if !ok { + return + } + if err := sk.ShareStatus(false, &mode); err != nil { + ce.Reply("Failed to publish StatusKit share status: %v", err) + return + } + label := mode + for _, m := range wellKnownFocusModes { + if m.ID == mode { + label = m.Label + break + } + } + ce.Reply("Published StatusKit share state: away (%s)", label) + }), + }) +} + +func fnStatuskitResetKeys(ce *commands.Event) { + sk, ok := statusKitClientFromEvent(ce) + if !ok { + return + } + sk.ResetKeys() + ce.Reply("Reset StatusKit keys.") +} + +func fnStatuskitRollKeys(ce *commands.Event) { + sk, ok := statusKitClientFromEvent(ce) + if !ok { + return + } + sk.RollKeys() + ce.Reply("Rotated StatusKit keys.") +} + +func fnStatuskitRequestHandles(ce *commands.Event) { + if len(ce.Args) < 1 { + ce.Reply("Usage: `!statuskit-request-handles `") + return + } + sk, ok := statusKitClientFromEvent(ce) + if !ok { + return + } + handles := parseListArgs(ce.Args) + if len(handles) == 0 { + ce.Reply("Please provide at least one handle.") + return + } + sk.RequestHandles(handles) + ce.Reply("Requested StatusKit updates for %d handle(s).", len(handles)) +} + +func fnStatuskitClearInterest(ce *commands.Event) { + sk, ok := statusKitClientFromEvent(ce) + if !ok { + return + } + sk.ClearInterestTokens() + ce.Reply("Cleared StatusKit interest tokens.") +} + +func fnStatuskitInviteToChannel(ce *commands.Event) { + if len(ce.Args) < 2 { + ce.Reply("Usage: `!statuskit-invite-channel `") + return + } + sk, ok := statusKitClientFromEvent(ce) + if !ok { + return + } + sender := strings.TrimSpace(ce.Args[0]) + targetParts := parseListArgs(ce.Args[1:]) + if len(targetParts) == 0 { + ce.Reply("Please provide at least one invite handle.") + return + } + invites := make([]rustpushgo.WrappedStatusKitInviteHandle, 0, len(targetParts)) + for _, part := range targetParts { + handle := part + allowedModes := []string{} + if eq := strings.Index(part, "="); eq > 0 { + handle = strings.TrimSpace(part[:eq]) + modes := strings.Split(part[eq+1:], "|") + for _, m := range modes { + m = strings.TrimSpace(m) + if m != "" { + allowedModes = append(allowedModes, m) + } + } + } + if handle == "" { + continue + } + invites = append(invites, rustpushgo.WrappedStatusKitInviteHandle{Handle: handle, AllowedModes: allowedModes}) + } + if len(invites) == 0 { + ce.Reply("No valid invite handles provided.") + return + } + if err := sk.InviteToChannel(sender, invites); err != nil { + ce.Reply("Failed to invite handles to StatusKit channel: %v", err) + return + } + ce.Reply("Invited %d handle(s) to StatusKit channel.", len(invites)) +} diff --git a/pkg/connector/sync_controller.go b/pkg/connector/sync_controller.go new file mode 100644 index 00000000..14ca03c1 --- /dev/null +++ b/pkg/connector/sync_controller.go @@ -0,0 +1,3074 @@ +package connector + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "runtime/debug" + "sort" + "strings" + "sync/atomic" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/simplevent" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + + "github.com/lrhodin/imessage/pkg/rustpushgo" +) + +// No periodic polling needed: real-time messages arrive via APNs push +// on com.apple.madrid. CloudKit sync is only used for initial backfill +// of historical messages (bootstrap). + +// cloudSyncVersion is bumped when sync logic changes in a way that +// requires a one-time full re-download from CloudKit (e.g. improved PCS +// key handling that can now decrypt previously-skipped records). +// Increment this to trigger a token clear on the next bootstrap. +const cloudSyncVersion = 4 + +// maxCloudSyncPages is the upper bound on CloudKit pagination loops. +// Each CloudKit zone sync (messages, chats, attachments) breaks after this many +// pages so a runaway response can never loop forever. +const maxCloudSyncPages = 10000 + +// cloudChatSyncVersion is bumped when chat-specific sync logic changes in a +// way that requires a one-time full re-download of the chatManateeZone only +// (not messages). This avoids the expensive message re-download that +// bumping cloudSyncVersion would cause. +// Current bump: 2 — populate group_photo_guid for all group chats. +const cloudChatSyncVersion = 2 + +type cloudSyncCounters struct { + Imported int + Updated int + Skipped int + Deleted int + Filtered int +} + +func (c *cloudSyncCounters) add(other cloudSyncCounters) { + c.Imported += other.Imported + c.Updated += other.Updated + c.Skipped += other.Skipped + c.Deleted += other.Deleted + c.Filtered += other.Filtered +} + +func (c *IMClient) setCloudSyncDone() { + c.cloudSyncDoneLock.Lock() + c.cloudSyncDone = true + c.cloudSyncDoneLock.Unlock() + + // Flush the APNs reorder buffer once all forward backfills are complete. + // Messages accumulated during CloudKit sync to avoid interleaving APNs + // messages before older CloudKit messages in Matrix. + // + // If there are pending initial backfills (portals queued by the bootstrap + // createPortalsFromCloudSync pass), we wait for each FetchMessages call + // to complete and deliver its batch to Matrix (via CompleteCallback) before + // flushing. This ensures APNs messages appear AFTER the CloudKit history, + // not interleaved with it. + // + // If no portals were queued (fresh sync with no history, or all portals + // already up-to-date), flush immediately. + pending := atomic.LoadInt64(&c.pendingInitialBackfills) + if pending <= 0 { + log.Info().Msg("No pending initial backfills — flushing APNs buffer immediately") + atomic.StoreInt64(&c.apnsBufferFlushedAt, time.Now().UnixMilli()) + if c.msgBuffer != nil { + c.msgBuffer.flush() + } + c.flushPendingPortalMsgs() + return + } + + log.Info().Int64("pending", pending). + Msg("Waiting for initial forward backfills before flushing APNs buffer") + + // Safety-net goroutine: if some FetchMessages calls never complete (e.g. + // portal deleted before bridgev2 processes the ChatResync event, or a + // crash/panic inside FetchMessages), force-flush the buffer so APNs + // messages are never permanently suppressed. + // + // Uses an activity-based deadline rather than a fixed one: the timer + // resets every time any forward backfill completes. This lets slow + // hardware (or large accounts) take as long as needed, while still + // eventually force-flushing if nothing makes progress for 5 minutes + // (i.e. a portal is genuinely stuck and will never finish). + go func() { + const noProgressTimeout = 5 * time.Minute + lastCount := atomic.LoadInt64(&c.pendingInitialBackfills) + deadline := time.Now().Add(noProgressTimeout) + for time.Now().Before(deadline) { + if atomic.LoadInt64(&c.pendingInitialBackfills) <= 0 { + // onForwardBackfillDone already flushed the buffer. + return + } + time.Sleep(250 * time.Millisecond) + if current := atomic.LoadInt64(&c.pendingInitialBackfills); current < lastCount { + // Progress was made — reset the no-progress deadline. + lastCount = current + deadline = time.Now().Add(noProgressTimeout) + } + } + remaining := atomic.LoadInt64(&c.pendingInitialBackfills) + log.Warn().Int64("remaining", remaining). + Msg("APNs buffer flush timeout: no forward backfill progress in 5 minutes, forcing flush") + // Mirror what onForwardBackfillDone does: stamp the flush time so the + // read-receipt grace window (handleReadReceipt) knows the burst is done. + atomic.StoreInt64(&c.apnsBufferFlushedAt, time.Now().UnixMilli()) + if c.msgBuffer != nil { + c.msgBuffer.flush() + } + c.flushPendingPortalMsgs() + }() +} + +func (c *IMClient) isCloudSyncDone() bool { + c.cloudSyncDoneLock.RLock() + defer c.cloudSyncDoneLock.RUnlock() + return c.cloudSyncDone +} + +// recentlyDeletedPortalsTTL is how long entries stay in recentlyDeletedPortals +// before being pruned. 24 hours is generous — tombstones are re-delivered on +// every CloudKit sync so short-lived entries are repopulated anyway. +const recentlyDeletedPortalsTTL = 24 * time.Hour + +// trackDeletedChat adds a portal ID to the recently-deleted set so stale APNs +// echoes and CloudKit sync don't recreate the portal. +func (c *IMClient) trackDeletedChat(portalID string) { + c.recentlyDeletedPortalsMu.Lock() + if c.recentlyDeletedPortals == nil { + c.recentlyDeletedPortals = make(map[string]deletedPortalEntry) + } + entry := deletedPortalEntry{deletedAt: time.Now()} + c.recentlyDeletedPortals[portalID] = entry + // For gid: portals, also track all other portal IDs that share the same + // group UUID. CloudKit chat_id UUIDs can differ from group_id UUIDs, + // creating multiple portal_id variants for the same group. Without + // tracking all variants, createPortalsFromCloudSync recreates the + // portal under the alternate UUID on restart. + if strings.HasPrefix(portalID, "gid:") && c.cloudStore != nil { + uuid := strings.TrimPrefix(portalID, "gid:") + ctx := context.Background() + if aliases, err := c.cloudStore.findPortalIDsByGroupID(ctx, uuid); err == nil { + for _, alias := range aliases { + if alias != portalID { + c.recentlyDeletedPortals[alias] = entry + } + } + } + } + c.recentlyDeletedPortalsMu.Unlock() +} + +// pruneRecentlyDeletedPortals removes entries older than the TTL. +// Called after bootstrap sync to prevent unbounded memory growth. +func (c *IMClient) pruneRecentlyDeletedPortals(log zerolog.Logger) { + c.recentlyDeletedPortalsMu.Lock() + defer c.recentlyDeletedPortalsMu.Unlock() + if len(c.recentlyDeletedPortals) == 0 { + return + } + cutoff := time.Now().Add(-recentlyDeletedPortalsTTL) + pruned := 0 + for portalID, entry := range c.recentlyDeletedPortals { + if entry.deletedAt.Before(cutoff) { + delete(c.recentlyDeletedPortals, portalID) + pruned++ + } + } + if pruned > 0 { + log.Info().Int("pruned", pruned).Int("remaining", len(c.recentlyDeletedPortals)). + Msg("Pruned stale entries from recentlyDeletedPortals") + } +} + +// seedDeletedChatsFromRecycleBin reads recoverable chat/message data from +// Apple's recycle-bin zones, classifies which portals still look deleted, and +// also auto-recovers portals that were previously soft-deleted locally but +// whose current CloudKit state now looks active again. +func (c *IMClient) seedDeletedChatsFromRecycleBin(log zerolog.Logger) { + if c.client == nil || c.cloudStore == nil { + return + } + // ListRecoverableChats / ListRecoverableMessageGuids cross into the + // CloudKit FFI path, which has reachable panic sites upstream + // (cloudkit.rs type assertions). A leaf-level recover here lets the + // sync controller goroutine continue to setCloudSyncDone() even if one + // seed pass panics — unblocking the APNs buffer is more important than + // crashing the whole process over a seed-pass failure. + defer func() { + if r := recover(); r != nil { + log.Error().Interface("panic", r).Msg("seedDeletedChatsFromRecycleBin panicked — skipped this pass") + } + }() + + ctx := context.Background() + candidateMap := make(map[string]recycleBinCandidate) + seeded := 0 + isRestoreOverridden := func(portalID string) bool { + overridden, err := c.cloudStore.hasRestoreOverride(ctx, portalID) + if err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("DELETE-SEED: failed to check restore override") + return false + } + return overridden + } + + log.Info().Msg("DELETE-SEED: reading recoverable chats from Apple's recycle bin") + recoverableChats, chatErr := c.client.ListRecoverableChats() + if chatErr != nil { + log.Warn().Err(chatErr).Msg("DELETE-SEED: failed to read recoverable chats") + } else { + log.Info().Int("recoverable_chats", len(recoverableChats)). + Msg("DELETE-SEED: read recoverable chats, matching against local portal IDs") + for _, chat := range recoverableChats { + portalID := c.resolvePortalIDForCloudChat(chat.Participants, chat.DisplayName, chat.GroupId, chat.Style) + if portalID == "" { + log.Debug(). + Str("record_name", chat.RecordName). + Str("cloud_chat_id", chat.CloudChatId). + Str("group_id", chat.GroupId). + Strs("participants", chat.Participants). + Msg("DELETE-SEED: skipping recoverable chat with unresolved portal ID") + continue + } + if isRestoreOverridden(portalID) { + if undeleted, err := c.cloudStore.undeleteCloudMessagesByPortalID(ctx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("DELETE-SEED: failed to honor restore override for recoverable chat") + } else { + c.recentlyDeletedPortalsMu.Lock() + delete(c.recentlyDeletedPortals, portalID) + c.recentlyDeletedPortalsMu.Unlock() + log.Info(). + Str("portal_id", portalID). + Int("undeleted_messages", undeleted). + Msg("DELETE-SEED: skipping recoverable chat tombstone because user restored it manually") + } + continue + } + + if hasChat, err := c.cloudStore.portalHasChat(ctx, portalID); err == nil && hasChat { + c.recentlyDeletedPortalsMu.Lock() + delete(c.recentlyDeletedPortals, portalID) + c.recentlyDeletedPortalsMu.Unlock() + log.Info(). + Str("portal_id", portalID). + Str("record_name", chat.RecordName). + Msg("DELETE-SEED: recoverable chat already has live CloudKit row, skipping tombstone seed") + continue + } else if err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("DELETE-SEED: failed to check live chat state for recoverable chat") + } + + // If this portal already has messages in the main CloudKit zone (stored + // during Phase 2 message sync), it is a recycle-bin-only chat whose + // messages appeared in messageManateeZone — not a true deletion. Seed a + // live cloud_chat row so createPortalsFromCloudSync picks it up, and skip + // the tombstone entirely. This fixes fresh-backfill for chats that were + // deleted before the bridge's first ever sync (they appear in the recycle + // bin zone but their messages are still in the main zone). + if hasMessages, msgErr := c.cloudStore.hasPortalMessages(ctx, portalID); msgErr == nil && hasMessages { + dn := "" + if chat.DisplayName != nil { + dn = *chat.DisplayName + } + c.cloudStore.seedChatFromRecycleBin(ctx, portalID, chat.CloudChatId, chat.GroupId, dn, "", chat.Participants) + log.Info(). + Str("portal_id", portalID). + Str("record_name", chat.RecordName). + Msg("DELETE-SEED: recycle-bin chat has main-zone messages, seeding live chat row instead of tombstone") + continue + } else if msgErr != nil { + log.Warn().Err(msgErr).Str("portal_id", portalID). + Msg("DELETE-SEED: failed to check portal messages for recoverable chat") + } + + participantsJSON, err := json.Marshal(chat.Participants) + if err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("DELETE-SEED: failed to serialize recoverable chat participants") + continue + } + if err := c.cloudStore.insertDeletedChatTombstone( + ctx, + chat.CloudChatId, + portalID, + chat.RecordName, + chat.GroupId, + chat.Service, + chat.DisplayName, + string(participantsJSON), + ); err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("DELETE-SEED: failed to persist recoverable chat tombstone") + continue + } + if err := c.cloudStore.deleteLocalChatByPortalID(ctx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("DELETE-SEED: failed to soft-delete local data for recoverable chat") + } + + c.recentlyDeletedPortalsMu.Lock() + if c.recentlyDeletedPortals == nil { + c.recentlyDeletedPortals = make(map[string]deletedPortalEntry) + } + if _, exists := c.recentlyDeletedPortals[portalID]; !exists { + c.recentlyDeletedPortals[portalID] = deletedPortalEntry{ + deletedAt: time.Now(), + isTombstone: true, + } + seeded++ + } + c.recentlyDeletedPortalsMu.Unlock() + + portalKey := networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: c.UserLogin.ID} + name := friendlyPortalName(ctx, c.Main.Bridge, c, portalKey, portalID) + isGroup := strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") + if chat.DisplayName != nil && *chat.DisplayName != "" && (name == portalID || name == strings.TrimPrefix(strings.TrimPrefix(portalID, "mailto:"), "tel:") || (isGroup && strings.HasPrefix(name, "Group "))) { + name = *chat.DisplayName + } + if isGroup && strings.HasPrefix(name, "Group ") && len(chat.Participants) > 0 { + normalized := make([]string, 0, len(chat.Participants)) + for _, p := range chat.Participants { + if n := normalizeIdentifierForPortalID(p); n != "" { + normalized = append(normalized, n) + } + } + if len(normalized) > 0 { + if built := c.buildGroupName(normalized); built != "" && built != "Group Chat" { + name = built + } + } + } + candidateKey := groupPortalDedupKey(portalID, chat.GroupId, chat.Participants) + candidateMap[candidateKey] = recycleBinCandidate{ + portalID: portalID, + displayName: name, + } + log.Info(). + Str("portal_id", portalID). + Str("record_name", chat.RecordName). + Str("name", name). + Msg("DELETE-SEED: seeded deleted chat from recoverable chat identity") + } + } + + log.Info().Msg("DELETE-SEED: reading recoverable message GUIDs from Apple's recycle bin") + guids, err := c.client.ListRecoverableMessageGuids() + if err != nil { + log.Warn().Err(err).Msg("DELETE-SEED: failed to read recoverable message GUIDs") + } else if len(guids) == 0 { + log.Info().Msg("DELETE-SEED: no recoverable messages found, nothing to seed") + } else { + log.Info().Int("recoverable_guids", len(guids)). + Msg("DELETE-SEED: read recoverable message GUIDs, matching against cloud_message") + portalHints := c.buildRecoverableMessagePortalHints(ctx, guids) + if len(portalHints) > 0 { + log.Info().Int("portal_hints", len(portalHints)). + Msg("DELETE-SEED: derived portal hints from recoverable message metadata") + } + + states, err := c.cloudStore.classifyRecycleBinPortals(ctx, guids) + if err != nil { + log.Warn().Err(err).Msg("DELETE-SEED: failed to match GUIDs against cloud_message") + } else if len(states) == 0 { + log.Info().Int("recoverable_guids", len(guids)). + Msg("DELETE-SEED: no portals matched recoverable messages") + } else { + var deletedStates []recycleBinPortalState + autoRecovered := 0 + for _, state := range states { + if state.NeedsUndelete() { + undeleted, err := c.cloudStore.undeleteCloudMessagesByPortalID(ctx, state.PortalID) + if err != nil { + log.Warn().Err(err).Str("portal_id", state.PortalID). + Msg("DELETE-SEED: failed to auto-undelete restored portal") + } else { + c.recentlyDeletedPortalsMu.Lock() + delete(c.recentlyDeletedPortals, state.PortalID) + c.recentlyDeletedPortalsMu.Unlock() + log.Info(). + Str("portal_id", state.PortalID). + Int("undeleted_messages", undeleted). + Int("recoverable", state.Recoverable). + Int("recoverable_suffix", state.RecoverableSuffix). + Msg("DELETE-SEED: auto-recovered restored portal before backfill") + autoRecovered++ + } + } + if state.LooksDeleted() { + if hasChat, err := c.cloudStore.portalHasChat(ctx, state.PortalID); err != nil { + log.Warn().Err(err).Str("portal_id", state.PortalID). + Msg("DELETE-SEED: failed to check live chat state for recycle-bin match") + } else if hasChat { + c.recentlyDeletedPortalsMu.Lock() + delete(c.recentlyDeletedPortals, state.PortalID) + c.recentlyDeletedPortalsMu.Unlock() + log.Info(). + Str("portal_id", state.PortalID). + Int("recoverable", state.Recoverable). + Int("recoverable_suffix", state.RecoverableSuffix). + Msg("DELETE-SEED: live CloudKit chat row wins over recycle-bin message history") + continue + } + if isRestoreOverridden(state.PortalID) { + if undeleted, err := c.cloudStore.undeleteCloudMessagesByPortalID(ctx, state.PortalID); err != nil { + log.Warn().Err(err).Str("portal_id", state.PortalID). + Msg("DELETE-SEED: failed to honor restore override for recycle-bin match") + } else { + c.recentlyDeletedPortalsMu.Lock() + delete(c.recentlyDeletedPortals, state.PortalID) + c.recentlyDeletedPortalsMu.Unlock() + log.Info(). + Str("portal_id", state.PortalID). + Int("undeleted_messages", undeleted). + Msg("DELETE-SEED: skipping recycle-bin delete block because user restored it manually") + } + continue + } + deletedStates = append(deletedStates, state) + } + } + if autoRecovered > 0 { + log.Info().Int("auto_recovered", autoRecovered). + Msg("DELETE-SEED: restored portals were undeleted locally before portal creation") + } + if len(deletedStates) == 0 { + log.Info().Int("recoverable_guids", len(guids)). + Msg("DELETE-SEED: no portals currently look deleted after reconciliation") + } else { + deletedPortalIDs := make([]string, 0, len(deletedStates)) + for _, state := range deletedStates { + deletedPortalIDs = append(deletedPortalIDs, state.PortalID) + } + log.Info().Int("deleted_portals", len(deletedStates)). + Strs("portal_ids", deletedPortalIDs). + Msg("DELETE-SEED: found portals whose newest messages are still in Apple's recycle bin") + + // Mark these portals as deleted in memory + DB, and store candidates + // for the post-backfill bridgebot notification. + c.recentlyDeletedPortalsMu.Lock() + if c.recentlyDeletedPortals == nil { + c.recentlyDeletedPortals = make(map[string]deletedPortalEntry) + } + for _, state := range deletedStates { + portalID := state.PortalID + if _, exists := c.recentlyDeletedPortals[portalID]; exists { + continue + } + if err := c.cloudStore.ensureDeletedChatTombstoneByPortalID(ctx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("DELETE-SEED: failed to persist tombstone for blocked portal") + } + c.recentlyDeletedPortals[portalID] = deletedPortalEntry{ + deletedAt: time.Now(), + isTombstone: true, + } + if err := c.cloudStore.deleteLocalChatByPortalID(ctx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("DELETE-SEED: failed to soft-delete local data for seeded portal") + } + portalKey := networkid.PortalKey{ID: networkid.PortalID(portalID), Receiver: c.UserLogin.ID} + name := friendlyPortalName(ctx, c.Main.Bridge, c, portalKey, portalID) + stateGroupID := "" + if strings.HasPrefix(portalID, "gid:") { + stateGroupID = c.cloudStore.getGroupIDForPortalID(ctx, portalID) + } + candidateKey := groupPortalDedupKey(portalID, stateGroupID, nil) + candidateMap[candidateKey] = recycleBinCandidate{ + portalID: portalID, + displayName: name, + recoverable: state.Recoverable, + total: state.Total, + } + log.Info(). + Str("portal_id", portalID). + Str("name", name). + Int("recoverable", state.Recoverable). + Int("recoverable_suffix", state.RecoverableSuffix). + Int("total", state.Total). + Msg("DELETE-SEED: blocked portal — latest messages are still in Apple's recycle bin") + seeded++ + } + c.recentlyDeletedPortalsMu.Unlock() + } + } + + hintedSeeded := 0 + for _, hint := range portalHints { + hintGroupID := "" + if strings.HasPrefix(hint.PortalID, "gid:") { + // Cross-reference cloud_chat to find the real group_id. + // The portal ID's UUID might be a chat_id, not the group_id. + if resolved := c.cloudStore.getGroupIDForPortalID(ctx, hint.PortalID); resolved != "" { + hintGroupID = resolved + } else { + hintGroupID = strings.TrimPrefix(hint.PortalID, "gid:") + } + } + candidateKey := groupPortalDedupKey(hint.PortalID, hintGroupID, hint.Participants) + if _, exists := candidateMap[candidateKey]; exists { + continue + } + if isRestoreOverridden(hint.PortalID) { + if undeleted, err := c.cloudStore.undeleteCloudMessagesByPortalID(ctx, hint.PortalID); err != nil { + log.Warn().Err(err).Str("portal_id", hint.PortalID). + Msg("DELETE-SEED: failed to honor restore override for recoverable message hint") + } else { + c.recentlyDeletedPortalsMu.Lock() + delete(c.recentlyDeletedPortals, hint.PortalID) + c.recentlyDeletedPortalsMu.Unlock() + log.Info(). + Str("portal_id", hint.PortalID). + Int("undeleted_messages", undeleted). + Msg("DELETE-SEED: skipping recoverable message hint because user restored it manually") + } + continue + } + + hasChat, err := c.cloudStore.portalHasChat(ctx, hint.PortalID) + if err != nil { + log.Warn().Err(err).Str("portal_id", hint.PortalID). + Msg("DELETE-SEED: failed to check live chat state for recoverable message hint") + continue + } + if hasChat { + c.recentlyDeletedPortalsMu.Lock() + delete(c.recentlyDeletedPortals, hint.PortalID) + c.recentlyDeletedPortalsMu.Unlock() + log.Debug(). + Str("portal_id", hint.PortalID). + Int("recoverable", hint.Count). + Msg("DELETE-SEED: recoverable message hint already has live CloudKit row, skipping tombstone seed") + continue + } + + softDeletedInfo, infoErr := c.cloudStore.getSoftDeletedPortalInfo(ctx, hint.PortalID) + if infoErr != nil { + log.Warn().Err(infoErr).Str("portal_id", hint.PortalID). + Msg("DELETE-SEED: failed to inspect local deleted state for recoverable message hint") + continue + } + if !softDeletedInfo.Deleted && hint.Count < 2 { + log.Debug(). + Str("portal_id", hint.PortalID). + Int("recoverable", hint.Count). + Msg("DELETE-SEED: skipping weak recoverable message hint without local deleted state") + continue + } + + participantsJSON, marshalErr := json.Marshal(hint.Participants) + if marshalErr != nil { + log.Warn().Err(marshalErr).Str("portal_id", hint.PortalID). + Msg("DELETE-SEED: failed to serialize recoverable message participants") + continue + } + + cloudChatID := hint.CloudChatID + if cloudChatID == "" { + cloudChatID = "synthetic:recoverable:" + hint.PortalID + } + groupID := "" + if strings.HasPrefix(hint.PortalID, "gid:") { + groupID = strings.TrimPrefix(hint.PortalID, "gid:") + } + if err = c.cloudStore.insertDeletedChatTombstone( + ctx, + cloudChatID, + hint.PortalID, + "", + groupID, + hint.Service, + nil, + string(participantsJSON), + ); err != nil { + log.Warn().Err(err).Str("portal_id", hint.PortalID). + Msg("DELETE-SEED: failed to persist recoverable message tombstone") + continue + } + if err = c.cloudStore.deleteLocalChatByPortalID(ctx, hint.PortalID); err != nil { + log.Warn().Err(err).Str("portal_id", hint.PortalID). + Msg("DELETE-SEED: failed to soft-delete local data for recoverable message hint") + } + + added := false + c.recentlyDeletedPortalsMu.Lock() + if c.recentlyDeletedPortals == nil { + c.recentlyDeletedPortals = make(map[string]deletedPortalEntry) + } + if _, exists := c.recentlyDeletedPortals[hint.PortalID]; !exists { + c.recentlyDeletedPortals[hint.PortalID] = deletedPortalEntry{ + deletedAt: time.Now(), + isTombstone: true, + } + added = true + } + c.recentlyDeletedPortalsMu.Unlock() + if added { + seeded++ + hintedSeeded++ + } + + portalKey := networkid.PortalKey{ID: networkid.PortalID(hint.PortalID), Receiver: c.UserLogin.ID} + name := friendlyPortalName(ctx, c.Main.Bridge, c, portalKey, hint.PortalID) + candidateMap[candidateKey] = recycleBinCandidate{ + portalID: hint.PortalID, + displayName: name, + recoverable: hint.Count, + total: hint.Count, + } + log.Info(). + Str("portal_id", hint.PortalID). + Str("name", name). + Int("recoverable", hint.Count). + Str("cloud_chat_id", hint.CloudChatID). + Msg("DELETE-SEED: seeded deleted chat from recoverable message metadata") + } + if hintedSeeded > 0 { + log.Info().Int("seeded", hintedSeeded). + Msg("DELETE-SEED: seeded deleted chats from recoverable message metadata") + } + } + + // Store candidates for post-backfill notification. + candidates := make([]recycleBinCandidate, 0, len(candidateMap)) + for _, candidate := range candidateMap { + candidates = append(candidates, candidate) + } + if len(candidates) > 0 { + c.recycleBinCandidatesMu.Lock() + c.recycleBinCandidates = candidates + c.recycleBinCandidatesMu.Unlock() + } + + log.Info().Int("seeded", seeded).Int("total_recoverable_guids", len(guids)). + Msg("DELETE-SEED: finished — blocked portals will be shown to user via bridgebot") +} + +// notifyRecycleBinCandidates sends a bridgebot message listing chats that were +// blocked because their messages appear in Apple's recycle bin. The user can +// restore false positives with !restore-chat. +func (c *IMClient) notifyRecycleBinCandidates(log zerolog.Logger) { + c.recycleBinCandidatesMu.Lock() + candidates := c.recycleBinCandidates + c.recycleBinCandidates = nil + c.recycleBinCandidatesMu.Unlock() + + if len(candidates) == 0 { + return + } + + ctx := context.Background() + user := c.UserLogin.User + mgmtRoom, err := user.GetManagementRoom(ctx) + if err != nil { + log.Warn().Err(err).Msg("Failed to get management room for recycle bin notification") + return + } + + var sb strings.Builder + sb.WriteString("**Chats blocked from Apple's recycle bin:**\n\n") + sb.WriteString("These chats have messages in Apple's \"Recently Deleted\" and were not created during backfill. ") + sb.WriteString("If any were restored on your iPhone and should appear here, use `!restore-chat` to bring them back.\n\n") + for i, c := range candidates { + sb.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, c.displayName)) + } + sb.WriteString(fmt.Sprintf("\nUse `!restore-chat` to restore any of these chats.")) + + content := format.RenderMarkdown(sb.String(), true, false) + content.MsgType = event.MsgNotice + _, err = c.Main.Bridge.Bot.SendMessage(ctx, mgmtRoom, event.EventMessage, &event.Content{ + Parsed: content, + }, nil) + if err != nil { + log.Warn().Err(err).Msg("Failed to send recycle bin notification to management room") + } else { + log.Info().Int("count", len(candidates)).Msg("Sent recycle bin notification to user") + } +} + +func (c *IMClient) setContactsReady(log zerolog.Logger) { + firstTime := false + c.contactsReadyLock.Lock() + if !c.contactsReady { + c.contactsReady = true + firstTime = true + readyCh := c.contactsReadyCh + c.contactsReadyLock.Unlock() + if readyCh != nil { + close(readyCh) + } + log.Info().Msg("Contacts readiness gate satisfied") + } else { + c.contactsReadyLock.Unlock() + } + + // Re-resolve ghost and group names from contacts on every sync, + // not just the first time. Contacts may have been added/edited in iCloud. + if firstTime { + log.Info().Msg("Running initial contact name resolution for ghosts and group portals") + } else { + log.Info().Msg("Re-syncing contact names for ghosts and group portals") + } + go c.refreshGhostNamesFromContacts(log) + go c.refreshGroupPortalNamesFromContacts(log) + go c.subscribeToContactPresence(log) +} + +// subscribeToContactPresence subscribes to iMessage presence updates for all +// known ghosts via StatusKit. Presence changes are delivered via +// OnStatusUpdate and mapped to Matrix ghost presence. +func (c *IMClient) subscribeToContactPresence(log zerolog.Logger) { + if c.client == nil { + return + } + // Guard against panics inside the SubscribeToStatus FFI call (upstream + // StatusKit path has several panic sites, e.g. statuskit.rs:736). + defer func() { + if r := recover(); r != nil { + log.Warn().Interface("panic", r).Msg("subscribeToContactPresence panicked — skipped") + } + }() + // Serialize concurrent calls so each one reads the freshest state.keys + // snapshot from the Rust side. A previous leading-edge debounce was + // swallowing re-subscriptions fired by OnKeysReceived: startup ran an + // initial subscribe with zero keys in state, then the key-sharing + // response arrived within the debounce window and the follow-up + // subscribe (which would have picked up the new channel) was skipped. + // No further key-sharing arrives for a contact who already shared, so + // the channel stayed unsubscribed until the next restart. + c.lastPresenceSubscribeLock.Lock() + defer c.lastPresenceSubscribeLock.Unlock() + c.lastPresenceSubscribe = time.Now() + + ctx := context.Background() + rows, err := c.Main.Bridge.DB.RawDB.QueryContext(ctx, "SELECT id FROM ghost WHERE bridge_id=$1", c.Main.Bridge.ID) + if err != nil { + log.Warn().Err(err).Msg("Failed to query ghosts for presence subscription") + return + } + defer rows.Close() + + selfHandles := make(map[string]struct{}, len(c.allHandles)) + for _, h := range c.allHandles { + selfHandles[h] = struct{}{} + } + var handles []string + for rows.Next() { + var ghostID string + if err := rows.Scan(&ghostID); err != nil { + continue + } + if _, isSelf := selfHandles[ghostID]; isSelf { + continue + } + handles = append(handles, ghostID) + } + if err := rows.Err(); err != nil { + log.Warn().Err(err).Msg("Ghost row iteration error during presence subscription — subscription may be incomplete") + return + } + if len(handles) == 0 { + return + } + if err := c.client.SubscribeToStatus(handles); err != nil { + log.Warn().Err(err).Int("count", len(handles)).Msg("Failed to subscribe to presence") + } else { + log.Info().Int("count", len(handles)).Msg("Subscribed to iMessage presence for known ghosts") + } +} + +// inviteContactsToStatusSharing sends our StatusKit key to all known ghost +// handles via IDS keysharing. When a contact's device receives our key, it +// responds with its own key — without this exchange the bridge device never +// gets any contact's channel key, no APNs channel exists, and OnStatusUpdate +// never fires. Must run after InitStatuskit so shared_statuskit is populated. +func (c *IMClient) inviteContactsToStatusSharing(log zerolog.Logger) { + if c.client == nil || c.handle == "" { + return + } + // Function-level panic guard. SetStatus/InviteToStatusSharing both + // cross into Rust and upstream has several reachable panic sites in + // the keysharing path (see audit). + defer func() { + if r := recover(); r != nil { + log.Warn().Interface("panic", r).Msg("inviteContactsToStatusSharing panicked — skipped") + } + }() + ctx := context.Background() + rows, err := c.Main.Bridge.DB.RawDB.QueryContext(ctx, "SELECT id FROM ghost WHERE bridge_id=$1", c.Main.Bridge.ID) + if err != nil { + log.Warn().Err(err).Msg("StatusKit invite: failed to query ghosts") + return + } + selfHandles := make(map[string]struct{}, len(c.allHandles)) + for _, h := range c.allHandles { + selfHandles[h] = struct{}{} + } + var handles []string + for rows.Next() { + var ghostID string + if err := rows.Scan(&ghostID); err != nil { + continue + } + if _, isSelf := selfHandles[ghostID]; isSelf { + continue + } + handles = append(handles, ghostID) + } + if err := rows.Err(); err != nil { + log.Warn().Err(err).Msg("StatusKit invite: ghost row iteration error") + } + rows.Close() + if len(handles) == 0 { + return + } + // ensure_channel (called inside invite_to_channel) panics with "No my + // key!!" if our StatusKit channel hasn't been allocated yet. SetStatus + // calls share_status → ensure_channel safely (returns error, never + // panics), allocating our channel key before we attempt the invite. + if err := c.client.SetStatus(true); err != nil { + log.Warn().Err(err).Msg("StatusKit invite: channel not ready (will retry on next restart)") + return + } + senders := c.allHandles + if len(senders) == 0 && c.handle != "" { + senders = []string{c.handle} + } + var okCount, failCount int + for _, sender := range senders { + if err := c.client.InviteToStatusSharing(sender, handles); err != nil { + failCount++ + log.Warn().Err(err).Str("sender", sender).Int("count", len(handles)).Msg("StatusKit invite failed for sender") + } else { + okCount++ + } + } + log.Info().Int("count", len(handles)).Int("senders_ok", okCount).Int("senders_failed", failCount).Strs("senders", senders).Msg("Sent StatusKit key invites from all registered handles") +} + +// statusKitLastInviteKeyPrefix is the KV store key prefix for per-ghost +// last-invite timestamps. Stored as RFC3339 strings, parallel to the +// statuskit.presence. keys used by OnStatusUpdate. +const statusKitLastInviteKeyPrefix = "statuskit.last_invite." + +// statusKitReinviteMinSpacing is the minimum time between re-invites to the +// same ghost. Bounds worst-case IDS keysharing calls per non-responder to +// 6/day. Apple's per-target rate limits for keysharing are not documented, +// but this leaves a comfortable margin. +const statusKitReinviteMinSpacing = 4 * time.Hour + +// reinvitePendingStatusSharingGhosts re-sends StatusKit key invites only to +// ghosts who have NOT yet responded with their own key. Addresses contacts +// whose devices never responded to the initial invite (Apple-side rate limit, +// device offline when invite arrived, or trust-gated). Complements +// inviteContactsToStatusSharing, which runs at startup and after backfill. +func (c *IMClient) reinvitePendingStatusSharingGhosts(log zerolog.Logger) { + if c.client == nil || c.handle == "" { + return + } + defer func() { + if r := recover(); r != nil { + log.Warn().Interface("panic", r).Msg("reinvitePendingStatusSharingGhosts panicked — skipped") + } + }() + ctx := context.Background() + rows, err := c.Main.Bridge.DB.RawDB.QueryContext(ctx, "SELECT id FROM ghost WHERE bridge_id=$1", c.Main.Bridge.ID) + if err != nil { + log.Warn().Err(err).Msg("StatusKit re-invite: failed to query ghosts") + return + } + selfHandles := make(map[string]struct{}, len(c.allHandles)) + for _, h := range c.allHandles { + selfHandles[h] = struct{}{} + } + var allHandles []string + for rows.Next() { + var ghostID string + if err := rows.Scan(&ghostID); err != nil { + continue + } + if _, isSelf := selfHandles[ghostID]; isSelf { + continue + } + allHandles = append(allHandles, ghostID) + } + if err := rows.Err(); err != nil { + log.Warn().Err(err).Msg("StatusKit re-invite: ghost row iteration error") + } + rows.Close() + if len(allHandles) == 0 { + return + } + + sk, err := c.client.GetStatuskitClient() + if err != nil || sk == nil { + log.Warn().Err(err).Msg("StatusKit re-invite: no statuskit client available") + return + } + known := sk.GetKnownHandles() + knownSet := make(map[string]struct{}, len(known)) + for _, h := range known { + knownSet[h] = struct{}{} + } + + now := time.Now() + var pending []string + var aliasSkipped int + for _, h := range allHandles { + if _, responded := knownSet[h]; responded { + continue + } + // Peers key back under whichever handle form their device advertises + // (typically tel:) regardless of which form the bridge's ghost row + // takes (mailto: or tel:). Ask the IDS correlation cache whether this + // ghost aliases to any already-keyed handle; if so, they've responded + // under another form and re-inviting would just earn a duplicate-drop. + if aliases := c.client.ResolveHandleCached(h, known); len(aliases) > 0 { + aliasSkipped++ + continue + } + last := c.Main.Bridge.DB.KV.Get(ctx, database.Key(statusKitLastInviteKeyPrefix+h)) + if last != "" { + if ts, parseErr := time.Parse(time.RFC3339, last); parseErr == nil && now.Sub(ts) < statusKitReinviteMinSpacing { + continue + } + } + pending = append(pending, h) + } + + if len(pending) == 0 { + log.Debug(). + Int("total", len(allHandles)). + Int("responded", len(known)). + Int("alias_skipped", aliasSkipped). + Msg("StatusKit re-invite: no pending ghosts") + return + } + + // SetStatus allocates our channel if not already; safe to call repeatedly. + if err := c.client.SetStatus(true); err != nil { + log.Warn().Err(err).Msg("StatusKit re-invite: channel not ready") + return + } + senders := c.allHandles + if len(senders) == 0 && c.handle != "" { + senders = []string{c.handle} + } + var okCount, failCount int + for _, sender := range senders { + if err := c.client.InviteToStatusSharing(sender, pending); err != nil { + failCount++ + log.Warn().Err(err).Str("sender", sender).Int("count", len(pending)).Msg("StatusKit re-invite: failed for sender") + } else { + okCount++ + } + } + if okCount == 0 { + return + } + + nowStr := now.Format(time.RFC3339) + for _, h := range pending { + c.Main.Bridge.DB.KV.Set(ctx, database.Key(statusKitLastInviteKeyPrefix+h), nowStr) + } + log.Info(). + Int("total", len(allHandles)). + Int("responded", len(known)). + Int("alias_skipped", aliasSkipped). + Int("pending", len(pending)). + Msg("Sent periodic StatusKit re-invite to pending ghosts") +} + +func (c *IMClient) refreshGhostNamesFromContacts(log zerolog.Logger) { + if c.contacts == nil { + return + } + ctx := context.Background() + + // Use bridge_id-scoped query and fetch the current name for diff-gating. + rows, err := c.Main.Bridge.DB.Database.Query(ctx, + "SELECT id, COALESCE(name, '') FROM ghost WHERE bridge_id=$1", + c.Main.Bridge.ID, + ) + if err != nil { + log.Err(err).Msg("Failed to query ghosts for contact name refresh") + return + } + defer rows.Close() + + type ghostEntry struct { + id networkid.UserID + name string + } + var ghosts []ghostEntry + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + log.Err(err).Msg("Failed to scan ghost ID") + continue + } + ghosts = append(ghosts, ghostEntry{networkid.UserID(id), name}) + } + if err := rows.Err(); err != nil { + log.Err(err).Msg("Ghost ID row iteration error") + } + rows.Close() + + updated := 0 + for _, g := range ghosts { + // Skip ghosts with no matching contact (efficiency: avoids loading + // the full ghost object for participants who aren't in the address book). + localID := stripIdentifierPrefix(string(g.id)) + if localID == "" { + continue + } + contact, _ := c.contacts.GetContactInfo(localID) + if contact == nil || !contact.HasName() { + continue + } + // Diff-gate: compute the expected displayname and skip if it matches + // the stored name. This prevents unnecessary Matrix profile update API + // calls on every contact refresh cycle (AggressiveUpdateInfo=true means + // UpdateInfo always makes an API call; diffing here is our only guard). + expectedName := c.Main.Config.FormatDisplayname(DisplaynameParams{ + FirstName: contact.FirstName, + LastName: contact.LastName, + Nickname: contact.Nickname, + ID: localID, + }) + if g.name == expectedName { + continue + } + ghost, err := c.Main.Bridge.GetGhostByID(ctx, g.id) + if err != nil || ghost == nil { + log.Warn().Err(err).Str("ghost_id", string(g.id)).Msg("Failed to load ghost for name refresh") + continue + } + // Use the full GetUserInfo → UpdateInfo cycle (same as refreshAllGhosts) + // to ensure name, avatar, and identifiers are all propagated to Matrix. + info, err := c.GetUserInfo(ctx, ghost) + if err != nil || info == nil { + continue + } + ghost.UpdateInfo(ctx, info) + updated++ + } + log.Info().Int("updated", updated).Int("total", len(ghosts)).Msg("Refreshed ghost names from contacts") +} + +// refreshGroupPortalNamesFromContacts re-resolves group portal names using +// contact data. Portals created before contacts loaded may have raw phone +// numbers / email addresses as the room name. This also picks up contact +// edits on subsequent periodic syncs. +func (c *IMClient) refreshGroupPortalNamesFromContacts(log zerolog.Logger) { + if c.contacts == nil { + return + } + ctx := context.Background() + + portals, err := c.Main.Bridge.GetAllPortalsWithMXID(ctx) + if err != nil { + log.Err(err).Msg("Failed to load portals for group name refresh") + return + } + + updated := 0 + total := 0 + for _, portal := range portals { + if portal.Receiver != c.UserLogin.ID { + continue + } + portalID := string(portal.ID) + isGroup := strings.HasPrefix(portalID, "gid:") || strings.Contains(portalID, ",") + if !isGroup { + continue + } + total++ + + newName, authoritative := c.resolveGroupName(ctx, portalID) + if newName == "" || newName == portal.Name { + continue + } + // Don't overwrite an existing portal name with a contact-derived + // fallback — only authoritative sources (user-set iMessage group + // names from the in-memory cache or CloudKit) should rename. + if !authoritative && portal.Name != "" { + continue + } + + c.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatInfoChange, + PortalKey: networkid.PortalKey{ + ID: portal.ID, + Receiver: c.UserLogin.ID, + }, + LogContext: func(lc zerolog.Context) zerolog.Context { + return lc.Str("portal_id", portalID).Str("source", "group_name_refresh") + }, + }, + ChatInfoChange: &bridgev2.ChatInfoChange{ + ChatInfo: &bridgev2.ChatInfo{ + Name: &newName, + // Exclude from timeline so Beeper doesn't render + // contact-based name resolution as a bridge bot message. + ExcludeChangesFromTimeline: true, + }, + }, + }) + updated++ + } + log.Info().Int("updated", updated).Int("total_groups", total).Msg("Refreshed group portal names from contacts") +} + +// contactsWaitTimeout is how long CloudKit sync waits for the contacts +// readiness gate before proceeding without contacts. Contacts are nice-to-have +// for name resolution but shouldn't block backfill indefinitely. +const contactsWaitTimeout = 30 * time.Second + +func (c *IMClient) waitForContactsReady(log zerolog.Logger) bool { + c.contactsReadyLock.RLock() + alreadyReady := c.contactsReady + readyCh := c.contactsReadyCh + c.contactsReadyLock.RUnlock() + if alreadyReady { + return true + } + + log.Info().Dur("timeout", contactsWaitTimeout).Msg("Waiting for contacts readiness gate before CloudKit sync") + select { + case <-readyCh: + log.Info().Msg("Contacts readiness gate opened") + return true + case <-time.After(contactsWaitTimeout): + log.Warn().Msg("Contacts readiness timed out — proceeding with CloudKit sync without contacts") + return true + case <-c.stopChan: + return false + } +} + +func (c *IMClient) startCloudSyncController(log zerolog.Logger) { + if c.cloudStore == nil { + log.Warn().Msg("CloudKit sync controller not started: cloud store is nil") + return + } + if c.client == nil { + log.Warn().Msg("CloudKit sync controller not started: client is nil") + return + } + log.Info().Msg("Starting CloudKit sync controller goroutine") + go c.runCloudSyncController(log.With().Str("component", "cloud_sync").Logger()) +} + +// cloudSyncRetryInterval is the interval used when a bootstrap sync attempt +// fails, so recovery happens quickly. +const cloudSyncRetryInterval = 1 * time.Minute + +func (c *IMClient) runCloudSyncController(log zerolog.Logger) { + // NOTE: no defer recover() here intentionally. A panic in this goroutine + // must crash the process so the bridge restarts and re-runs CloudKit sync. + // Swallowing the panic would leave setCloudSyncDone() uncalled, permanently + // blocking the APNs message buffer and dropping all incoming messages. + + // Derive a cancellable context from stopChan so that preUploadCloudAttachments + // and other long-running operations can be interrupted promptly on shutdown + // instead of running to completion (worst case: 48 min of leaked downloads). + ctx, cancel := context.WithCancel(context.Background()) + go func() { + select { + case <-c.stopChan: + cancel() + case <-ctx.Done(): + } + }() + defer cancel() + controllerStart := time.Now() + if !c.waitForContactsReady(log) { + c.setCloudSyncDone() // unblock APNs portal creation + return + } + log.Info().Dur("contacts_wait", time.Since(controllerStart)).Msg("Contacts ready, proceeding with CloudKit sync") + + // Repopulate recentlyDeletedPortals from DB before CloudKit sync. + // This ensures tombstones from prior sessions survive bridge restarts + // even if CloudKit's incremental sync (with a saved continuation token) + // doesn't re-deliver the tombstone records. + if c.cloudStore != nil { + if deletedPortals, err := c.cloudStore.listDeletedPortalIDs(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to load deleted portals from DB") + } else if len(deletedPortals) > 0 { + c.recentlyDeletedPortalsMu.Lock() + if c.recentlyDeletedPortals == nil { + c.recentlyDeletedPortals = make(map[string]deletedPortalEntry) + } + for _, portalID := range deletedPortals { + c.recentlyDeletedPortals[portalID] = deletedPortalEntry{ + deletedAt: time.Now(), + isTombstone: true, + } + } + c.recentlyDeletedPortalsMu.Unlock() + log.Info().Int("count", len(deletedPortals)). + Msg("Repopulated recentlyDeletedPortals from DB (restart safety)") + } + } + + // Bootstrap: download CloudKit data with retries until successful. + // IMPORTANT: Do NOT set cloudSyncDone on failure. The APNs gate must + // stay closed until sync succeeds — otherwise APNs echoes can create + // portals for deleted chats because cloud_message has no UUID data + // and recentlyDeletedPortals has no tombstone entries. + // Messages for EXISTING portals are still delivered during this time + // (handleMessage only checks cloudSyncDone for NEW portal creation). + c.cloudSyncRunningLock.Lock() + c.cloudSyncRunning = true + c.cloudSyncRunningLock.Unlock() + // Ensure cloudSyncRunning is cleared on all exit paths (early return, + // panic, stopChan) so handleChatRecover doesn't defer forever. + defer func() { + c.cloudSyncRunningLock.Lock() + c.cloudSyncRunning = false + c.cloudSyncRunningLock.Unlock() + }() + for { + err := c.runCloudSyncOnceSerialized(ctx, log, true) + if err != nil { + log.Error().Err(err). + Dur("retry_in", cloudSyncRetryInterval). + Msg("CloudKit sync failed, will retry (APNs gate stays closed)") + select { + case <-time.After(cloudSyncRetryInterval): + continue + case <-c.stopChan: + return + } + } + break + } + + // Soft-delete cloud records for portals that should be dead: + // Tombstoned portals — chat tombstones processed before messages, + // so message import creates cloud_message rows for deleted chats. + // deleteLocalChatByPortalID marks cloud_message deleted=TRUE (preserving + // UUIDs for echo detection) and removes cloud_chat rows. + skipPortals := make(map[string]bool) + c.recentlyDeletedPortalsMu.RLock() + for portalID := range c.recentlyDeletedPortals { + skipPortals[portalID] = true + } + c.recentlyDeletedPortalsMu.RUnlock() + for portalID := range skipPortals { + if err := c.cloudStore.deleteLocalChatByPortalID(ctx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to soft-delete records for dead portal") + } + } + if len(skipPortals) > 0 { + log.Info().Int("count", len(skipPortals)). + Msg("Soft-deleted re-imported records for dead portals") + } + + // Pre-upload all CloudKit attachments to Matrix before triggering portal + // creation. This populates attachmentContentCache so that FetchMessages + // (which runs inside the portal event loop goroutine) gets instant cache + // hits instead of blocking on CloudKit for 30+ minutes. + c.preUploadCloudAttachments(ctx) + + // Seed delete knowledge from Apple's recycle bin BEFORE creating portals. + // Must run after runCloudSyncOnce (PCS keys needed) but before + // createPortalsFromCloudSync so deleted chats don't get portals. + // IMPORTANT: Must also run BEFORE normalizeGroupChatPortalIDs, because + // normalization unifies portal_ids (gid: → gid:), + // which would make portalHasChat match live CloudKit rows for chats + // that are actually in the recycle bin. + c.seedDeletedChatsFromRecycleBin(log) + + // Normalize inconsistent group portal IDs in cloud_chat: unify all + // rows for the same group to use gid: as the canonical portal_id. + // This must run after seedDeletedChatsFromRecycleBin (which needs the + // pre-normalization mismatch to correctly detect deleted chats) but + // before createPortalsFromCloudSync (which needs unified portal_ids). + if normalized, err := c.cloudStore.normalizeGroupChatPortalIDs(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to normalize group chat portal IDs") + } else if normalized > 0 { + log.Info().Int64("normalized", normalized). + Msg("Normalized inconsistent group chat portal IDs (cloud_chat)") + } + + // Normalize inconsistent group portal IDs in cloud_message. + // resolveConversationID historically used CloudKit chat_id UUIDs before + // the getChatPortalID-first fix (2eace9a), creating gid: rows + // that differ from the canonical gid:. This fixes those rows + // using cloud_chat as the authoritative mapping, preventing duplicate + // portal creation on startup and restore. + if normalized, err := c.cloudStore.normalizeGroupMessagePortalIDs(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to normalize group message portal IDs") + } else if normalized > 0 { + log.Info().Int64("normalized", normalized). + Msg("Normalized inconsistent group message portal IDs using cloud_chat mappings") + } + + // Create portals and queue forward backfill for all of them. + // Skip portals that are tombstoned or recently deleted this session. + portalStart := time.Now() + c.createPortalsFromCloudSync(ctx, log, skipPortals) + c.setCloudSyncDone() + + log.Info(). + Dur("portal_creation_elapsed", time.Since(portalStart)). + Dur("total_elapsed", time.Since(controllerStart)). + Msg("CloudKit bootstrap complete — all portals queued, APNs portal creation enabled") + + // Notify the user about chats blocked from Apple's recycle bin. + c.notifyRecycleBinCandidates(log) + + // Clean up stale entries in recentlyDeletedPortals to prevent unbounded + // memory growth over long-running sessions. + c.pruneRecentlyDeletedPortals(log) + + // Housekeeping: prune orphaned data that accumulates over time. + // Run after bootstrap so the cleanup doesn't delay portal creation. + c.runPostSyncHousekeeping(ctx, log) + + // Delayed incremental re-syncs: catch CloudKit messages that propagated + // after the bootstrap sync completed. Messages sent in the last few minutes + // may not be in the CloudKit changes feed yet (propagation delay). APNs + // delivers them with CreatePortal=false (gate closed), so bridgev2 drops + // them for non-existent portals. Multiple re-sync passes at increasing + // intervals ensure we catch them as CloudKit propagates. + // + // IMPORTANT: These run inline (not in a goroutine) so that the defer + // cancel() on the parent context doesn't fire until all re-syncs are + // done. Previously these ran in a goroutine, but runCloudSyncController + // returned immediately after launching it — the defer cancel() killed + // the context, causing every re-sync to fail with "context canceled". + delays := []time.Duration{15 * time.Second, 60 * time.Second, 3 * time.Minute} + for i, delay := range delays { + select { + case <-time.After(delay): + case <-c.stopChan: + return + } + resyncLog := log.With(). + Str("source", "delayed_resync"). + Int("pass", i+1). + Int("total_passes", len(delays)). + Logger() + resyncLog.Info().Msg("Running delayed incremental CloudKit re-sync") + c.cloudSyncRunningLock.Lock() + c.cloudSyncRunning = true + c.cloudSyncRunningLock.Unlock() + if err := c.runCloudSyncOnceSerialized(ctx, resyncLog, false); err != nil { + c.cloudSyncRunningLock.Lock() + c.cloudSyncRunning = false + c.cloudSyncRunningLock.Unlock() + resyncLog.Warn().Err(err).Msg("Delayed incremental re-sync failed") + continue + } + // Extend skipPortals with any portals deleted since bootstrap. + // The delayed re-sync may have imported new cloud_message records + // for these portals (deleted=FALSE). Soft-delete them now so they + // don't persist in the DB and resurrect the portal on next restart. + c.recentlyDeletedPortalsMu.RLock() + for portalID := range c.recentlyDeletedPortals { + if !skipPortals[portalID] { + skipPortals[portalID] = true + if err := c.cloudStore.deleteLocalChatByPortalID(ctx, portalID); err != nil { + resyncLog.Warn().Err(err).Str("portal_id", portalID). + Msg("Failed to soft-delete re-imported records for recently deleted portal") + } else { + resyncLog.Debug().Str("portal_id", portalID). + Msg("Soft-deleted re-imported records for portal deleted after bootstrap") + } + } + } + c.recentlyDeletedPortalsMu.RUnlock() + // Pre-upload any new attachments discovered by this re-sync; + // already-cached record_names are skipped instantly. + c.preUploadCloudAttachments(ctx) + c.createPortalsFromCloudSync(ctx, resyncLog, skipPortals) + c.cloudSyncRunningLock.Lock() + c.cloudSyncRunning = false + c.cloudSyncRunningLock.Unlock() + } +} + +// runPostSyncHousekeeping cleans up accumulated dead data after a successful +// CloudKit sync. Each operation is independent and best-effort — failures are +// logged but don't block the bridge. +func (c *IMClient) runPostSyncHousekeeping(ctx context.Context, log zerolog.Logger) { + if c.cloudStore == nil { + return + } + log = log.With().Str("component", "housekeeping").Logger() + + // Prune cloud_attachment_cache entries not referenced by any live message. + if pruned, err := c.cloudStore.pruneOrphanedAttachmentCache(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to prune orphaned attachment cache") + } else if pruned > 0 { + log.Info().Int64("pruned", pruned).Msg("Pruned orphaned attachment cache entries") + } + + // Delete cloud_message rows whose portal_id has no cloud_chat entry. + if deleted, err := c.cloudStore.deleteOrphanedMessages(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to delete orphaned cloud messages") + } else if deleted > 0 { + log.Info().Int64("deleted", deleted).Msg("Deleted orphaned cloud_message rows (no matching cloud_chat)") + } +} + +func (c *IMClient) runCloudSyncOnceSerialized(ctx context.Context, log zerolog.Logger, isBootstrap bool) error { + c.cloudSyncRunMu.Lock() + defer c.cloudSyncRunMu.Unlock() + return c.runCloudSyncOnce(ctx, log, isBootstrap) +} + +// runCloudSyncOnce performs a single CloudKit sync pass. On the first run +// (isBootstrap=true) it detects fresh vs. interrupted state and clears stale +// data if needed. On subsequent runs it's purely incremental — the saved +// continuation tokens mean CloudKit only returns changes since last sync. +func (c *IMClient) runCloudSyncOnce(ctx context.Context, log zerolog.Logger, isBootstrap bool) error { + if isBootstrap { + isFresh := false + hasOwnPortal := false + if portals, err := c.Main.Bridge.GetAllPortalsWithMXID(ctx); err == nil { + for _, p := range portals { + if p.Receiver == c.UserLogin.ID { + hasOwnPortal = true + break + } + } + } + + if !hasOwnPortal { + hasSyncState := false + if has, err := c.cloudStore.hasAnySyncState(ctx); err == nil { + hasSyncState = has + } + if !hasSyncState { + // No portals AND no sync state — truly fresh. Clear everything. + if err := c.cloudStore.clearAllData(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to clear stale cloud data") + } else { + log.Info().Msg("Fresh database detected, cleared cloud cache for full bootstrap") + } + isFresh = true + } else { + // Sync state exists but no portals. Two sub-cases: + // (a) CloudKit data download was interrupted → resume from saved tokens + // (b) Data download completed but portal creation was interrupted + // → backfill returns ~0 new records, portal creation re-runs + // Both cases are handled correctly: backfill resumes or no-ops, + // then createPortalsFromCloudSync reads from the DB tables. + chatTok, _ := c.cloudStore.getSyncState(ctx, cloudZoneChats) + msgTok, _ := c.cloudStore.getSyncState(ctx, cloudZoneMessages) + log.Info(). + Bool("has_chat_token", chatTok != nil). + Bool("has_msg_token", msgTok != nil). + Msg("Resuming interrupted CloudKit sync (sync state exists but no portals yet)") + } + } else { + // Existing portals — check sync version. If the sync logic has + // been upgraded (e.g. better PCS key handling), do a one-time + // full re-sync to pick up previously-undecryptable records. + // Subsequent restarts use incremental sync (cheap). + savedVersion, _ := c.cloudStore.getSyncVersion(ctx) + if savedVersion < cloudSyncVersion { + if err := c.cloudStore.clearSyncTokens(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to clear sync tokens for version upgrade re-sync") + } else { + log.Info(). + Int("old_version", savedVersion). + Int("new_version", cloudSyncVersion). + Msg("Sync version upgraded — cleared tokens for one-time full re-sync") + } + } else { + log.Info().Int("sync_version", savedVersion).Msg("Sync version current — using incremental CloudKit sync") + + // Check chat-specific sync version. A bump here only clears + // the chatManateeZone token, leaving the (expensive) message + // token intact. Used for changes that only require re-fetching + // chat metadata (e.g. populating group_photo_guid). + savedChatVersion, _ := c.cloudStore.getChatSyncVersion(ctx) + if savedChatVersion < cloudChatSyncVersion { + if err := c.cloudStore.clearZoneToken(ctx, cloudZoneChats); err != nil { + log.Warn().Err(err).Msg("Failed to clear chat zone token for chat sync version upgrade") + } else { + log.Info(). + Int("old_version", savedChatVersion). + Int("new_version", cloudChatSyncVersion). + Msg("Chat sync version upgraded — cleared chat zone token for one-time full chat re-sync") + } + } + } + } + + log.Info().Bool("fresh", isFresh).Msg("CloudKit sync start") + } + + backfillStart := time.Now() + counts, err := c.runCloudKitBackfill(ctx, log) + if err != nil { + return fmt.Errorf("CloudKit sync failed after %s: %w", time.Since(backfillStart).Round(time.Second), err) + } + + log.Info(). + Int("imported", counts.Imported). + Int("updated", counts.Updated). + Int("skipped", counts.Skipped). + Int("deleted", counts.Deleted). + Bool("bootstrap", isBootstrap). + Dur("elapsed", time.Since(backfillStart)). + Msg("CloudKit sync pass complete") + + // Only persist sync version if the sync actually received data. + // If the re-sync returned 0 records (e.g. CloudKit changes feed + // considers records already delivered), leave the version unsaved + // so the next restart will clear tokens and try again. + totalRecords := counts.Imported + counts.Updated + counts.Deleted + if isBootstrap && totalRecords > 0 { + if err := c.cloudStore.setSyncVersion(ctx, cloudSyncVersion); err != nil { + log.Warn().Err(err).Msg("Failed to persist sync version") + } + if err := c.cloudStore.setChatSyncVersion(ctx, cloudChatSyncVersion); err != nil { + log.Warn().Err(err).Msg("Failed to persist chat sync version") + } + } else if !isBootstrap { + // For existing portals, always persist the chat sync version so that + // a targeted chat re-sync (cloudChatSyncVersion bump) is only done once. + if err := c.cloudStore.setChatSyncVersion(ctx, cloudChatSyncVersion); err != nil { + log.Warn().Err(err).Msg("Failed to persist chat sync version") + } + } else if isBootstrap && totalRecords == 0 { + log.Warn(). + Int("sync_version", cloudSyncVersion). + Msg("CloudKit sync returned 0 records — NOT saving version (will retry on next restart)") + // (Previously ran CloudDiagFullCount here as a diagnostic. Removed: + // it spans hundreds of anisette-authed pages and tripped an + // upstream `panic!()` in omnisette's remote_anisette_v3 that, when + // unwound across the FFI boundary, left the shared anisette + // tokio::sync::Mutex in an inconsistent state and deadlocked every + // subsequent operation that needed anisette — including message + // send. The diagnostic was non-essential; dropping it entirely + // avoids the trigger.) + } + + return nil +} + +func (c *IMClient) runCloudKitBackfill(ctx context.Context, log zerolog.Logger) (cloudSyncCounters, error) { + var total cloudSyncCounters + backfillStart := time.Now() + + // Always restart attachment sync from scratch. The attachment map + // (attMap) is built in-memory during sync and used to enrich messages. + // On crash/restart the map is lost, so resuming from a saved token + // would produce an incomplete map — messages referencing pre-crash + // attachments would lose their metadata. Attachment zones are small + // relative to messages, so the overhead is minimal. + if err := c.cloudStore.clearZoneToken(ctx, cloudZoneAttachments); err != nil { + log.Warn().Err(err).Msg("Failed to clear attachment zone token for fresh sync") + } + + // Check which zones have saved continuation tokens (for diagnostic logging). + savedChatTok, _ := c.cloudStore.getSyncState(ctx, cloudZoneChats) + savedMsgTok, _ := c.cloudStore.getSyncState(ctx, cloudZoneMessages) + log.Info(). + Bool("chat_token_saved", savedChatTok != nil). + Bool("msg_token_saved", savedMsgTok != nil). + Msg("CloudKit backfill starting (attachment zone always fresh)") + + // Phase 0: Sync attachments first (with ALL_ASSETS) to populate the + // Ford key cache before any downloads happen. MMCS deduplication can + // serve Ford blobs encrypted with a different record's key, so the + // cache must be fully populated before downloads start. + phase0Start := time.Now() + var attMap map[string]cloudAttachmentRow + var attToken *string + var attErr error + { + attMap, attToken, attErr = c.syncCloudAttachments(ctx) + attCount := 0 + if attMap != nil { + attCount = len(attMap) + } + log.Info(). + Dur("elapsed", time.Since(phase0Start)). + Int("attachments", attCount). + Err(attErr). + Msg("CloudKit attachment sync complete (Ford key cache populated)") + } + + // Phase 1: Sync chats. Messages depend on chats (portal ID resolution) + // and attachments (GUID→record_name mapping), so they must wait. + phase1Start := time.Now() + + var chatCounts cloudSyncCounters + var chatToken *string + var chatErr error + { + chatStart := time.Now() + chatCounts, chatToken, chatErr = c.syncCloudChats(ctx) + log.Info(). + Dur("elapsed", time.Since(chatStart)). + Int("imported", chatCounts.Imported). + Int("updated", chatCounts.Updated). + Int("skipped", chatCounts.Skipped). + Err(chatErr). + Msg("CloudKit chat sync complete") + } + log.Info().Dur("phase1_elapsed", time.Since(phase1Start)).Msg("CloudKit phase 1 (chats) complete") + + if chatErr != nil { + _ = c.cloudStore.setSyncStateError(ctx, cloudZoneChats, chatErr.Error()) + return total, chatErr + } + // Only persist the chat token if non-nil — a nil token from a "no changes" + // response would overwrite the saved watermark and force a full re-download + // on the next restart. + if chatToken != nil { + if err := c.cloudStore.setSyncStateSuccess(ctx, cloudZoneChats, chatToken); err != nil { + log.Warn().Err(err).Msg("Failed to persist chat sync token") + } + } + total.add(chatCounts) + + if attErr != nil { + log.Warn().Err(attErr).Msg("Failed to sync CloudKit attachments (continuing without)") + } else if attToken != nil { + if err := c.cloudStore.setSyncStateSuccess(ctx, cloudZoneAttachments, attToken); err != nil { + log.Warn().Err(err).Msg("Failed to persist attachment sync token") + } + } + + // Phase 2: Sync messages (depends on chats + attachments). + phase2Start := time.Now() + msgCounts, msgToken, err := c.syncCloudMessages(ctx, attMap) + if err != nil { + _ = c.cloudStore.setSyncStateError(ctx, cloudZoneMessages, err.Error()) + return total, err + } + if msgToken != nil { + if err = c.cloudStore.setSyncStateSuccess(ctx, cloudZoneMessages, msgToken); err != nil { + log.Warn().Err(err).Msg("Failed to persist message sync token") + } + } + total.add(msgCounts) + + log.Info(). + Dur("phase2_elapsed", time.Since(phase2Start)). + Int("imported", msgCounts.Imported). + Int("updated", msgCounts.Updated). + Int("skipped", msgCounts.Skipped). + Dur("total_elapsed", time.Since(backfillStart)). + Msg("CloudKit phase 2 (messages) complete") + + return total, nil +} + +// syncCloudAttachments syncs the attachment zone and builds a GUID→attachment info map. +func (c *IMClient) syncCloudAttachments(ctx context.Context) (map[string]cloudAttachmentRow, *string, error) { + attMap := make(map[string]cloudAttachmentRow) + token, err := c.cloudStore.getSyncState(ctx, cloudZoneAttachments) + if err != nil { + return attMap, nil, err + } + + log := c.Main.Bridge.Log.With().Str("component", "cloud_sync").Logger() + consecutiveErrors := 0 + const maxConsecutiveAttErrors = 3 + for page := 0; page < maxCloudSyncPages; page++ { + resp, syncErr := safeCloudSyncAttachments(c.client, token) + if syncErr != nil { + consecutiveErrors++ + log.Warn().Err(syncErr). + Int("page", page). + Int("imported_so_far", len(attMap)). + Int("consecutive_errors", consecutiveErrors). + Msg("CloudKit attachment sync page failed (FFI error)") + if consecutiveErrors >= maxConsecutiveAttErrors { + log.Error(). + Int("page", page). + Int("imported_so_far", len(attMap)). + Msg("CloudKit attachment sync: too many consecutive FFI errors, stopping pagination") + break + } + continue + } + consecutiveErrors = 0 + + for _, att := range resp.Attachments { + mime := "" + if att.MimeType != nil { + mime = *att.MimeType + } + uti := "" + if att.UtiType != nil { + uti = *att.UtiType + } + filename := "" + if att.Filename != nil { + filename = *att.Filename + } + attMap[att.Guid] = cloudAttachmentRow{ + GUID: att.Guid, + MimeType: mime, + UTIType: uti, + Filename: filename, + FileSize: att.FileSize, + RecordName: att.RecordName, + HasAvid: att.HasAvid, + } + + // Populate the Ford key cache from this record's + // PCS-decrypted protection_info. Mirrors the sync-side + // half of the 94f7b8e Ford dedup fix — every video's + // Ford key is available for future cross-batch lookup, + // regardless of upload order. Registered on BOTH the + // Go cache (used by Go-side lookups and tests) AND the + // wrapper's process-wide cache (used by the Ford + // recovery path inside cloud_download_attachment when + // an SIV panic is caught). + if att.FordKey != nil { + if c.fordCache != nil { + c.fordCache.Register(*att.FordKey) + } + rustpushgo.RegisterFordKey(*att.FordKey) + } + if att.AvidFordKey != nil { + if c.fordCache != nil { + c.fordCache.Register(*att.AvidFordKey) + } + rustpushgo.RegisterFordKey(*att.AvidFordKey) + } + } + + prev := ptrStringOr(token, "") + token = resp.ContinuationToken + + // Persist token after each page for crash-safe resume. + if token != nil { + if saveErr := c.cloudStore.setSyncStateSuccess(ctx, cloudZoneAttachments, token); saveErr != nil { + log.Warn().Err(saveErr).Int("page", page).Msg("Failed to persist attachment sync token mid-page") + } + } + + if resp.Done || (page > 0 && prev == ptrStringOr(token, "")) { + break + } + } + + // QueryRecords fallback: query attachmentManateeZone directly for records + // the FetchRecordChanges feed missed. Pass all record_names already in attMap + // so Rust only returns genuinely new records. + knownNames := make([]string, 0, len(attMap)) + for _, row := range attMap { + knownNames = append(knownNames, row.RecordName) + } + if fallback, fallbackErr := safeCloudQueryAttachmentsFallback(c.client, knownNames); fallbackErr != nil { + log.Warn().Err(fallbackErr).Msg("CloudKit attachment QueryRecords fallback failed") + } else { + for _, att := range fallback.Attachments { + mime := "" + if att.MimeType != nil { + mime = *att.MimeType + } + uti := "" + if att.UtiType != nil { + uti = *att.UtiType + } + filename := "" + if att.Filename != nil { + filename = *att.Filename + } + attMap[att.Guid] = cloudAttachmentRow{ + GUID: att.Guid, + MimeType: mime, + UTIType: uti, + Filename: filename, + FileSize: att.FileSize, + RecordName: att.RecordName, + HasAvid: att.HasAvid, + } + if att.FordKey != nil { + if c.fordCache != nil { + c.fordCache.Register(*att.FordKey) + } + rustpushgo.RegisterFordKey(*att.FordKey) + } + if att.AvidFordKey != nil { + if c.fordCache != nil { + c.fordCache.Register(*att.AvidFordKey) + } + rustpushgo.RegisterFordKey(*att.AvidFordKey) + } + } + if len(fallback.Attachments) > 0 { + log.Info().Int("count", len(fallback.Attachments)).Msg("CloudKit attachment QueryRecords fallback added records") + } + } + + return attMap, token, nil +} + +func (c *IMClient) syncCloudChats(ctx context.Context) (cloudSyncCounters, *string, error) { + var counts cloudSyncCounters + token, err := c.cloudStore.getSyncState(ctx, cloudZoneChats) + if err != nil { + return counts, nil, err + } + + log := c.Main.Bridge.Log.With().Str("component", "cloud_sync").Logger() + totalPages := 0 + consecutiveErrors := 0 + const maxConsecutiveChatErrors = 3 + for page := 0; page < maxCloudSyncPages; page++ { + resp, syncErr := safeCloudSyncChats(c.client, token) + if syncErr != nil { + consecutiveErrors++ + log.Warn().Err(syncErr). + Int("page", page). + Int("imported_so_far", counts.Imported). + Int("consecutive_errors", consecutiveErrors). + Msg("CloudKit chat sync page failed (FFI error)") + if consecutiveErrors >= maxConsecutiveChatErrors { + log.Error(). + Int("page", page). + Int("imported_so_far", counts.Imported). + Msg("CloudKit chat sync: too many consecutive FFI errors, stopping pagination") + break + } + time.Sleep(500 * time.Millisecond) + continue + } + consecutiveErrors = 0 + + log.Info(). + Int("page", page). + Int("chats_on_page", len(resp.Chats)). + Int32("status", resp.Status). + Bool("done", resp.Done). + Msg("CloudKit chat sync page") + + ingestCounts, ingestErr := c.ingestCloudChats(ctx, resp.Chats) + if ingestErr != nil { + return counts, token, ingestErr + } + counts.add(ingestCounts) + totalPages = page + 1 + + prev := ptrStringOr(token, "") + token = resp.ContinuationToken + + // Persist token after each page for crash-safe resume. + if token != nil { + if saveErr := c.cloudStore.setSyncStateSuccess(ctx, cloudZoneChats, token); saveErr != nil { + log.Warn().Err(saveErr).Int("page", page).Msg("Failed to persist chat sync token mid-page") + } + } + + if resp.Done || (page > 0 && prev == ptrStringOr(token, "")) { + log.Info().Int("page", page).Bool("api_done", resp.Done).Bool("token_unchanged", prev == ptrStringOr(token, "")). + Msg("CloudKit chat sync pagination stopped") + break + } + } + + log.Info().Int("total_pages", totalPages).Int("imported", counts.Imported).Int("updated", counts.Updated). + Int("skipped", counts.Skipped).Int("deleted", counts.Deleted).Int("filtered", counts.Filtered). + Msg("CloudKit chat sync finished") + + return counts, token, nil +} + +// safeFFICall wraps an FFI call with panic recovery. +// UniFFI deserialization panics on malformed buffers; this prevents bridge crashes. +func safeFFICall[T any](name string, fn func() (T, error)) (result T, err error) { + defer func() { + if r := recover(); r != nil { + log.Error().Str("ffi_method", name).Str("stack", string(debug.Stack())).Msgf("FFI panic recovered: %v", r) + err = fmt.Errorf("FFI panic in %s: %v", name, r) + } + }() + return fn() +} + +func safeCloudSyncMessages(client *rustpushgo.Client, token *string) (rustpushgo.WrappedCloudSyncMessagesPage, error) { + return safeFFICall("CloudSyncMessages", func() (rustpushgo.WrappedCloudSyncMessagesPage, error) { + return client.CloudSyncMessages(token) + }) +} + +func safeCloudSyncChats(client *rustpushgo.Client, token *string) (rustpushgo.WrappedCloudSyncChatsPage, error) { + return safeFFICall("CloudSyncChats", func() (rustpushgo.WrappedCloudSyncChatsPage, error) { + return client.CloudSyncChats(token) + }) +} + +func safeCloudSyncAttachments(client *rustpushgo.Client, token *string) (rustpushgo.WrappedCloudSyncAttachmentsPage, error) { + return safeFFICall("CloudSyncAttachments", func() (rustpushgo.WrappedCloudSyncAttachmentsPage, error) { + return client.CloudSyncAttachments(token) + }) +} + +func safeCloudQueryAttachmentsFallback(client *rustpushgo.Client, knownRecordNames []string) (rustpushgo.WrappedCloudSyncAttachmentsPage, error) { + return safeFFICall("CloudQueryAttachmentsFallback", func() (rustpushgo.WrappedCloudSyncAttachmentsPage, error) { + return client.CloudQueryAttachmentsFallback(knownRecordNames) + }) +} + +func (c *IMClient) syncCloudMessages(ctx context.Context, attMap map[string]cloudAttachmentRow) (cloudSyncCounters, *string, error) { + var counts cloudSyncCounters + token, err := c.cloudStore.getSyncState(ctx, cloudZoneMessages) + if err != nil { + return counts, nil, err + } + + log := c.Main.Bridge.Log.With().Str("component", "cloud_sync").Logger() + log.Info(). + Bool("token_nil", token == nil). + Msg("CloudKit message sync starting") + + consecutiveErrors := 0 + const maxConsecutiveErrors = 3 + totalPages := 0 + for page := 0; page < maxCloudSyncPages; page++ { + resp, syncErr := safeCloudSyncMessages(c.client, token) + if syncErr != nil { + consecutiveErrors++ + log.Warn().Err(syncErr). + Int("page", page). + Int("imported_so_far", counts.Imported). + Int("consecutive_errors", consecutiveErrors). + Msg("CloudKit message sync page failed (FFI error)") + if consecutiveErrors >= maxConsecutiveErrors { + log.Error(). + Int("page", page). + Int("imported_so_far", counts.Imported). + Msg("CloudKit message sync: too many consecutive FFI errors, stopping pagination") + break + } + // Skip this page and retry with the same token on next iteration. + // The server may return different records on retry. + time.Sleep(500 * time.Millisecond) + continue + } + consecutiveErrors = 0 + + log.Info(). + Int("page", page). + Int("messages", len(resp.Messages)). + Int32("status", resp.Status). + Bool("done", resp.Done). + Bool("has_token", resp.ContinuationToken != nil). + Msg("CloudKit message sync page") + + if err = c.ingestCloudMessages(ctx, resp.Messages, "", &counts, attMap); err != nil { + return counts, token, err + } + + prev := ptrStringOr(token, "") + token = resp.ContinuationToken + totalPages = page + 1 + + // Only persist the continuation token if records were received. + // Persisting an empty-page token on a 0-record re-sync would + // prevent future retries from starting fresh. + if token != nil && len(resp.Messages) > 0 { + if saveErr := c.cloudStore.setSyncStateSuccess(ctx, cloudZoneMessages, token); saveErr != nil { + log.Warn().Err(saveErr).Int("page", page).Msg("Failed to persist message sync token mid-page") + } + } + + if resp.Done || (page > 0 && prev == ptrStringOr(token, "")) { + log.Info(). + Int("page", page). + Bool("api_done", resp.Done). + Bool("token_unchanged", prev == ptrStringOr(token, "")). + Msg("CloudKit message sync pagination stopped") + break + } + } + + log.Info(). + Int("total_pages", totalPages). + Int("imported", counts.Imported). + Int("updated", counts.Updated). + Int("skipped", counts.Skipped). + Int("deleted", counts.Deleted). + Int("filtered", counts.Filtered). + Msg("CloudKit message sync finished") + + return counts, token, nil +} + +func (c *IMClient) ingestCloudChats(ctx context.Context, chats []rustpushgo.WrappedCloudSyncChat) (cloudSyncCounters, error) { + var counts cloudSyncCounters + log := c.Main.Bridge.Log.With().Str("component", "cloud_sync").Logger() + + // Batch existence check for all non-deleted chat IDs. + chatIDs := make([]string, 0, len(chats)) + for _, chat := range chats { + if !chat.Deleted { + chatIDs = append(chatIDs, chat.CloudChatId) + } + } + existingSet, err := c.cloudStore.hasChatBatch(ctx, chatIDs) + if err != nil { + return counts, fmt.Errorf("batch chat existence check failed: %w", err) + } + + // Collect deleted record_names for tombstone handling. + // Tombstones only carry the record_name (CloudChatId is set to + // record_name by the FFI layer since there's no data to extract + // the real chat identifier). We must look up by record_name. + var deletedRecordNames []string + + // Collect portal IDs for chats that have a group photo GUID so we can + // warm the photo cache after upserting the batch. + var photoPortalIDs []string + + // Build batch of rows. + batch := make([]cloudChatUpsertRow, 0, len(chats)) + for _, chat := range chats { + if chat.Deleted { + counts.Deleted++ + deletedRecordNames = append(deletedRecordNames, chat.RecordName) + continue + } + + portalID := c.resolvePortalIDForCloudChat(chat.Participants, chat.DisplayName, chat.GroupId, chat.Style) + if portalID == "" { + counts.Skipped++ + continue + } + + // Update SMS flag from CloudKit chat service type. If a stale + // CloudKit record briefly clears the flag for a legitimately-SMS + // portal, the next live SMS message will re-set it immediately + // via handleMessage's unconditional updatePortalSMS call. + if strings.EqualFold(chat.Service, "SMS") { + c.updatePortalSMS(portalID, true) + } else if strings.EqualFold(chat.Service, "iMessage") { + c.updatePortalSMS(portalID, false) + } + + participantsJSON, jsonErr := json.Marshal(chat.Participants) + if jsonErr != nil { + return counts, jsonErr + } + + if chat.GroupPhotoGuid != nil && *chat.GroupPhotoGuid != "" { + log.Info(). + Str("portal_id", portalID). + Str("record_name", chat.RecordName). + Str("group_photo_guid", *chat.GroupPhotoGuid). + Msg("CloudKit chat sync: group photo GUID found") + photoPortalIDs = append(photoPortalIDs, portalID) + } + + batch = append(batch, cloudChatUpsertRow{ + CloudChatID: chat.CloudChatId, + RecordName: chat.RecordName, + GroupID: chat.GroupId, + PortalID: portalID, + Service: chat.Service, + DisplayName: nullableString(chat.DisplayName), + GroupPhotoGuid: nullableString(chat.GroupPhotoGuid), + ParticipantsJSON: string(participantsJSON), + UpdatedTS: int64(chat.UpdatedTimestampMs), + IsFiltered: chat.IsFiltered, + }) + + if chat.IsFiltered != 0 { + counts.Filtered++ + log.Debug(). + Str("cloud_chat_id", chat.CloudChatId). + Str("portal_id", portalID). + Int64("is_filtered", chat.IsFiltered). + Msg("Stored filtered/junk chat from CloudKit (will skip messages)") + } else if existingSet[chat.CloudChatId] { + counts.Updated++ + } else { + counts.Imported++ + } + } + + // Batch insert all non-deleted chats. The upsert's ON CONFLICT clause + // preserves the existing deleted flag, so soft-deleted (Beeper-deleted) + // chats stay deleted through CloudKit re-sync. Only explicit recovery + // (RecoverChat, !restore-chat) un-deletes via undeleteCloudChatByPortalID. + if err := c.cloudStore.upsertChatBatch(ctx, batch); err != nil { + return counts, err + } + + // Remove un-tombstoned portals from recentlyDeletedPortals — but only + // tombstone entries (seeded from recycle bin). Beeper-initiated deletes + // (isTombstone=false) must NOT be cleared here: the CloudKit record may + // still exist because the async CloudKit deletion hasn't completed yet. + // Clearing the in-memory block prematurely lets createPortalsFromCloudSync + // resurrect the portal as a zombie. + var tombstonesCleared []string + c.recentlyDeletedPortalsMu.Lock() + for _, row := range batch { + if entry, ok := c.recentlyDeletedPortals[row.PortalID]; ok && entry.isTombstone { + delete(c.recentlyDeletedPortals, row.PortalID) + tombstonesCleared = append(tombstonesCleared, row.PortalID) + } + } + c.recentlyDeletedPortalsMu.Unlock() + + // For each tombstone portal that CloudKit just confirmed is live, also + // clear the deleted=TRUE flag in the DB. upsertChatBatch preserves + // cloud_chat.deleted intentionally for Beeper-initiated deletes, but + // tombstone portals that re-appear as live should be fully undeleted so + // ingestCloudMessages/portalHasChat doesn't silently filter their messages. + for _, portalID := range tombstonesCleared { + if err := c.cloudStore.undeleteCloudChatByPortalID(ctx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID). + Msg("Failed to undelete tombstone portal DB row after live CloudKit record received") + } else { + log.Info().Str("portal_id", portalID). + Msg("Tombstone portal confirmed live by CloudKit — undeleted in DB") + } + } + + // Proactively warm the group_photo_cache for chats that have a photo GUID + // but no cached bytes yet. This ensures GetChatInfo can serve the avatar + // immediately on first portal open without waiting for an APNs IconChange. + // Runs in a background goroutine so it doesn't block the sync sweep. + // Each download attempt is best-effort: failures are logged at debug level + // since the CloudKit "gp" asset field is often unpopulated by Apple clients. + if len(photoPortalIDs) > 0 { + bgCtx, cancelBg := context.WithCancel(context.Background()) + // Wire bgCtx to the client lifecycle: cancel it when the client + // disconnects. The warmup goroutine also cancels on exit via defer, + // so this watcher exits promptly in either case. + go func() { + select { + case <-c.stopChan: + cancelBg() + case <-bgCtx.Done(): + } + }() + portalIDs := photoPortalIDs + go func() { + defer cancelBg() + photoLog := log.With().Str("component", "cloud_photo_warmup").Logger() + for _, portalID := range portalIDs { + _, existing, cacheErr := c.cloudStore.getGroupPhoto(bgCtx, portalID) + if cacheErr == nil && len(existing) > 0 { + continue // already cached + } + c.fetchAndCacheGroupPhoto(bgCtx, + photoLog.With().Str("portal_id", portalID).Logger(), + portalID) + } + }() + } + + // Handle tombstoned (deleted) chats. Tombstones only carry the + // record_name, so we look up portal_ids from the local cloud_chat + // table, then: + // 1. Soft-delete cloud_chat rows (deleted=TRUE) — preserves data for + // portalHasChat checks and potential restore-chat + // 2. Soft-delete cloud_message rows (deleted=TRUE) — preserves UUIDs + // for echo detection via hasMessageUUID + // 3. Queue bridge portal deletion for any existing portal + // 4. Mark in recentlyDeletedPortals to block APNs echo resurrection + if len(deletedRecordNames) > 0 { + // Resolve record_names → portal_ids before soft-deleting. + portalMap, lookupErr := c.cloudStore.lookupPortalIDsByRecordNames(ctx, deletedRecordNames) + if lookupErr != nil { + log.Warn().Err(lookupErr).Msg("Failed to look up portal IDs for tombstoned chats") + } + + // Soft-delete cloud_chat entries by record_name (correct for tombstones). + if err := c.cloudStore.deleteChatsByRecordNames(ctx, deletedRecordNames); err != nil { + return counts, fmt.Errorf("failed to soft-delete tombstoned chats by record_name: %w", err) + } + + // Soft-delete cloud_message rows for resolved portal_ids. + // The cloud_chat rows were already soft-deleted above by record_name. + // deleteLocalChatByPortalID sets deleted=TRUE on both tables (idempotent + // for cloud_chat, but catches cloud_message rows too). UUIDs are preserved + // so hasMessageUUID can still detect stale APNs echoes. + for recordName, portalID := range portalMap { + if err := c.cloudStore.deleteLocalChatByPortalID(ctx, portalID); err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to soft-delete messages for tombstoned chat") + } + + // Mark as deleted in memory so createPortalsFromCloudSync skips + // this portal and FetchMessages returns empty. + c.recentlyDeletedPortalsMu.Lock() + if c.recentlyDeletedPortals == nil { + c.recentlyDeletedPortals = make(map[string]deletedPortalEntry) + } + c.recentlyDeletedPortals[portalID] = deletedPortalEntry{ + deletedAt: time.Now(), + isTombstone: true, + } + c.recentlyDeletedPortalsMu.Unlock() + + // Queue bridge portal deletion if it exists. + portalKey := networkid.PortalKey{ + ID: networkid.PortalID(portalID), + Receiver: c.UserLogin.ID, + } + existing, _ := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if existing != nil && existing.MXID != "" { + log.Info(). + Str("portal_id", portalID). + Str("record_name", recordName). + Msg("CloudKit tombstone: deleting bridge portal for chat in recycle bin") + c.Main.Bridge.QueueRemoteEvent(c.UserLogin, &simplevent.ChatDelete{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatDelete, + PortalKey: portalKey, + Timestamp: time.Now(), + LogContext: func(lc zerolog.Context) zerolog.Context { + return lc.Str("source", "cloudkit_tombstone").Str("record_name", recordName) + }, + }, + OnlyForMe: true, + }) + } + } + } + + return counts, nil +} + +// uuidPattern matches a UUID string (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). +var uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// hexGroupPattern matches bare hex strings of 40+ characters (SHA1 hashes +// used as CloudKit group identifiers for SMS groups). +var hexGroupPattern = regexp.MustCompile(`(?i)^[0-9a-f]{40,128}$`) + +// isNumericSuffix returns true if s is non-empty and contains only ASCII digits. +// Used to identify CloudKit self-chat identifiers of the form "chat". +func isNumericSuffix(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +// resolveConversationID determines the canonical portal ID for a cloud message. +// +// Rule 1: If chat_id is a UUID → it's a group conversation → "gid:" +// Rule 2: Otherwise derive from sender (DM) → "tel:+..." or "mailto:..." +// Rule 3: Messages create conversations. Never discard a message because +// +// we haven't seen the chat record yet. +func (c *IMClient) resolveConversationID(ctx context.Context, msg rustpushgo.WrappedCloudSyncMessage) string { + // Try to look up the chat record first. This is authoritative because + // getChatPortalID matches on cloud_chat_id, record_name, AND group_id, + // so it returns the correct gid: even when the message's + // chat_id UUID differs from the chat's group_id UUID. + if msg.CloudChatId != "" { + if portalID, err := c.cloudStore.getChatPortalID(ctx, msg.CloudChatId); err == nil && portalID != "" { + return portalID + } + } + + // Fallback: if no chat record exists yet, a UUID chat_id means group. + if msg.CloudChatId != "" && uuidPattern.MatchString(msg.CloudChatId) { + return "gid:" + strings.ToLower(msg.CloudChatId) + } + + // Fallback: bare hex hash (40+ chars) — SMS group identifiers use SHA1 + // hashes as their CloudKit chatID. These are always group chats. + if msg.CloudChatId != "" && hexGroupPattern.MatchString(msg.CloudChatId) { + return "gid:" + strings.ToLower(msg.CloudChatId) + } + + // Group chat with non-UUID hex suffix format: "any;+;5851dc712a4b..." or + // "iMessage;+;chat". The ";+;" separator marks a group (vs ";-;" for + // DMs). Extract the suffix and use it as the gid: portal ID so group + // messages (including is_from_me=true) don't leak to the self-chat portal. + if msg.CloudChatId != "" && strings.Contains(msg.CloudChatId, ";+;") { + parts := strings.SplitN(msg.CloudChatId, ";+;", 2) + if len(parts) == 2 && parts[1] != "" { + return "gid:" + strings.ToLower(parts[1]) + } + } + + // DM: derive from sender + if msg.Sender != "" && !msg.IsFromMe { + normalized := normalizeIdentifierForPortalID(msg.Sender) + if normalized != "" { + resolved := c.resolveContactPortalID(normalized) + resolved = c.resolveExistingDMPortalID(string(resolved)) + return string(resolved) + } + } + + // is_from_me DMs: derive from destination + if msg.IsFromMe && msg.CloudChatId != "" { + // chat_id for DMs is like "iMessage;-;+16692858317" or bare "email@example.com". + var recipient string + parts := strings.Split(msg.CloudChatId, ";") + if len(parts) == 3 { + // Standard service-prefixed format: "iMessage;-;recipient" + recipient = parts[2] + } else if !strings.Contains(msg.CloudChatId, ";") { + // Bare chatId (no service prefix) — legacy format or recycle-bin + // restored messages. Treat the whole CloudChatId as the recipient. + recipient = msg.CloudChatId + } + if recipient != "" { + normalized := normalizeIdentifierForPortalID(recipient) + // Only use recipient if it's a real phone/email. + // CloudKit uses "chat" identifiers for self-chat, which + // normalizeIdentifierForPortalID returns unchanged (no tel:/mailto: + // prefix). Using those raw creates junk portals. + if normalized != "" && (strings.HasPrefix(normalized, "tel:") || strings.HasPrefix(normalized, "mailto:")) { + resolved := c.resolveContactPortalID(normalized) + resolved = c.resolveExistingDMPortalID(string(resolved)) + return string(resolved) + } + } + } + + // Self-chat fallback: is_from_me messages where the recipient couldn't be + // determined. CloudKit uses unique "chat" identifiers for self-chat + // instead of the user's phone number, so the handler above can't extract a + // valid recipient. Route to the user's own handle (Notes to Self portal). + // + // Guard: only fall back to self-chat when the CloudChatId looks like an + // actual self-chat identifier (empty, or starts with "chat" + digits). + // If it contains "@", ";", or matches UUID/phone patterns it belongs to a + // remote DM or group — return "" so the message is skipped rather than + // misrouted to self-chat. This prevents deleted/splintered remote-chat + // messages from polluting the Notes to Self portal. + if msg.IsFromMe { + chatID := msg.CloudChatId + // Require an explicit "chat" identifier to route to self-chat. + // Empty chat_id is too broad — from-me messages from any conversation can + // arrive with no chat_id and must be skipped rather than dumped into Notes + // to Self. Genuine Notes-to-Self messages use Apple's "chat" format. + isSelfChatID := strings.HasPrefix(strings.ToLower(chatID), "chat") && isNumericSuffix(chatID[4:]) + if !isSelfChatID { + return "" + } + normalized := normalizeIdentifierForPortalID(c.handle) + if normalized != "" { + return normalized + } + } + + return "" +} + +func (c *IMClient) ingestCloudMessages( + ctx context.Context, + messages []rustpushgo.WrappedCloudSyncMessage, + preferredPortalID string, + counts *cloudSyncCounters, + attMap map[string]cloudAttachmentRow, +) error { + log := c.Main.Bridge.Log.With().Str("component", "cloud_sync").Logger() + + // Separate deleted messages from live messages up front. + // Deleted messages are removed from the DB (they may have been stored + // in a previous sync before the user deleted them in iCloud). + var deletedGUIDs []string + var liveMessages []rustpushgo.WrappedCloudSyncMessage + for _, msg := range messages { + if msg.Deleted { + counts.Deleted++ + if msg.Guid != "" { + deletedGUIDs = append(deletedGUIDs, msg.Guid) + } + continue + } + liveMessages = append(liveMessages, msg) + } + + // Remove deleted messages from DB. + if err := c.cloudStore.deleteMessageBatch(ctx, deletedGUIDs); err != nil { + return fmt.Errorf("failed to delete messages: %w", err) + } + + // Snapshot recentlyDeletedPortals so we can mark re-imported messages for + // deleted portals as deleted=TRUE. This closes the race where a periodic + // re-sync downloads messages for a portal that was just deleted: without + // this check, those messages would be inserted with deleted=FALSE and could + // trigger portal resurrection or spurious backfill on a future restart. + c.recentlyDeletedPortalsMu.RLock() + deletedPortalsSnapshot := make(map[string]bool, len(c.recentlyDeletedPortals)) + for id := range c.recentlyDeletedPortals { + deletedPortalsSnapshot[id] = true + } + c.recentlyDeletedPortalsMu.RUnlock() + + // Phase 1: Resolve portal IDs and build rows for live messages (no DB writes yet). + guids := make([]string, 0, len(liveMessages)) + for _, msg := range liveMessages { + if msg.Guid != "" { + guids = append(guids, msg.Guid) + } + } + existingSet, err := c.cloudStore.hasMessageBatch(ctx, guids) + if err != nil { + return fmt.Errorf("batch existence check failed: %w", err) + } + + // Reverse index: message_guid -> []attachment_guid, derived from attMap. + // + // CloudAttachment.AttachmentMeta.guid (aguid) is always shaped + // `at__` (see upstream rustpush + // imessage/cloud_messages.rs:445, serde rename "aguid"), so we can + // recover which message owns an attachment purely from the attachment + // record's own metadata, without relying on the parent message's + // attributedBody parse. + // + // This is the load-bearing fix for a class of silently-dropped + // attachments: when the wrapper's extract_attachment_guids_from_attributed_body + // returns [] (either because the body is in a format the + // NSAttributedString decoder doesn't understand, or because + // coder_decode_flattened panicked and got swallowed by catch_unwind), + // the pre-fix code path skipped the entire enrichment block below and + // wrote `attachments_json=""` into the DB — resulting in a Matrix event + // with just a space character where an image should have been. By + // merging in any attachments the aguid-suffix lookup finds, we recover + // every attachment we actually have in attMap regardless of what the + // NSAttributedString parser could see. + attachmentsByMessage := make(map[string][]string, len(attMap)) + // Case-insensitive companion index for attMap lookups. Rust's + // plist/serde decode preserves whatever casing Apple's backend wrote + // into `cm.aguid`, while extract_attachment_guids_from_attributed_body + // preserves whatever casing the NSAttributedString recorded. On some + // records these diverge (upper-case UUID in the message, lower-case in + // the attachment record, or vice versa), so a direct map lookup + // misses even though both sides refer to the same attachment. Keyed + // by strings.ToLower(aguid) → original aguid string used as attMap + // key, so the fallback below can resolve either casing. + attMapLowercased := make(map[string]string, len(attMap)) + for attGUID := range attMap { + if !strings.HasPrefix(attGUID, "at_") { + continue + } + attMapLowercased[strings.ToLower(attGUID)] = attGUID + // Split at__ by finding the second underscore. + rest := attGUID[len("at_"):] + underscore := strings.IndexByte(rest, '_') + if underscore <= 0 || underscore == len(rest)-1 { + continue + } + msgGUID := rest[underscore+1:] + attachmentsByMessage[msgGUID] = append(attachmentsByMessage[msgGUID], attGUID) + // ALSO index by the lowercased msg_guid suffix so attributedBody + // parses that extract the UUID in the opposite case still hit. + msgGUIDLower := strings.ToLower(msgGUID) + if msgGUIDLower != msgGUID { + attachmentsByMessage[msgGUIDLower] = append(attachmentsByMessage[msgGUIDLower], attGUID) + } + } + + batch := make([]cloudMessageRow, 0, len(liveMessages)) + for _, msg := range liveMessages { + if msg.Guid == "" { + log.Warn(). + Str("cloud_chat_id", msg.CloudChatId). + Str("sender", msg.Sender). + Bool("is_from_me", msg.IsFromMe). + Int64("timestamp_ms", msg.TimestampMs). + Msg("Skipping message with empty GUID") + counts.Skipped++ + continue + } + + // NOTE: msgType=0 is a REGULAR user message in CloudKit — do NOT filter + // it. System/service messages are already filtered on the Rust side using + // IS_SYSTEM_MESSAGE / IS_SERVICE_MESSAGE flags. A previous version of this + // code incorrectly assumed msgType=0 was a system record and filtered all + // regular messages, causing cloud_message to remain empty. + + portalID := c.resolveConversationID(ctx, msg) + if portalID == "" { + portalID = preferredPortalID + } + if portalID == "" { + log.Warn(). + Str("guid", msg.Guid). + Str("cloud_chat_id", msg.CloudChatId). + Str("sender", msg.Sender). + Bool("is_from_me", msg.IsFromMe). + Int64("timestamp_ms", msg.TimestampMs). + Str("service", msg.Service). + Msg("Skipping message: could not resolve portal ID") + counts.Skipped++ + continue + } + + // Skip messages for portals that have been explicitly deleted/tombstoned. + // A cloud_chat row with deleted=TRUE means the user or Apple deleted this + // conversation. CloudKit re-sync always re-delivers stale messages for + // deleted chats; we must NOT revive them here — only explicit recovery + // (RecoverChat APNs, !restore-chat) should un-delete a portal. + // + // Portals with NO cloud_chat row at all are allowed through when the + // message has a non-empty CloudChatId. This covers recycle-bin-only chats + // on fresh backfill: the chat was deleted before the bridge's first sync + // so it never appeared in the main chat zone, but its messages are still + // in the main message zone. seedDeletedChatsFromRecycleBin (runs after + // this sync) seeds live cloud_chat rows for them. + if c.cloudStore != nil { + if isTombstoned, err := c.cloudStore.portalIsExplicitlyDeleted(ctx, portalID); err == nil && isTombstoned { + log.Debug(). + Str("guid", msg.Guid). + Str("portal_id", portalID). + Str("sender", msg.Sender). + Str("cloud_chat_id", msg.CloudChatId). + Msg("Skipping message for tombstoned/explicitly-deleted chat") + counts.Filtered++ + continue + } + } + + // Skip orphaned messages: no CloudChatId AND no cloud_chat record for the + // resolved portal. Apple omits filtered/junk chats from the chat zone + // entirely; messages from those chats have no chat_id and represent spam + // or unknown-sender conversations the user never sees in iMessage. + // Messages with a non-empty CloudChatId are allowed through even without + // a cloud_chat row — they may belong to recycle-bin-only chats. + if msg.CloudChatId == "" && c.cloudStore != nil { + if hasChat, err := c.cloudStore.portalHasChat(ctx, portalID); err == nil && !hasChat { + log.Debug(). + Str("guid", msg.Guid). + Str("portal_id", portalID). + Str("sender", msg.Sender). + Msg("Skipping orphaned message (no chat_id, no chat record)") + counts.Filtered++ + continue + } + } + + text := "" + if msg.Text != nil { + text = *msg.Text + } + subject := "" + if msg.Subject != nil { + subject = *msg.Subject + } + timestampMS := msg.TimestampMs + if timestampMS <= 0 { + timestampMS = time.Now().UnixMilli() + } + + tapbackTargetGUID := "" + if msg.TapbackTargetGuid != nil { + tapbackTargetGUID = *msg.TapbackTargetGuid + } + tapbackEmoji := "" + if msg.TapbackEmoji != nil { + tapbackEmoji = *msg.TapbackEmoji + } + + // Enrich and serialize attachment metadata. + // + // Merge two sources of attachment GUIDs so we don't silently drop + // any attachment the account actually has: + // 1. msg.AttachmentGuids — from the wrapper's NSAttributedString + // parse of the message proto's attributedBody. Authoritative + // for ORDER and may include guids for which the attachment + // record itself didn't sync (e.g. inline/tiny blobs). + // 2. attachmentsByMessage[msg.Guid] — from the attachment zone's + // own aguid field (`at__`). Load-bearing fallback + // for messages where the attributedBody parse returned []: + // without this, those messages ship to Matrix as an empty + // " " text event and the attachment is silently lost. + // + // Dedup by guid, preserving attributedBody order first. + attachmentsJSON := "" + if attMap != nil { + seen := make(map[string]struct{}, len(msg.AttachmentGuids)+4) + mergedGuids := make([]string, 0, len(msg.AttachmentGuids)+4) + for _, g := range msg.AttachmentGuids { + if g == "" { + continue + } + if _, ok := seen[g]; ok { + continue + } + seen[g] = struct{}{} + mergedGuids = append(mergedGuids, g) + } + fallback := attachmentsByMessage[msg.Guid] + if len(fallback) > 0 { + // Deterministic order for the fallback set — sorting by + // the aguid string (which starts with `at__`) keeps + // the original attachment order for messages that have + // more than one attachment. + sort.Strings(fallback) + for _, g := range fallback { + if _, ok := seen[g]; ok { + continue + } + seen[g] = struct{}{} + mergedGuids = append(mergedGuids, g) + log.Info(). + Str("msg_guid", msg.Guid). + Str("att_guid", g). + Msg("Recovered attachment via aguid suffix match (attributedBody parse missed it)") + } + } + + // Also probe attachmentsByMessage by the lowercased msg.Guid + // suffix. Fresh reverse-index entries created during the + // case-insensitive indexing pass above pick up aguids that + // differ from the message's casing. + msgGuidLower := strings.ToLower(msg.Guid) + if msgGuidLower != msg.Guid { + for _, g := range attachmentsByMessage[msgGuidLower] { + if _, ok := seen[g]; ok { + continue + } + seen[g] = struct{}{} + mergedGuids = append(mergedGuids, g) + log.Info(). + Str("msg_guid", msg.Guid). + Str("att_guid", g). + Msg("Recovered attachment via case-insensitive aguid suffix match") + } + } + + if len(mergedGuids) > 0 { + var attRows []cloudAttachmentRow + for _, guid := range mergedGuids { + if enriched, ok := attMap[guid]; ok { + attRows = append(attRows, enriched) + } else if origKey, ok := attMapLowercased[strings.ToLower(guid)]; ok { + // Case mismatch: the message's attributedBody has one + // casing, the attachment record has the other. Look up + // the actual attMap entry under its original key. + log.Info(). + Str("msg_guid", msg.Guid). + Str("att_guid_from_message", guid). + Str("att_guid_in_attmap", origKey). + Msg("Recovered attachment via case-insensitive attMap lookup") + attRows = append(attRows, attMap[origKey]) + } else { + log.Warn().Str("msg_guid", msg.Guid).Str("att_guid", guid). + Msg("Attachment GUID not found in attachment zone") + } + } + if len(attRows) > 0 { + if attJSON, jsonErr := json.Marshal(attRows); jsonErr == nil { + attachmentsJSON = string(attJSON) + } + } + } + } + + // Mark as deleted if the portal is currently being deleted, so + // concurrent or future re-syncs don't resurrect the portal. + isDeleted := deletedPortalsSnapshot[portalID] + + batch = append(batch, cloudMessageRow{ + GUID: msg.Guid, + RecordName: msg.RecordName, + CloudChatID: msg.CloudChatId, + PortalID: portalID, + TimestampMS: timestampMS, + Sender: msg.Sender, + IsFromMe: msg.IsFromMe, + Text: text, + Subject: subject, + Service: msg.Service, + Deleted: isDeleted, + TapbackType: msg.TapbackType, + TapbackTargetGUID: tapbackTargetGUID, + TapbackEmoji: tapbackEmoji, + AttachmentsJSON: attachmentsJSON, + DateReadMS: msg.DateReadMs, + HasBody: msg.HasBody, + }) + + if existingSet[msg.Guid] { + counts.Updated++ + } else { + counts.Imported++ + } + } + + // Phase 2: Batch insert all live rows in a single transaction. + if err := c.cloudStore.upsertMessageBatch(ctx, batch); err != nil { + return err + } + + return nil +} + +func (c *IMClient) resolvePortalIDForCloudChat(participants []string, displayName *string, groupID string, style int64) string { + normalizedParticipants := make([]string, 0, len(participants)) + for _, participant := range participants { + normalized := normalizeIdentifierForPortalID(participant) + if normalized == "" { + continue + } + normalizedParticipants = append(normalizedParticipants, normalized) + } + if len(normalizedParticipants) == 0 { + return "" + } + + // CloudKit chat style: 43 = group, 45 = DM. + // Use style as the authoritative group/DM signal. The group_id (gid) + // field is set for ALL CloudKit chats, even DMs, so we can't use its + // presence alone. + isGroup := style == 43 + + // For groups with a persistent group UUID, use gid: as portal ID + if isGroup && groupID != "" { + normalizedGID := strings.ToLower(groupID) + return "gid:" + normalizedGID + } + + // For DMs: use the single remote participant as the portal ID + // (e.g., "tel:+15551234567" or "mailto:user@example.com"). + // Filter out our own handle so only the remote side remains. + remoteParticipants := make([]string, 0, len(normalizedParticipants)) + for _, p := range normalizedParticipants { + if !c.isMyHandle(p) { + remoteParticipants = append(remoteParticipants, p) + } + } + + if len(remoteParticipants) == 1 { + // Standard DM — portal ID is the remote participant. + // Use contact merging so that separate CloudKit chat records for + // the same contact (one per handle) resolve to a single portal. + handle := remoteParticipants[0] + resolved := c.resolveContactPortalID(handle) + if string(resolved) == handle { + resolved = networkid.PortalID(c.canonicalContactHandle(handle)) + } + resolved = c.resolveExistingDMPortalID(string(resolved)) + return string(resolved) + } + + // Self-chat (Notes to Self): all participants are our own handle. + // Use our handle as the portal ID directly. + if len(remoteParticipants) == 0 && len(normalizedParticipants) > 0 { + return normalizedParticipants[0] + } + + // Fallback for edge cases (unknown style, multi-participant without group style) + groupName := displayName + var senderGuidPtr *string + if isGroup && groupID != "" { + senderGuidPtr = &groupID + } + portalKey := c.makePortalKey(normalizedParticipants, groupName, nil, senderGuidPtr) + return string(portalKey.ID) +} + +func (c *IMClient) createPortalsFromCloudSync(ctx context.Context, log zerolog.Logger, pendingDeletePortals map[string]bool) { + if c.cloudStore == nil { + return + } + + // Get portal IDs sorted by newest message timestamp (most recent first). + // This includes both portals that have messages AND chat-only portals + // from cloud_chat (with 0 messages). Chat-only portals are included so + // conversations synced from CloudKit without any resolved messages still + // get bridge portals created. + portalInfos, err := c.cloudStore.listPortalIDsWithNewestTimestamp(ctx) + if err != nil { + log.Err(err).Msg("Failed to list cloud portal IDs with timestamps") + return + } + + if len(portalInfos) == 0 { + return + } + + // Tombstoned (deleted) chats are already removed from cloud_chat/cloud_message + // during ingestCloudChats, so they won't appear in portalInfos. No separate + // deleted_portal filter needed — CloudKit tombstones are the authoritative signal. + + // Check recentlyDeletedPortals live (not a snapshot) so that recoveries + // that arrive during CloudKit paging are respected. If a portal is removed + // from the map by handleChatRecover between now and when we check it below, + // the portal won't be skipped. + + // Count how many portals have messages vs chat-only (diagnostic). + chatOnlyPortals := 0 + for _, p := range portalInfos { + if p.MessageCount == 0 { + chatOnlyPortals++ + } + } + log.Info(). + Int("total_portals", len(portalInfos)). + Int("with_messages", len(portalInfos)-chatOnlyPortals). + Int("chat_only", chatOnlyPortals). + Msg("Portal candidates from cloud sync (messages + chat-only)") + + // Skip portals already queued this session with the same newest timestamp. + // If CloudKit has newer messages, the timestamp changes and we re-queue. + if c.queuedPortals == nil { + c.queuedPortals = make(map[string]int64) + } + ordered := make([]string, 0, len(portalInfos)) + newestTSByPortal := make(map[string]int64, len(portalInfos)) + forwardBackfillPortals := 0 + alreadyQueued := 0 + pendingDeleteSkipped := 0 + groupDedupSkipped := 0 + seenGroupKeys := make(map[string]string) // dedup key → chosen portal_id + for _, p := range portalInfos { + newestTSByPortal[p.PortalID] = p.NewestTS + // Skip portals that are recently deleted this session. + // Checked live (not from a static snapshot) so that mid-sync + // recoveries via handleChatRecover are respected immediately. + c.recentlyDeletedPortalsMu.RLock() + _, isDeleted := c.recentlyDeletedPortals[p.PortalID] + c.recentlyDeletedPortalsMu.RUnlock() + if isDeleted { + pendingDeleteSkipped++ + continue + } + // Dedup group portals: the same group can appear under multiple + // portal_ids (gid: vs gid:). Use the group + // dedup key to detect duplicates and keep only the portal_id + // that already has a bridge portal, or the first one (most + // recent messages since list is sorted by newest_ts DESC). + if isGroupPortalID(p.PortalID) { + groupID := "" + if strings.HasPrefix(p.PortalID, "gid:") { + groupID = c.cloudStore.getGroupIDForPortalID(ctx, p.PortalID) + } + key := groupPortalDedupKey(p.PortalID, groupID, nil) + if existingPortalID, seen := seenGroupKeys[key]; seen { + // Already have a candidate for this group. Prefer + // whichever has an existing bridge portal. + existingKey := networkid.PortalKey{ID: networkid.PortalID(existingPortalID), Receiver: c.UserLogin.ID} + newKey := networkid.PortalKey{ID: networkid.PortalID(p.PortalID), Receiver: c.UserLogin.ID} + existingPortal, _ := c.UserLogin.Bridge.GetExistingPortalByKey(ctx, existingKey) + newPortal, _ := c.UserLogin.Bridge.GetExistingPortalByKey(ctx, newKey) + existingHasRoom := existingPortal != nil && existingPortal.MXID != "" + newHasRoom := newPortal != nil && newPortal.MXID != "" + if newHasRoom && !existingHasRoom { + // Swap: the new one has the bridge portal, use it instead + log.Info(). + Str("kept_portal_id", p.PortalID). + Str("skipped_portal_id", existingPortalID). + Str("dedup_key", key). + Msg("Group dedup: swapping to portal with existing bridge room") + seenGroupKeys[key] = p.PortalID + // Replace in ordered list + for j, id := range ordered { + if id == existingPortalID { + ordered[j] = p.PortalID + break + } + } + } else { + log.Info(). + Str("kept_portal_id", existingPortalID). + Str("skipped_portal_id", p.PortalID). + Str("dedup_key", key). + Msg("Group dedup: skipping duplicate group portal") + } + groupDedupSkipped++ + continue + } + seenGroupKeys[key] = p.PortalID + } + if lastTS, ok := c.queuedPortals[p.PortalID]; ok && lastTS >= p.NewestTS { + alreadyQueued++ + continue + } + portalKey := networkid.PortalKey{ID: networkid.PortalID(p.PortalID), Receiver: c.UserLogin.ID} + existingPortal, _ := c.UserLogin.Bridge.GetExistingPortalByKey(ctx, portalKey) + if p.MessageCount > 0 && (existingPortal == nil || existingPortal.MXID == "") { + forwardBackfillPortals++ + } + ordered = append(ordered, p.PortalID) + } + if pendingDeleteSkipped > 0 { + log.Info().Int("skipped", pendingDeleteSkipped).Msg("Skipped tombstoned or recently deleted portals") + } + if groupDedupSkipped > 0 { + log.Info().Int("skipped", groupDedupSkipped).Msg("Skipped duplicate group portal IDs (same group, different UUID)") + } + + portalStart := time.Now() + log.Info(). + Int("total_candidates", len(portalInfos)). + Int("already_queued", alreadyQueued). + Int("group_dedup_skipped", groupDedupSkipped). + Int("to_process", len(ordered)). + Msg("Creating portals from cloud sync") + + // Set pendingInitialBackfills BEFORE queuing any portals. + // bridgev2 processes ChatResync events concurrently (QueueRemoteEvent is + // async), so FetchMessages(Forward=true) calls — and their + // onForwardBackfillDone decrements — can fire during the queuing loop. + // If we set the counter AFTER the loop (StoreInt64 at the end), early + // decrements land on 0 (making it negative), then the Store overwrites + // those decrements with N, leaving the counter stuck at the number of + // early completions rather than reaching 0. Setting it up-front ensures + // every decrement is counted correctly. + // + // Count ONLY portals that actually have message history. Chat-only portals + // still get ChatResync events so rooms are created, but GetChatInfo sets + // CanBackfill=false for them, so bridgev2 never calls FetchMessages(Forward) + // and they must not hold the APNs buffer open. + if !c.isCloudSyncDone() { + atomic.StoreInt64(&c.pendingInitialBackfills, int64(forwardBackfillPortals)) + log.Debug(). + Int("count", forwardBackfillPortals). + Int("chat_only_or_existing", len(ordered)-forwardBackfillPortals). + Msg("Set pendingInitialBackfills for APNs buffer hold") + } + + created := 0 + for i, portalID := range ordered { + portalKey := networkid.PortalKey{ + ID: networkid.PortalID(portalID), + Receiver: c.UserLogin.ID, + } + + newestTS := newestTSByPortal[portalID] + var latestMessageTS time.Time + if newestTS > 0 { + latestMessageTS = time.UnixMilli(newestTS) + } + log.Debug(). + Str("portal_id", portalID). + Int("index", i). + Int("total", len(ordered)). + Int64("newest_ts", newestTS). + Msg("Queuing ChatResync for portal") + c.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventChatResync, + PortalKey: portalKey, + CreatePortal: true, + LogContext: func(lc zerolog.Context) zerolog.Context { + return lc. + Str("portal_id", portalID). + Str("source", "cloud_sync") + }, + }, + GetChatInfoFunc: c.GetChatInfo, + LatestMessageTS: latestMessageTS, + }) + c.queuedPortals[portalID] = newestTS + created++ + if (i+1)%25 == 0 { + log.Info(). + Int("progress", i+1). + Int("total", len(ordered)). + Dur("elapsed", time.Since(portalStart)). + Msg("Portal queuing progress") + } + // Stagger portal processing to avoid overwhelming Matrix server + // with concurrent ghost updates and room state changes. + if (i+1)%5 == 0 { + time.Sleep(500 * time.Millisecond) + } + } + + log.Info(). + Int("queued", created). + Int("total", len(ordered)). + Dur("elapsed", time.Since(portalStart)). + Msg("Finished queuing portals from cloud sync") + + // pendingInitialBackfills was already set BEFORE the loop + // so that early-completing FetchMessages(Forward=true) calls are counted + // correctly (see comment above the loop). Nothing more to do here. + + // Reset backward backfill tasks for portals that already have Matrix rooms. + // This handles the version-upgrade re-sync case where rooms exist but the + // backfill task may be marked done from a previous incomplete sync. + // + // For NEW portals (fresh DB, no rooms yet), we do NOT create tasks here. + // bridgev2 automatically creates backfill tasks when it creates the Matrix + // room during ChatResync processing (portal.go calls BackfillTask.Upsert + // when CanBackfill=true). Creating tasks before rooms exist causes a race: + // the backfill queue picks up the task, finds portal.MXID=="", and + // permanently deletes it via deleteBackfillQueueTaskIfRoomDoesNotExist. + resetCount := 0 + skippedNoRoom := 0 + for _, portalID := range ordered { + portalKey := networkid.PortalKey{ + ID: networkid.PortalID(portalID), + Receiver: c.UserLogin.ID, + } + portal, err := c.Main.Bridge.GetExistingPortalByKey(ctx, portalKey) + if err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to look up portal for backfill task") + continue + } + if portal == nil || portal.MXID == "" { + skippedNoRoom++ + continue + } + if err := c.Main.Bridge.DB.BackfillTask.Upsert(ctx, &database.BackfillTask{ + PortalKey: portalKey, + UserLoginID: c.UserLogin.ID, + BatchCount: -1, + IsDone: false, + Cursor: "", + OldestMessageID: "", + NextDispatchMinTS: time.Now().Add(5 * time.Second), + }); err != nil { + log.Warn().Err(err).Str("portal_id", portalID).Msg("Failed to reset backfill task") + } else { + resetCount++ + } + } + if resetCount > 0 || skippedNoRoom > 0 { + log.Info(). + Int("reset_count", resetCount). + Int("skipped_no_room", skippedNoRoom). + Msg("Backward backfill task setup (rooms without tasks reset, new portals deferred to ChatResync)") + if resetCount > 0 { + c.Main.Bridge.WakeupBackfillQueue() + } + } +} + +func (c *IMClient) ensureCloudSyncStore(ctx context.Context) error { + if c.cloudStore == nil { + return fmt.Errorf("cloud store not initialized") + } + return c.cloudStore.ensureSchema(ctx) +} + +// filenameBase returns the filename without its extension. +// e.g., "IMG_5551.HEIC" → "IMG_5551", "photo.MOV" → "photo" +func filenameBase(name string) string { + if idx := strings.LastIndex(name, "."); idx > 0 { + return name[:idx] + } + return "" +} diff --git a/pkg/connector/urlpreview.go b/pkg/connector/urlpreview.go new file mode 100644 index 00000000..826a90f0 --- /dev/null +++ b/pkg/connector/urlpreview.go @@ -0,0 +1,334 @@ +package connector + +import ( + "context" + "crypto/tls" + "fmt" + "html" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// metaTagRegex generically matches all tags with property/name and content +// attributes, in either attribute order. Captures the full attribute name (including +// any namespace prefix like og:, twitter:, etc.) and the content value. +// Double-quoted values only to avoid apostrophe truncation. +var metaTagRegex = regexp.MustCompile( + `(?i)]*(?:property|name)="([^"]+)"[^>]*content="([^"]*)"[^>]*/?\s*>` + + `|` + + `(?i)]*content="([^"]*)"[^>]*(?:property|name)="([^"]+)"[^>]*/?\s*>`, +) + +// titleRegex extracts the tag content as a last-resort fallback. +var titleRegex = regexp.MustCompile(`(?i)<title[^>]*>([^<]+)`) + +// fetchURLPreview builds a BeeperLinkPreview by fetching the target URL's +// metadata. It tries the homeserver's /preview_url first, then falls back to +// fetching the HTML and parsing meta tags directly (og:, twitter:, standard +// HTML meta, ). If an image is found, it is downloaded and uploaded +// via the provided intent. +func fetchURLPreview(ctx context.Context, bridge *bridgev2.Bridge, intent bridgev2.MatrixAPI, roomID id.RoomID, targetURL string) *event.BeeperLinkPreview { + log := zerolog.Ctx(ctx) + + fetchURL := normalizeURL(targetURL) + + preview := &event.BeeperLinkPreview{ + MatchedURL: targetURL, + LinkPreview: event.LinkPreview{ + CanonicalURL: fetchURL, + Title: targetURL, + }, + } + + // Try homeserver preview first + if mc, ok := bridge.Matrix.(bridgev2.MatrixConnectorWithURLPreviews); ok { + lp, err := mc.GetURLPreview(ctx, fetchURL) + if err != nil { + log.Debug().Err(err).Str("url", fetchURL).Msg("Homeserver URL preview failed, falling back to meta scraping") + } + if err == nil && lp != nil { + preview.LinkPreview = *lp + if preview.CanonicalURL == "" { + preview.CanonicalURL = targetURL + } + if preview.Title == "" { + preview.Title = targetURL + } + // If homeserver already returned an image, we're done + if preview.ImageURL != "" { + return preview + } + } + } + + // Fetch the page ourselves and parse metadata from all meta tag formats + meta := fetchPageMetadata(ctx, fetchURL) + if meta["title"] != "" && preview.Title == targetURL { + preview.Title = meta["title"] + } + if meta["description"] != "" && preview.Description == "" { + preview.Description = meta["description"] + } + + // Download and upload image + imageURL := meta["image"] + if imageURL == "" { + imageURL = meta["image:secure_url"] + } + if imageURL != "" && intent != nil { + // Resolve relative URLs against the normalized (https://) URL + if !strings.HasPrefix(imageURL, "http") { + if base, err := url.Parse(fetchURL); err == nil { + if ref, err := url.Parse(imageURL); err == nil { + imageURL = base.ResolveReference(ref).String() + } + } + } + data, mime, err := downloadURL(ctx, imageURL) + if err == nil && len(data) > 0 { + mxcURL, encFile, err := intent.UploadMedia(ctx, roomID, data, "preview", mime) + if err == nil { + if encFile != nil { + preview.ImageEncryption = encFile + preview.ImageURL = encFile.URL + } else { + preview.ImageURL = mxcURL + } + preview.ImageType = mime + preview.ImageSize = event.IntOrString(len(data)) + log.Debug().Str("image_url", imageURL).Msg("Uploaded URL preview image") + } else { + log.Debug().Err(err).Msg("Failed to upload URL preview image") + } + } else if err != nil { + log.Debug().Err(err).Str("image_url", imageURL).Msg("Failed to download URL preview image") + } + } + + return preview +} + +// userAgents lists User-Agent strings to try when fetching page metadata. +// Many modern sites (JS SPAs) only serve meta tags to recognized social media +// crawlers — regular browser UAs get a JS shell with no metadata. We lead +// with crawler UAs that are universally whitelisted for link unfurling, then +// fall back to browser UAs for traditional sites. +// +// 1. facebookexternalhit — most widely whitelisted crawler; virtually every +// site serves og: tags to Facebook's crawler for link unfurling. +// 2. WhatsApp — also very widely whitelisted for the same reason. +// 3. iPhone Safari — for sites that serve metadata to real mobile browsers. +// 4. Googlebot — last resort; some sites verify by reverse DNS but many don't. +var userAgents = []string{ + "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatype.html)", + "WhatsApp/2.23.2 A", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "Googlebot/2.1 (+http://www.google.com/bot.html)", +} + +// fetchPageMetadata fetches a URL and extracts preview metadata from the HTML. +// It tries multiple User-Agent strings and returns a unified map with keys +// "title", "description", "image", "image:secure_url" resolved from all meta +// tag formats (og:, twitter:, standard HTML meta, <title> fallback). +func fetchPageMetadata(ctx context.Context, targetURL string) map[string]string { + log := zerolog.Ctx(ctx) + + for _, ua := range userAgents { + result := fetchPageMetadataWithUA(ctx, targetURL, ua) + if result["image"] != "" || result["title"] != "" { + log.Debug().Str("ua", ua).Str("url", targetURL).Str("title", result["title"]). + Bool("has_image", result["image"] != "").Msg("Found page metadata") + return result + } + log.Debug().Str("ua", ua).Str("url", targetURL).Msg("No metadata found with this UA, trying next") + } + + return make(map[string]string) +} + +// ogHTTPClient uses TLS 1.3 to avoid bot detection via TLS fingerprinting. +// Sites like Reddit reject Go's default TLS 1.2 handshake with HTTP 403. +var ogHTTPClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + }, + }, +} + +// fetchPageMetadataWithUA fetches a URL with a specific User-Agent and extracts +// metadata from ALL meta tag formats. It parses every <meta> tag generically, +// then resolves the best title/description/image using a priority cascade: +// +// og: > twitter: > standard HTML meta (name="description") > <title> tag +// +// This ensures we get metadata from any site regardless of which meta tag +// convention it uses. +func fetchPageMetadataWithUA(ctx context.Context, targetURL string, ua string) map[string]string { + result := make(map[string]string) + log := zerolog.Ctx(ctx) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return result + } + req.Header.Set("User-Agent", ua) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + // Note: Do NOT set Accept-Encoding manually. Go's Transport automatically + // handles gzip and transparently decompresses. Setting it explicitly + // disables auto-decompression, causing us to read raw gzip bytes. + + resp, err := ogHTTPClient.Do(req) + if err != nil { + log.Debug().Err(err).Str("url", targetURL).Str("ua", ua).Msg("HTTP request failed for meta scraping") + return result + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + log.Debug().Int("status", resp.StatusCode).Str("url", targetURL).Str("ua", ua).Msg("HTTP error for meta scraping") + return result + } + + // Read first 512KB — meta tags should be in <head> but some sites + // inline hundreds of KB of CSS/JS before their meta tags. + data, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + if len(data) == 0 { + return result + } + log.Debug().Int("status", resp.StatusCode).Int("body_bytes", len(data)).Str("url", targetURL).Str("ua", ua).Msg("Fetched page for meta scraping") + htmlStr := string(data) + + // Parse ALL <meta> tags generically into namespaced buckets. + // e.g. "og:title" -> ogTags["title"], "twitter:image" -> twitterTags["image"], + // "description" -> stdTags["description"] + ogTags := make(map[string]string) + twitterTags := make(map[string]string) + stdTags := make(map[string]string) + + for _, match := range metaTagRegex.FindAllStringSubmatch(htmlStr, -1) { + var name, content string + if match[1] != "" { + name, content = match[1], match[2] + } else { + name, content = match[4], match[3] + } + name = strings.ToLower(name) + content = html.UnescapeString(content) + + switch { + case strings.HasPrefix(name, "og:"): + key := name[3:] // strip "og:" prefix + if _, exists := ogTags[key]; !exists { + ogTags[key] = content + } + case strings.HasPrefix(name, "twitter:"): + key := name[8:] // strip "twitter:" prefix + if _, exists := twitterTags[key]; !exists { + twitterTags[key] = content + } + default: + if _, exists := stdTags[name]; !exists { + stdTags[name] = content + } + } + } + + // Resolve with priority cascade: og: > twitter: > standard meta + // Title + switch { + case ogTags["title"] != "": + result["title"] = ogTags["title"] + case twitterTags["title"] != "": + result["title"] = twitterTags["title"] + case stdTags["title"] != "": + result["title"] = stdTags["title"] + } + + // Description + switch { + case ogTags["description"] != "": + result["description"] = ogTags["description"] + case twitterTags["description"] != "": + result["description"] = twitterTags["description"] + case stdTags["description"] != "": + result["description"] = stdTags["description"] + } + + // Image + switch { + case ogTags["image"] != "": + result["image"] = ogTags["image"] + case ogTags["image:secure_url"] != "": + result["image"] = ogTags["image:secure_url"] + case twitterTags["image"] != "": + result["image"] = twitterTags["image"] + case twitterTags["image:src"] != "": + result["image"] = twitterTags["image:src"] + } + + // Also expose image:secure_url if present (used by fetchURLPreview) + if ogTags["image:secure_url"] != "" { + result["image:secure_url"] = ogTags["image:secure_url"] + } + + // Fall back to <title> tag if no title was found from any meta tags + if result["title"] == "" { + if m := titleRegex.FindStringSubmatch(htmlStr); m != nil { + title := strings.TrimSpace(html.UnescapeString(m[1])) + if title != "" { + result["title"] = title + } + } + } + + return result +} + +// downloadURL fetches a URL and returns the body bytes and content type. +// Uses the shared TLS 1.3 client to avoid bot detection. +func downloadURL(ctx context.Context, targetURL string) ([]byte, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return nil, "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15") + + resp, err := ogHTTPClient.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, "", fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, targetURL) + } + + // Limit to 5MB + data, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) + if err != nil { + return nil, "", err + } + + mime := resp.Header.Get("Content-Type") + if i := strings.Index(mime, ";"); i >= 0 { + mime = strings.TrimSpace(mime[:i]) + } + if mime == "" { + mime = "image/jpeg" + } + + return data, mime, nil +} diff --git a/pkg/connector/util.go b/pkg/connector/util.go new file mode 100644 index 00000000..b65a5bb4 --- /dev/null +++ b/pkg/connector/util.go @@ -0,0 +1,115 @@ +package connector + +import ( + "strings" + "unicode" +) + +// normalizePhone strips all non-digit characters (except leading +). +func normalizePhone(phone string) string { + var b strings.Builder + for i, r := range phone { + if r == '+' && i == 0 { + b.WriteRune(r) + } else if unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + +// phoneSuffixes returns the number and its last 10/7 digits for flexible matching. +func phoneSuffixes(phone string) []string { + n := normalizePhone(phone) + if n == "" { + return nil + } + suffixes := []string{n} + // Strip leading + for matching + without := strings.TrimPrefix(n, "+") + if without != n { + suffixes = append(suffixes, without) + } + // Last 10 digits (US number without country code) + if len(without) > 10 { + suffixes = append(suffixes, without[len(without)-10:]) + } + // Last 7 digits (local number) + if len(without) > 7 { + suffixes = append(suffixes, without[len(without)-7:]) + } + return suffixes +} + +// stripNonBase64 removes all characters that are not valid in base64 encoding. +// This handles garbage injected by chat UIs (non-breaking spaces, newlines, etc.). +func stripNonBase64(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '+' || r == '/' || r == '=' { + b.WriteRune(r) + } + } + return b.String() +} + +// stripSmsSuffix removes Apple SMS service suffixes such as "(smsfp)" or +// "(smsft)" that appear on identifiers in chat.db. +func stripSmsSuffix(id string) string { + if idx := strings.Index(id, "(sms"); idx > 0 { + // Verify the suffix is at the end of the string with a closing paren. + if closeIdx := strings.Index(id[idx:], ")"); closeIdx >= 0 && idx+closeIdx+1 == len(id) { + return id[:idx] + } + } + return id +} + +// stripIdentifierPrefix removes tel: or mailto: prefix from an identifier. +func stripIdentifierPrefix(id string) string { + id = strings.TrimPrefix(id, "tel:") + id = strings.TrimPrefix(id, "mailto:") + return id +} + +// addIdentifierPrefix adds the appropriate tel:/mailto: prefix to a raw identifier +// so it matches the portal/ghost ID format used by rustpush. +func addIdentifierPrefix(localID string) string { + if strings.HasPrefix(localID, "tel:") || strings.HasPrefix(localID, "mailto:") { + return localID + } + if strings.Contains(localID, "@") { + return "mailto:" + localID + } + if strings.HasPrefix(localID, "+") || isNumeric(localID) { + return "tel:" + localID + } + return localID +} + +// isNumeric returns true if the string contains only digits. +func isNumeric(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +// identifierToDisplaynameParams converts a portal/ghost identifier to +// DisplaynameParams for contact name formatting. +func identifierToDisplaynameParams(identifier string) DisplaynameParams { + localID := stripIdentifierPrefix(identifier) + if strings.HasPrefix(localID, "+") { + return DisplaynameParams{Phone: localID, ID: localID} + } + if strings.Contains(localID, "@") { + return DisplaynameParams{Email: localID, ID: localID} + } + return DisplaynameParams{ID: localID} +} diff --git a/pkg/connector/util_test.go b/pkg/connector/util_test.go new file mode 100644 index 00000000..2291b0ec --- /dev/null +++ b/pkg/connector/util_test.go @@ -0,0 +1,159 @@ +package connector + +import ( + "testing" +) + +func TestNormalizePhone(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"+1 (555) 123-4567", "+15551234567"}, + {"15551234567", "15551234567"}, + {"+1-555-123-4567", "+15551234567"}, + {"", ""}, + {"+", "+"}, + {"abc", ""}, + {"+44 20 7946 0958", "+442079460958"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := normalizePhone(tt.input) + if got != tt.want { + t.Errorf("normalizePhone(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestPhoneSuffixes(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"", nil}, + {"+15551234567", []string{"+15551234567", "15551234567", "5551234567", "1234567"}}, + {"+4412345678901", []string{"+4412345678901", "4412345678901", "2345678901", "5678901"}}, + {"5551234", []string{"5551234"}}, + {"+1234567", []string{"+1234567", "1234567"}}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := phoneSuffixes(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("phoneSuffixes(%q) returned %d items, want %d: %v", tt.input, len(got), len(tt.want), got) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("phoneSuffixes(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestStripNonBase64(t *testing.T) { + tests := []struct { + input, want string + }{ + {"SGVsbG8=", "SGVsbG8="}, + {"SGVs bG8=", "SGVsbG8="}, + {"abc" + "!@#" + "def", "abcdef"}, + {"", ""}, + {"ABCD+/==", "ABCD+/=="}, + } + for _, tt := range tests { + got := stripNonBase64(tt.input) + if got != tt.want { + t.Errorf("stripNonBase64(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestStripIdentifierPrefix(t *testing.T) { + tests := []struct { + input, want string + }{ + {"tel:+15551234567", "+15551234567"}, + {"mailto:user@example.com", "user@example.com"}, + {"+15551234567", "+15551234567"}, + {"user@example.com", "user@example.com"}, + {"", ""}, + {"tel:mailto:nested", "nested"}, + } + for _, tt := range tests { + got := stripIdentifierPrefix(tt.input) + if got != tt.want { + t.Errorf("stripIdentifierPrefix(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestAddIdentifierPrefix(t *testing.T) { + tests := []struct { + input, want string + }{ + {"+15551234567", "tel:+15551234567"}, + {"user@example.com", "mailto:user@example.com"}, + {"12345", "tel:12345"}, + {"tel:+1555", "tel:+1555"}, + {"mailto:a@b.com", "mailto:a@b.com"}, + {"some-guid", "some-guid"}, + {"", ""}, + } + for _, tt := range tests { + got := addIdentifierPrefix(tt.input) + if got != tt.want { + t.Errorf("addIdentifierPrefix(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestIsNumeric(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"12345", true}, + {"0", true}, + {"", false}, + {"12a45", false}, + {"+1234", false}, + {"123.45", false}, + } + for _, tt := range tests { + got := isNumeric(tt.input) + if got != tt.want { + t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestIdentifierToDisplaynameParams(t *testing.T) { + tests := []struct { + input string + phone string + email string + id string + }{ + {"tel:+15551234567", "+15551234567", "", "+15551234567"}, + {"mailto:user@example.com", "", "user@example.com", "user@example.com"}, + {"+15551234567", "+15551234567", "", "+15551234567"}, + {"some-guid", "", "", "some-guid"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := identifierToDisplaynameParams(tt.input) + if got.Phone != tt.phone { + t.Errorf("Phone = %q, want %q", got.Phone, tt.phone) + } + if got.Email != tt.email { + t.Errorf("Email = %q, want %q", got.Email, tt.email) + } + if got.ID != tt.id { + t.Errorf("ID = %q, want %q", got.ID, tt.id) + } + }) + } +} diff --git a/pkg/rustpushgo/Cargo.lock b/pkg/rustpushgo/Cargo.lock new file mode 100644 index 00000000..c9abe3c5 --- /dev/null +++ b/pkg/rustpushgo/Cargo.lock @@ -0,0 +1,4726 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-siv" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e08d0cdb774acd1e4dac11478b1a0c0d203134b2aab0ba25eb430de9b18f8b9" +dependencies = [ + "aead", + "aes", + "cipher", + "cmac", + "ctr", + "dbl", + "digest", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-loader" +version = "0.2.0" +source = "git+https://github.com/Dadoum/android-loader?branch=bigger_pages#dfa86501afca7caa23d5ce15322ac7260d857485" +dependencies = [ + "anyhow", + "lazy_static", + "libc", + "log", + "memmap2", + "rand 0.8.5", + "region", + "sysv64", + "xmas-elf", + "zero", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn 2.0.114", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-compression" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backon" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d67782c3f868daa71d3533538e98a8e13713231969def7536e8039606fc46bf0" +dependencies = [ + "fastrand", + "futures-core", + "pin-project", + "tokio", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.114", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "bitvec-nom2" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988fcc40055ceaa85edc55875a08f8abd29018582647fd82ad6128dba14a5f0" +dependencies = [ + "bitvec", + "nom", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cloudkit-derive" +version = "0.1.0" +dependencies = [ + "cloudkit-proto", + "deluxe", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cloudkit-proto" +version = "0.1.0" +dependencies = [ + "base64 0.21.7", + "disjoint_impls", + "plist", + "prost", + "prost-build", + "serde", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "const-oid" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c78c047431fee22c1a7bb92e00ad095a02a983affe4d8a72e2a2c62c1b94f3" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "cookie" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6" +dependencies = [ + "cookie", + "idna 0.3.0", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "dary_heap" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deku" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819b87cc7a05b3abe3fc38e59b3980a5fd3162f25a247116441a9171d3e84481" +dependencies = [ + "bitvec", + "deku_derive", +] + +[[package]] +name = "deku_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2ca12572239215a352a74ad7c776d7e8a914f8a23511c6cbedddd887e5009e" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "deluxe" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed332aaf752b459088acf3dd4eca323e3ef4b83c70a84ca48fb0ec5305f1488" +dependencies = [ + "deluxe-core", + "deluxe-macros", + "once_cell", + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "deluxe-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddada51c8576df9d6a8450c351ff63042b092c9458b8ac7d20f89cbd0ffd313" +dependencies = [ + "arrayvec", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "deluxe-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87546d9c837f0b7557e47b8bd6eae52c3c223141b76aa233c345c9ab41d9117" +dependencies = [ + "deluxe-core", + "heck 0.4.1", + "if_chain", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "der" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6919815d73839e7ad218de758883aae3a257ba6759ce7a9992501efbb53d705c" +dependencies = [ + "const-oid 0.7.1", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "disjoint_impls" +version = "1.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab975bef4e65e90e54d4d751096dbbf56f419aac0138798be0a1abd452750a4a" +dependencies = [ + "indexmap", + "itertools 0.14.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dlopen2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b121caccfc363e4d9a4589528f3bef7c71b83c6ed01c8dc68cbeeb7fd29ec698" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a09ac8bb8c16a282264c379dffba707b9c998afc7506009137f3c6136888078" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "libz-sys", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6b4de4a8eb6c46a8c77e1d3be942cb9a8bf073c22374578e5ba4b08ed0ff68" +dependencies = [ + "log", + "plain", + "scroll 0.11.0", +] + +[[package]] +name = "goblin" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "983a6aafb3b12d4c41ea78d39e189af4298ce747353945ff5105b54a056e5cd9" +dependencies = [ + "log", + "plain", + "scroll 0.13.0", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icloud_auth" +version = "0.1.0" +dependencies = [ + "aes", + "base64 0.13.1", + "cbc", + "hmac", + "log", + "num-bigint", + "omnisette", + "pbkdf2", + "pkcs7", + "plist", + "rand 0.8.5", + "reqwest", + "rustls 0.20.9", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "srp", + "thiserror 1.0.69", + "tokio", + "uuid", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keystore" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "openssl", + "plist", + "rand 0.8.5", + "serde", + "sha2", + "thiserror 1.0.69", +] + +[[package]] +name = "konst" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4381b9b00c55f251f2ebe9473aef7c117e96828def1a7cb3bd3f0f903c6894e9" +dependencies = [ + "const_panic", + "konst_kernel", + "typewit", +] + +[[package]] +name = "konst_kernel" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4b1eb7788f3824c629b1116a7a9060d6e898c358ebff59070093d51103dcc3c" +dependencies = [ + "typewit", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libflate" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" +dependencies = [ + "adler32", + "core2", + "crc32fast", + "dary_heap", + "libflate_lz77", +] + +[[package]] +name = "libflate_lz77" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" +dependencies = [ + "core2", + "hashbrown", + "rle-decode-fast", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nac-validation" +version = "0.1.0" +dependencies = [ + "cc", + "libc", + "thiserror 2.0.18", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.10.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "omnisette" +version = "0.1.0" +dependencies = [ + "android-loader", + "anyhow", + "async-trait", + "base64 0.21.7", + "chrono", + "dlopen2", + "futures-util", + "hex", + "libc", + "log", + "objc", + "objc-foundation", + "plist", + "rand 0.8.5", + "remove-async-await", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "uuid", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oneshot-uniffi" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548d5c78976f6955d72d0ced18c48ca07030f7a1d4024529fedd7c1c01b29c" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open-absinthe" +version = "1.0.0" +dependencies = [ + "base64 0.21.7", + "goblin 0.10.5", + "log", + "nac-validation", + "native-tls", + "rand 0.8.5", + "serde", + "serde_json", + "sha1", + "sha2", + "unicorn-engine", + "ureq", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs7" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7364e6d0e236473de91e042395d71e0e64715f99a60620b014a4a4c7d1619b" +dependencies = [ + "der 0.5.1", + "spki 0.5.4", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck 0.5.0", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.114", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna 1.1.0", + "psl-types", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rasn" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a659e5423da2252a602b220c3c58165a3b6b2fa8c88abf04b7118e7013f27d" +dependencies = [ + "arrayvec", + "bitvec", + "bitvec-nom2", + "bytes", + "chrono", + "either", + "konst", + "nom", + "num-bigint", + "num-integer", + "num-traits", + "once_cell", + "rasn-derive", + "snafu", +] + +[[package]] +name = "rasn-derive" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3935c98446174a7d802b4ac0f369daf7e8af8acdefe35e7d6e67f20b46d1c530" +dependencies = [ + "either", + "itertools 0.10.5", + "proc-macro2", + "quote", + "rayon", + "syn 1.0.109", + "uuid", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2", + "windows-sys 0.52.0", +] + +[[package]] +name = "remove-async-await" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0993102a683d0fb29c6053ad44d7ed7555f69b2fa5fe0fa3bba959a9aa4cd1" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "async-compression", + "base64 0.21.7", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.24.1", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring 0.17.14", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring 0.17.14", + "rustls-pki-types", + "rustls-webpki 0.103.11", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "aws-lc-rs", + "ring 0.17.14", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustpush" +version = "0.1.0" +dependencies = [ + "aes", + "aes-gcm", + "aes-siv", + "async-recursion", + "async-trait", + "backon", + "base64 0.21.7", + "bitflags 2.10.0", + "chrono", + "cloudkit-derive", + "cloudkit-proto", + "ctr", + "deku", + "flume", + "futures", + "hkdf", + "html-escape", + "icloud_auth", + "keystore", + "libflate", + "log", + "notify", + "num-bigint", + "omnisette", + "open-absinthe", + "openssl", + "plist", + "pretty_env_logger", + "prost", + "prost-build", + "rand 0.8.5", + "rasn", + "regex", + "reqwest", + "rustls 0.23.38", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "srp", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.26.4", + "uuid", + "x509-cert", + "xml-rs", + "zip", +] + +[[package]] +name = "rustpushgo" +version = "0.1.0" +dependencies = [ + "aes", + "aes-siv", + "async-trait", + "base64 0.21.7", + "cc", + "chrono", + "cloudkit-proto", + "ctr", + "futures", + "futures-util", + "hex", + "hkdf", + "icloud_auth", + "keystore", + "libc", + "log", + "nac-validation", + "omnisette", + "once_cell", + "open-absinthe", + "openssl", + "plist", + "pretty_env_logger", + "prost", + "rand 0.8.5", + "reqwest", + "rustls 0.23.38", + "rustpush", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "uniffi", + "uuid", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04c565b551bafbef4157586fa379538366e4385d42082f255bfd96e4fe8519da" +dependencies = [ + "scroll_derive 0.11.1", +] + +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" +dependencies = [ + "scroll_derive 0.13.1", +] + +[[package]] +name = "scroll_derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db149f81d46d2deba7cd3c50772474707729550221e69588478ebf9ada425ae" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "scroll_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snafu" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4de37ad025c587a29e8f3f5605c00f70b98715ef90b9061a815b9e59e9042d6" +dependencies = [ + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d01ac02a6ccf3e07db148d2be087da624fea0221a16152ed01f0496a6b0a27" +dependencies = [ + "der 0.5.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "srp" +version = "0.6.0" +dependencies = [ + "base64 0.21.7", + "digest", + "generic-array", + "lazy_static", + "num-bigint", + "subtle", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "sysv64" +version = "0.1.0" +source = "git+https://github.com/Dadoum/android-loader?branch=bigger_pages#dfa86501afca7caa23d5ce15322ac7260d857485" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.38", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", + "tungstenite", + "webpki-roots 0.25.4", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "rustls 0.21.12", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "typewit" +version = "1.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" +dependencies = [ + "typewit_proc_macros", +] + +[[package]] +name = "typewit_proc_macros" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e36a83ea2b3c704935a01b4642946aadd445cea40b10935e3f8bd8052b8193d6" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicorn-engine" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16b5d5186d9400cd3bf9f892ed826cb724f69b906bbe66811527df73eaf7a33" +dependencies = [ + "bindgen", + "cc", + "cmake", + "pkg-config", + "unicorn-engine-sys", +] + +[[package]] +name = "unicorn-engine-sys" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685282714d35a6fbfed0ae14d81bb11c91838bc7a165cac778321679a7bb91d8" +dependencies = [ + "bindgen", + "cc", + "cmake", + "heck 0.5.0", + "pkg-config", +] + +[[package]] +name = "uniffi" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21345172d31092fd48c47fd56c53d4ae9e41c4b1f559fb8c38c1ab1685fd919f" +dependencies = [ + "anyhow", + "uniffi_build", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd992f2929a053829d5875af1eff2ee3d7a7001cb3b9a46cc7895f2caede6940" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin 0.6.1", + "heck 0.4.1", + "once_cell", + "paste", + "serde", + "toml", + "uniffi_meta", + "uniffi_testing", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "001964dd3682d600084b3aaf75acf9c3426699bc27b65e96bb32d175a31c74e9" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55137c122f712d9330fd985d66fa61bdc381752e89c35708c13ce63049a3002c" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "uniffi_core" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6121a127a3af1665cd90d12dd2b3683c2643c5103281d0fed5838324ca1fad5b" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "camino", + "log", + "once_cell", + "oneshot-uniffi", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11cf7a58f101fcedafa5b77ea037999b88748607f0ef3a33eaa0efc5392e92e4" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.114", + "toml", + "uniffi_build", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dc8573a7b1ac4b71643d6da34888273ebfc03440c525121f1b3634ad3417a2" +dependencies = [ + "anyhow", + "bytes", + "siphasher", + "uniffi_checksum_derive", +] + +[[package]] +name = "uniffi_testing" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "118448debffcb676ddbe8c5305fb933ab7e0123753e659a71dc4a693f8d9f23c" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "fs-err", + "once_cell", +] + +[[package]] +name = "uniffi_udl" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "889edb7109c6078abe0e53e9b4070cf74a6b3468d141bdf5ef1bd4d1dc24a1c3" +dependencies = [ + "anyhow", + "uniffi_meta", + "uniffi_testing", + "weedle2", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls 0.23.38", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna 1.1.0", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "rand 0.9.2", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weedle2" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e79c5206e1f43a2306fd64bdb95025ee4228960f2e6c5a8b173f3caaf807741" +dependencies = [ + "nom", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "tls_codec", +] + +[[package]] +name = "xmas-elf" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c49817e78342f7f30a181573d82ff55b88a35f86ccaf07fc64b3008f56d1c6" +dependencies = [ + "zero", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zero" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe21bcc34ca7fe6dd56cc2cb1261ea59d6b93620215aefb5ea6032265527784" + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/pkg/rustpushgo/Cargo.toml b/pkg/rustpushgo/Cargo.toml new file mode 100644 index 00000000..8feb1bca --- /dev/null +++ b/pkg/rustpushgo/Cargo.toml @@ -0,0 +1,100 @@ +[package] +name = "rustpushgo" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib", "rlib"] +name = "rustpushgo" + +[features] +# hardware-key enables open-absinthe (x86 NAC emulator via unicorn-engine). +# Required on Linux; unnecessary on macOS (native AAAbsintheContext is used). +# The Makefile passes --features hardware-key on Linux only. +default = [] +hardware-key = ["rustpush/macos-validation-data"] +# Enables CloudKit avid (Live Photo MOV) download paths when the linked +# rustpush fork exposes the required API and record fields. +rustpush-avid-download = [] + +[dependencies] +tokio = { version = "1", features = ["full"] } +futures = "0.3" +uniffi = { version = "0.25.0", features = ["tokio"] } +pretty_env_logger = "0.5.0" +log = "0.4.20" +rustpush = { path = "../../third_party/rustpush-upstream", default-features = false, features = ["macos-validation-data"] } +# Direct path dep on cloudkit-proto so the `CloudKitRecord` derive macro (which +# emits `cloudkit_proto::*` references in its generated code) can resolve the +# crate name inside our wrapper. Without this, defining a local CloudKitRecord +# type in lib.rs fails to compile because the derive expects the crate to be +# importable by its literal name. +cloudkit-proto = { path = "../../third_party/rustpush-upstream/cloudkit-proto", package = "cloudkit-proto" } +# Direct dep on open-absinthe so the wrapper can call +# `open_absinthe::nac::set_relay_config` from Linux and macOS alike. +# Points to third_party/ — the Makefile applies our overlay from +# rustpush/open-absinthe/ on top after cloning upstream. +open-absinthe = { path = "../../third_party/rustpush-upstream/open-absinthe" } +keystore = { path = "../../third_party/rustpush-upstream/keystore" } +plist = "1.7.0" +serde = { version = "1.0", features = ["derive"] } +uuid = { version = "1.4.1", features = ["v4"] } +sha2 = "0.10" +sha1 = "0.10" +thiserror = "1.0.47" +async-trait = "0.1.73" +base64 = "0.21" +hex = "0.4" +serde_json = "1.0" +libc = "0.2" +# prost for decoding `mmcsp::AuthorizeGetResponse` in the Ford recovery +# gating check — match upstream's version so cargo feature unification +# resolves cleanly (upstream uses 0.12). +prost = "0.12" +# Crypto deps for the manual Ford download path that implements V1+V2 +# Ford decryption at the wrapper layer. Upstream rustpush's `get_mmcs` +# only supports V1 Ford and panics on V2 records (`chunks.item.expect` +# at mmcs.rs:1117), so the wrapper has to perform the download itself +# for Ford-encrypted assets. Versions match upstream rustpush exactly so +# cargo feature unification doesn't double up crates. +aes-siv = "0.7.0" +hkdf = "0.12.4" +aes = "0.8.4" +ctr = "0.9.2" +openssl = "0.10.56" +reqwest = { version = "0.11", features = ["stream", "rustls-tls", "rustls-tls-webpki-roots", "gzip"] } +# Direct dep on rustls 0.23 so we can install the aws-lc-rs CryptoProvider +# at startup. Upstream rustpush pulled in rustls 0.23 (transitively, via the +# packed-APS-encoding refactor) but no provider is auto-installed in 0.23 — +# someone has to call install_default() before the first TLS connection or +# the process panics inside hyper/reqwest. See connect()/new_client(). +rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] } +once_cell = "1" +rand = { version = "0.8.5", features = ["min_const_gen"] } + +[target.'cfg(target_os = "macos")'.dependencies] +icloud_auth = { path = "../../third_party/rustpush-upstream/apple-private-apis/icloud-auth", default-features = false } +omnisette = { path = "../../third_party/rustpush-upstream/apple-private-apis/omnisette", default-features = false } +nac-validation = { path = "../../nac-validation" } +# Enable the `native-nac` feature on open-absinthe so its `ValidationCtx` +# delegates to nac-validation (AAAbsintheContext) instead of running the +# unicorn XNU emulator. This preserves the bridge's "Local NAC" behavior +# (native macOS NAC generation, no relay, no emulator) while keeping the +# OSConfig impl as upstream's `rustpush::macos::MacOSConfig`. Cargo unifies +# this dep with rustpush's own path dep on open-absinthe; features are +# additive so the `native-nac` flag propagates into both users. +open-absinthe = { path = "../../third_party/rustpush-upstream/open-absinthe", features = ["native-nac"] } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +icloud_auth = { path = "../../third_party/rustpush-upstream/apple-private-apis/icloud-auth", default-features = false, features = ["remote-anisette-v3"] } +omnisette = { path = "../../third_party/rustpush-upstream/apple-private-apis/omnisette", default-features = false, features = ["remote-anisette-v3"] } +# WebSocket + datetime + futures adapters for the wrapper-level anisette +# pre-provisioner (see src/anisette.rs). Versions match upstream omnisette +# exactly so cargo feature unification doesn't double up crates. +tokio-tungstenite = { version = "0.20.1", features = ["rustls-tls-webpki-roots"] } +chrono = "0.4.37" +futures-util = "0.3.28" + +[build-dependencies] +uniffi = { version = "0.25.0", features = ["build"] } +cc = "1.0" diff --git a/pkg/rustpushgo/build.rs b/pkg/rustpushgo/build.rs new file mode 100644 index 00000000..2f1c0f6f --- /dev/null +++ b/pkg/rustpushgo/build.rs @@ -0,0 +1,17 @@ +fn main() { + #[cfg(target_os = "macos")] + { + // Compile the Objective-C hardware info reader + cc::Build::new() + .file("src/hardware_info.m") + .flag("-fobjc-arc") + .flag("-framework") + .flag("Foundation") + .flag("-framework") + .flag("IOKit") + .compile("hardware_info"); + + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=IOKit"); + } +} diff --git a/pkg/rustpushgo/rustpushgo.c b/pkg/rustpushgo/rustpushgo.c new file mode 100644 index 00000000..4863042d --- /dev/null +++ b/pkg/rustpushgo/rustpushgo.c @@ -0,0 +1,8 @@ +#include <rustpushgo.h> + +// This file exists beacause of +// https://github.com/golang/go/issues/11263 + +void cgo_rust_task_callback_bridge_rustpushgo(RustTaskCallback cb, const void * taskData, int8_t status) { + cb(taskData, status); +} \ No newline at end of file diff --git a/pkg/rustpushgo/rustpushgo.go b/pkg/rustpushgo/rustpushgo.go new file mode 100644 index 00000000..294a499c --- /dev/null +++ b/pkg/rustpushgo/rustpushgo.go @@ -0,0 +1,9400 @@ +package rustpushgo + +// #include <rustpushgo.h> +// #cgo LDFLAGS: -L${SRCDIR}/../../ -lrustpushgo -ldl -lm -lz +// #cgo darwin LDFLAGS: -framework Security -framework SystemConfiguration -framework CoreFoundation -framework Foundation -framework CoreServices -lresolv +// #cgo linux LDFLAGS: -lpthread -lssl -lcrypto -lresolv +import "C" + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + "runtime" + "runtime/cgo" + "sync" + "sync/atomic" + "unsafe" +) + +type RustBuffer struct { + capacity C.int32_t + len C.int32_t + data *C.uint8_t +} + +func rustBufferToC(rb RustBuffer) C.RustBuffer { + return *(*C.RustBuffer)(unsafe.Pointer(&rb)) +} + +func rustBufferFromC(crb C.RustBuffer) RustBuffer { + return *(*RustBuffer)(unsafe.Pointer(&crb)) +} + +type RustBufferI interface { + AsReader() *bytes.Reader + Free() + ToGoBytes() []byte + Data() unsafe.Pointer + Len() int + Capacity() int +} + +func RustBufferFromExternal(b RustBufferI) RustBuffer { + return RustBuffer{ + capacity: C.int(b.Capacity()), + len: C.int(b.Len()), + data: (*C.uchar)(b.Data()), + } +} + +func (cb RustBuffer) Capacity() int { + return int(cb.capacity) +} + +func (cb RustBuffer) Len() int { + return int(cb.len) +} + +func (cb RustBuffer) Data() unsafe.Pointer { + return unsafe.Pointer(cb.data) +} + +func (cb RustBuffer) AsReader() *bytes.Reader { + b := unsafe.Slice((*byte)(cb.data), C.int(cb.len)) + return bytes.NewReader(b) +} + +func (cb RustBuffer) Free() { + rustCall(func(status *C.RustCallStatus) bool { + C.ffi_rustpushgo_rustbuffer_free(rustBufferToC(cb), status) + return false + }) +} + +func (cb RustBuffer) ToGoBytes() []byte { + return C.GoBytes(unsafe.Pointer(cb.data), C.int(cb.len)) +} + +func stringToRustBuffer(str string) RustBuffer { + return bytesToRustBuffer([]byte(str)) +} + +func bytesToRustBuffer(b []byte) RustBuffer { + if len(b) == 0 { + return RustBuffer{} + } + // We can pass the pointer along here, as it is pinned + // for the duration of this call + foreign := C.ForeignBytes{ + len: C.int(len(b)), + data: (*C.uchar)(unsafe.Pointer(&b[0])), + } + + return rustCall(func(status *C.RustCallStatus) RustBuffer { + return rustBufferFromC(C.ffi_rustpushgo_rustbuffer_from_bytes(foreign, status)) + }) +} + +type BufLifter[GoType any] interface { + Lift(value RustBufferI) GoType +} + +type BufLowerer[GoType any] interface { + Lower(value GoType) RustBuffer +} + +type FfiConverter[GoType any, FfiType any] interface { + Lift(value FfiType) GoType + Lower(value GoType) FfiType +} + +type BufReader[GoType any] interface { + Read(reader io.Reader) GoType +} + +type BufWriter[GoType any] interface { + Write(writer io.Writer, value GoType) +} + +type FfiRustBufConverter[GoType any, FfiType any] interface { + FfiConverter[GoType, FfiType] + BufReader[GoType] +} + +func LowerIntoRustBuffer[GoType any](bufWriter BufWriter[GoType], value GoType) RustBuffer { + // This might be not the most efficient way but it does not require knowing allocation size + // beforehand + var buffer bytes.Buffer + bufWriter.Write(&buffer, value) + + bytes, err := io.ReadAll(&buffer) + if err != nil { + panic(fmt.Errorf("reading written data: %w", err)) + } + return bytesToRustBuffer(bytes) +} + +func LiftFromRustBuffer[GoType any](bufReader BufReader[GoType], rbuf RustBufferI) GoType { + defer rbuf.Free() + reader := rbuf.AsReader() + item := bufReader.Read(reader) + if reader.Len() > 0 { + // TODO: Remove this + leftover, _ := io.ReadAll(reader) + panic(fmt.Errorf("Junk remaining in buffer after lifting: %s", string(leftover))) + } + return item +} + +func rustCallWithError[U any](converter BufLifter[error], callback func(*C.RustCallStatus) U) (U, error) { + var status C.RustCallStatus + returnValue := callback(&status) + err := checkCallStatus(converter, status) + + return returnValue, err +} + +func checkCallStatus(converter BufLifter[error], status C.RustCallStatus) error { + switch status.code { + case 0: + return nil + case 1: + return converter.Lift(rustBufferFromC(status.errorBuf)) + case 2: + // when the rust code sees a panic, it tries to construct a rustbuffer + // with the message. but if that code panics, then it just sends back + // an empty buffer. + if status.errorBuf.len > 0 { + panic(fmt.Errorf("%s", FfiConverterStringINSTANCE.Lift(rustBufferFromC(status.errorBuf)))) + } else { + panic(fmt.Errorf("Rust panicked while handling Rust panic")) + } + default: + return fmt.Errorf("unknown status code: %d", status.code) + } +} + +func checkCallStatusUnknown(status C.RustCallStatus) error { + switch status.code { + case 0: + return nil + case 1: + panic(fmt.Errorf("function not returning an error returned an error")) + case 2: + // when the rust code sees a panic, it tries to construct a rustbuffer + // with the message. but if that code panics, then it just sends back + // an empty buffer. + if status.errorBuf.len > 0 { + panic(fmt.Errorf("%s", FfiConverterStringINSTANCE.Lift(rustBufferFromC(status.errorBuf)))) + } else { + panic(fmt.Errorf("Rust panicked while handling Rust panic")) + } + default: + return fmt.Errorf("unknown status code: %d", status.code) + } +} + +func rustCall[U any](callback func(*C.RustCallStatus) U) U { + returnValue, err := rustCallWithError(nil, callback) + if err != nil { + panic(err) + } + return returnValue +} + +func writeInt8(writer io.Writer, value int8) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func writeUint8(writer io.Writer, value uint8) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func writeInt16(writer io.Writer, value int16) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func writeUint16(writer io.Writer, value uint16) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func writeInt32(writer io.Writer, value int32) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func writeUint32(writer io.Writer, value uint32) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func writeInt64(writer io.Writer, value int64) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func writeUint64(writer io.Writer, value uint64) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func writeFloat32(writer io.Writer, value float32) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func writeFloat64(writer io.Writer, value float64) { + if err := binary.Write(writer, binary.BigEndian, value); err != nil { + panic(err) + } +} + +func readInt8(reader io.Reader) int8 { + var result int8 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func readUint8(reader io.Reader) uint8 { + var result uint8 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func readInt16(reader io.Reader) int16 { + var result int16 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func readUint16(reader io.Reader) uint16 { + var result uint16 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func readInt32(reader io.Reader) int32 { + var result int32 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func readUint32(reader io.Reader) uint32 { + var result uint32 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func readInt64(reader io.Reader) int64 { + var result int64 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func readUint64(reader io.Reader) uint64 { + var result uint64 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func readFloat32(reader io.Reader) float32 { + var result float32 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func readFloat64(reader io.Reader) float64 { + var result float64 + if err := binary.Read(reader, binary.BigEndian, &result); err != nil { + panic(err) + } + return result +} + +func init() { + + (&FfiConverterCallbackInterfaceMessageCallback{}).register() + (&FfiConverterCallbackInterfaceStatusCallback{}).register() + (&FfiConverterCallbackInterfaceUpdateUsersCallback{}).register() + uniffiInitContinuationCallback() + uniffiCheckChecksums() +} + +func uniffiCheckChecksums() { + // Get the bindings contract version from our ComponentInterface + bindingsContractVersion := 24 + // Get the scaffolding contract version by calling the into the dylib + scaffoldingContractVersion := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint32_t { + return C.ffi_rustpushgo_uniffi_contract_version(uniffiStatus) + }) + if bindingsContractVersion != int(scaffoldingContractVersion) { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: UniFFI contract version mismatch") + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_connect(uniffiStatus) + }) + if checksum != 48943 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_connect: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_create_config_from_hardware_key(uniffiStatus) + }) + if checksum != 35117 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_create_config_from_hardware_key: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_create_config_from_hardware_key_with_device_id(uniffiStatus) + }) + if checksum != 29425 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_create_config_from_hardware_key_with_device_id: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_create_local_macos_config(uniffiStatus) + }) + if checksum != 37134 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_create_local_macos_config: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_create_local_macos_config_with_device_id(uniffiStatus) + }) + if checksum != 44159 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_create_local_macos_config_with_device_id: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_ford_key_cache_size(uniffiStatus) + }) + if checksum != 25806 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_ford_key_cache_size: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_init_logger(uniffiStatus) + }) + if checksum != 38755 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_init_logger: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_login_start(uniffiStatus) + }) + if checksum != 53356 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_login_start: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_new_client(uniffiStatus) + }) + if checksum != 28402 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_new_client: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_register_ford_key(uniffiStatus) + }) + if checksum != 34188 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_register_ford_key: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_func_restore_token_provider(uniffiStatus) + }) + if checksum != 43442 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_func_restore_token_provider: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_diag_full_count(uniffiStatus) + }) + if checksum != 27287 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_diag_full_count: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_download_attachment(uniffiStatus) + }) + if checksum != 39378 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_download_attachment: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_download_attachment_avid(uniffiStatus) + }) + if checksum != 63062 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_download_attachment_avid: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_download_group_photo(uniffiStatus) + }) + if checksum != 31376 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_download_group_photo: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_dump_chats_json(uniffiStatus) + }) + if checksum != 18960 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_dump_chats_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_fetch_recent_messages(uniffiStatus) + }) + if checksum != 26669 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_fetch_recent_messages: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_query_attachments_fallback(uniffiStatus) + }) + if checksum != 5504 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_query_attachments_fallback: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_supports_avid_download(uniffiStatus) + }) + if checksum != 12325 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_supports_avid_download: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_sync_attachments(uniffiStatus) + }) + if checksum != 29066 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_sync_attachments: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_sync_chats(uniffiStatus) + }) + if checksum != 48464 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_sync_chats: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_cloud_sync_messages(uniffiStatus) + }) + if checksum != 61309 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_cloud_sync_messages: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_debug_recoverable_zones(uniffiStatus) + }) + if checksum != 46761 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_debug_recoverable_zones: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_delete_cloud_chats(uniffiStatus) + }) + if checksum != 24440 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_delete_cloud_chats: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_delete_cloud_messages(uniffiStatus) + }) + if checksum != 50268 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_delete_cloud_messages: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_fetch_profile(uniffiStatus) + }) + if checksum != 36346 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_fetch_profile: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_findmy_friends_import(uniffiStatus) + }) + if checksum != 28320 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_findmy_friends_import: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_findmy_friends_refresh_json(uniffiStatus) + }) + if checksum != 27180 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_findmy_friends_refresh_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_findmy_phone_refresh_json(uniffiStatus) + }) + if checksum != 45424 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_findmy_phone_refresh_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_get_contacts_url(uniffiStatus) + }) + if checksum != 50659 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_get_contacts_url: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_get_dsid(uniffiStatus) + }) + if checksum != 24963 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_get_dsid: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_get_facetime_client(uniffiStatus) + }) + if checksum != 2377 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_get_facetime_client: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_get_findmy_client(uniffiStatus) + }) + if checksum != 58400 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_get_findmy_client: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_get_handles(uniffiStatus) + }) + if checksum != 2965 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_get_handles: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_get_icloud_auth_headers(uniffiStatus) + }) + if checksum != 46466 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_get_icloud_auth_headers: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_get_passwords_client(uniffiStatus) + }) + if checksum != 61200 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_get_passwords_client: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_get_sharedstreams_client(uniffiStatus) + }) + if checksum != 25939 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_get_sharedstreams_client: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_get_statuskit_client(uniffiStatus) + }) + if checksum != 17269 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_get_statuskit_client: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_init_statuskit(uniffiStatus) + }) + if checksum != 16074 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_init_statuskit: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_invite_to_status_sharing(uniffiStatus) + }) + if checksum != 40272 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_invite_to_status_sharing: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_list_recoverable_chats(uniffiStatus) + }) + if checksum != 61049 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_list_recoverable_chats: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_list_recoverable_message_guids(uniffiStatus) + }) + if checksum != 37296 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_list_recoverable_message_guids: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_purge_recoverable_zones(uniffiStatus) + }) + if checksum != 38295 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_purge_recoverable_zones: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_reset_cloud_client(uniffiStatus) + }) + if checksum != 49216 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_reset_cloud_client: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_reset_statuskit_cursors(uniffiStatus) + }) + if checksum != 35023 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_reset_statuskit_cursors: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_resolve_handle(uniffiStatus) + }) + if checksum != 29605 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_resolve_handle: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_resolve_handle_cached(uniffiStatus) + }) + if checksum != 6690 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_resolve_handle_cached: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_restore_cloud_chat(uniffiStatus) + }) + if checksum != 36876 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_restore_cloud_chat: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_attachment(uniffiStatus) + }) + if checksum != 59726 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_attachment: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_change_participants(uniffiStatus) + }) + if checksum != 17502 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_change_participants: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_delivery_receipt(uniffiStatus) + }) + if checksum != 54993 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_delivery_receipt: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_edit(uniffiStatus) + }) + if checksum != 50609 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_edit: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_error_message(uniffiStatus) + }) + if checksum != 22923 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_error_message: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_icon_change(uniffiStatus) + }) + if checksum != 19572 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_icon_change: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_icon_clear(uniffiStatus) + }) + if checksum != 48413 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_icon_clear: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_mark_unread(uniffiStatus) + }) + if checksum != 20300 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_mark_unread: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_message(uniffiStatus) + }) + if checksum != 21078 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_message: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_message_read_on_device(uniffiStatus) + }) + if checksum != 32416 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_message_read_on_device: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_move_to_recycle_bin(uniffiStatus) + }) + if checksum != 23546 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_move_to_recycle_bin: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_notify_anyways(uniffiStatus) + }) + if checksum != 31335 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_notify_anyways: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_peer_cache_invalidate(uniffiStatus) + }) + if checksum != 62550 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_peer_cache_invalidate: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_permanent_delete_chat(uniffiStatus) + }) + if checksum != 23579 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_permanent_delete_chat: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_permanent_delete_messages(uniffiStatus) + }) + if checksum != 40735 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_permanent_delete_messages: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_read_receipt(uniffiStatus) + }) + if checksum != 61662 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_read_receipt: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_recover_chat(uniffiStatus) + }) + if checksum != 50565 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_recover_chat: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_rename_group(uniffiStatus) + }) + if checksum != 8861 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_rename_group: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_set_transcript_background(uniffiStatus) + }) + if checksum != 6986 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_set_transcript_background: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_share_profile(uniffiStatus) + }) + if checksum != 62242 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_share_profile: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_sms_activation(uniffiStatus) + }) + if checksum != 44855 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_sms_activation: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_sms_confirm_sent(uniffiStatus) + }) + if checksum != 22079 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_sms_confirm_sent: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_tapback(uniffiStatus) + }) + if checksum != 6103 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_tapback: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_typing(uniffiStatus) + }) + if checksum != 5805 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_typing: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_typing_with_app(uniffiStatus) + }) + if checksum != 8148 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_typing_with_app: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_unschedule(uniffiStatus) + }) + if checksum != 50141 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_unschedule: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_unsend(uniffiStatus) + }) + if checksum != 29429 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_unsend: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_update_extension(uniffiStatus) + }) + if checksum != 23014 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_update_extension: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_update_profile(uniffiStatus) + }) + if checksum != 42357 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_update_profile: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_send_update_profile_sharing(uniffiStatus) + }) + if checksum != 10120 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_send_update_profile_sharing: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_set_status(uniffiStatus) + }) + if checksum != 47775 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_set_status: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_stop(uniffiStatus) + }) + if checksum != 26750 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_stop: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_subscribe_to_status(uniffiStatus) + }) + if checksum != 52856 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_subscribe_to_status: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_test_cloud_messages(uniffiStatus) + }) + if checksum != 57936 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_test_cloud_messages: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_unsubscribe_all_status(uniffiStatus) + }) + if checksum != 47268 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_unsubscribe_all_status: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_client_validate_targets(uniffiStatus) + }) + if checksum != 44836 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_client_validate_targets: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_loginsession_finish(uniffiStatus) + }) + if checksum != 25021 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_loginsession_finish: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_loginsession_needs_2fa(uniffiStatus) + }) + if checksum != 10863 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_loginsession_needs_2fa: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_loginsession_submit_2fa(uniffiStatus) + }) + if checksum != 25146 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_loginsession_submit_2fa: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedapsconnection_state(uniffiStatus) + }) + if checksum != 59967 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedapsconnection_state: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedapsstate_to_string(uniffiStatus) + }) + if checksum != 2386 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedapsstate_to_string: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_add_members(uniffiStatus) + }) + if checksum != 4768 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_add_members: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_bind_bridge_link_to_session(uniffiStatus) + }) + if checksum != 20929 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_bind_bridge_link_to_session: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_clear_links(uniffiStatus) + }) + if checksum != 21029 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_clear_links: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_create_session(uniffiStatus) + }) + if checksum != 12539 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_create_session: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_create_session_no_ring(uniffiStatus) + }) + if checksum != 9956 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_create_session_no_ring: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_delete_link(uniffiStatus) + }) + if checksum != 57656 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_delete_link: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_export_state_json(uniffiStatus) + }) + if checksum != 63772 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_export_state_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_get_link_for_usage(uniffiStatus) + }) + if checksum != 56712 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_get_link_for_usage: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_get_session_link(uniffiStatus) + }) + if checksum != 7494 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_get_session_link: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_list_delegated_letmein_requests(uniffiStatus) + }) + if checksum != 3880 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_list_delegated_letmein_requests: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_register_pending_ring(uniffiStatus) + }) + if checksum != 44246 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_register_pending_ring: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_remove_members(uniffiStatus) + }) + if checksum != 65464 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_remove_members: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_respond_delegated_letmein(uniffiStatus) + }) + if checksum != 38946 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_respond_delegated_letmein: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_ring(uniffiStatus) + }) + if checksum != 4043 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_ring: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_use_link_for(uniffiStatus) + }) + if checksum != 19893 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_use_link_for: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfindmyclient_accept_item_share(uniffiStatus) + }) + if checksum != 21451 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfindmyclient_accept_item_share: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfindmyclient_delete_shared_item(uniffiStatus) + }) + if checksum != 47642 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfindmyclient_delete_shared_item: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfindmyclient_export_state_json(uniffiStatus) + }) + if checksum != 48669 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfindmyclient_export_state_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfindmyclient_sync_item_positions(uniffiStatus) + }) + if checksum != 16803 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfindmyclient_sync_item_positions: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedfindmyclient_update_beacon_name(uniffiStatus) + }) + if checksum != 531 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedfindmyclient_update_beacon_name: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedidsngmidentity_to_string(uniffiStatus) + }) + if checksum != 19097 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedidsngmidentity_to_string: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedidsusers_get_handles(uniffiStatus) + }) + if checksum != 54112 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedidsusers_get_handles: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedidsusers_login_id(uniffiStatus) + }) + if checksum != 23919 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedidsusers_login_id: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedidsusers_to_string(uniffiStatus) + }) + if checksum != 29 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedidsusers_to_string: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedidsusers_validate_keystore(uniffiStatus) + }) + if checksum != 49609 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedidsusers_validate_keystore: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedosconfig_get_device_id(uniffiStatus) + }) + if checksum != 39645 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedosconfig_get_device_id: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedosconfig_requires_nac_relay(uniffiStatus) + }) + if checksum != 13481 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedosconfig_requires_nac_relay: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_accept_invite(uniffiStatus) + }) + if checksum != 37840 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_accept_invite: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_create_group(uniffiStatus) + }) + if checksum != 45849 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_create_group: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_decline_invite(uniffiStatus) + }) + if checksum != 48841 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_decline_invite: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_delete_password_raw_entry(uniffiStatus) + }) + if checksum != 57340 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_delete_password_raw_entry: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_export_state_json(uniffiStatus) + }) + if checksum != 39354 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_export_state_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_get_password_site_counts(uniffiStatus) + }) + if checksum != 51887 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_get_password_site_counts: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_invite_user(uniffiStatus) + }) + if checksum != 44854 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_invite_user: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_list_password_raw_entry_refs(uniffiStatus) + }) + if checksum != 29676 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_list_password_raw_entry_refs: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_query_handle(uniffiStatus) + }) + if checksum != 11114 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_query_handle: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_remove_group(uniffiStatus) + }) + if checksum != 10084 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_remove_group: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_remove_user(uniffiStatus) + }) + if checksum != 32359 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_remove_user: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_rename_group(uniffiStatus) + }) + if checksum != 24623 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_rename_group: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_sync_passwords(uniffiStatus) + }) + if checksum != 50829 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_sync_passwords: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_upsert_password_raw_entry(uniffiStatus) + }) + if checksum != 24562 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_upsert_password_raw_entry: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_delete_assets(uniffiStatus) + }) + if checksum != 21365 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_delete_assets: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_download_file(uniffiStatus) + }) + if checksum != 57741 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_download_file: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_export_state_json(uniffiStatus) + }) + if checksum != 8939 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_export_state_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_album_assets(uniffiStatus) + }) + if checksum != 12757 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_album_assets: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_album_summary(uniffiStatus) + }) + if checksum != 64494 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_album_summary: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_assets_json(uniffiStatus) + }) + if checksum != 46429 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_assets_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_changes(uniffiStatus) + }) + if checksum != 57594 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_changes: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_list_album_ids(uniffiStatus) + }) + if checksum != 44544 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_list_album_ids: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_list_albums(uniffiStatus) + }) + if checksum != 20350 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_list_albums: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_subscribe(uniffiStatus) + }) + if checksum != 5706 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_subscribe: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_subscribe_token(uniffiStatus) + }) + if checksum != 37622 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_subscribe_token: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_unsubscribe(uniffiStatus) + }) + if checksum != 29614 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_unsubscribe: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_clear_interest_tokens(uniffiStatus) + }) + if checksum != 24594 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_clear_interest_tokens: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_export_state_json(uniffiStatus) + }) + if checksum != 3475 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_export_state_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_get_known_handles(uniffiStatus) + }) + if checksum != 9296 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_get_known_handles: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_invite_to_channel(uniffiStatus) + }) + if checksum != 51991 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_invite_to_channel: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_request_handles(uniffiStatus) + }) + if checksum != 17015 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_request_handles: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_reset_keys(uniffiStatus) + }) + if checksum != 28788 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_reset_keys: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_roll_keys(uniffiStatus) + }) + if checksum != 23793 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_roll_keys: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_share_status(uniffiStatus) + }) + if checksum != 10053 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_share_status: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_contacts_url(uniffiStatus) + }) + if checksum != 29421 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_contacts_url: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_dsid(uniffiStatus) + }) + if checksum != 58611 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_dsid: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_escrow_devices(uniffiStatus) + }) + if checksum != 27126 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_escrow_devices: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_icloud_auth_headers(uniffiStatus) + }) + if checksum != 3524 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_icloud_auth_headers: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_mme_delegate_json(uniffiStatus) + }) + if checksum != 9782 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_mme_delegate_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedtokenprovider_join_keychain_clique(uniffiStatus) + }) + if checksum != 14380 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedtokenprovider_join_keychain_clique: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedtokenprovider_join_keychain_clique_for_device(uniffiStatus) + }) + if checksum != 59097 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedtokenprovider_join_keychain_clique_for_device: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_wrappedtokenprovider_seed_mme_delegate_json(uniffiStatus) + }) + if checksum != 6840 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_wrappedtokenprovider_seed_mme_delegate_json: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_constructor_wrappedapsstate_new(uniffiStatus) + }) + if checksum != 9380 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_constructor_wrappedapsstate_new: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_constructor_wrappedidsngmidentity_new(uniffiStatus) + }) + if checksum != 24162 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_constructor_wrappedidsngmidentity_new: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_constructor_wrappedidsusers_new(uniffiStatus) + }) + if checksum != 42963 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_constructor_wrappedidsusers_new: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_messagecallback_on_message(uniffiStatus) + }) + if checksum != 9227 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_messagecallback_on_message: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_statuscallback_on_status_update(uniffiStatus) + }) + if checksum != 34109 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_statuscallback_on_status_update: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_statuscallback_on_keys_received(uniffiStatus) + }) + if checksum != 53322 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_statuscallback_on_keys_received: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_rustpushgo_checksum_method_updateuserscallback_update_users(uniffiStatus) + }) + if checksum != 85 { + // If this happens try cleaning and rebuilding your project + panic("rustpushgo: uniffi_rustpushgo_checksum_method_updateuserscallback_update_users: UniFFI API checksum mismatch") + } + } +} + +type FfiConverterUint32 struct{} + +var FfiConverterUint32INSTANCE = FfiConverterUint32{} + +func (FfiConverterUint32) Lower(value uint32) C.uint32_t { + return C.uint32_t(value) +} + +func (FfiConverterUint32) Write(writer io.Writer, value uint32) { + writeUint32(writer, value) +} + +func (FfiConverterUint32) Lift(value C.uint32_t) uint32 { + return uint32(value) +} + +func (FfiConverterUint32) Read(reader io.Reader) uint32 { + return readUint32(reader) +} + +type FfiDestroyerUint32 struct{} + +func (FfiDestroyerUint32) Destroy(_ uint32) {} + +type FfiConverterInt32 struct{} + +var FfiConverterInt32INSTANCE = FfiConverterInt32{} + +func (FfiConverterInt32) Lower(value int32) C.int32_t { + return C.int32_t(value) +} + +func (FfiConverterInt32) Write(writer io.Writer, value int32) { + writeInt32(writer, value) +} + +func (FfiConverterInt32) Lift(value C.int32_t) int32 { + return int32(value) +} + +func (FfiConverterInt32) Read(reader io.Reader) int32 { + return readInt32(reader) +} + +type FfiDestroyerInt32 struct{} + +func (FfiDestroyerInt32) Destroy(_ int32) {} + +type FfiConverterUint64 struct{} + +var FfiConverterUint64INSTANCE = FfiConverterUint64{} + +func (FfiConverterUint64) Lower(value uint64) C.uint64_t { + return C.uint64_t(value) +} + +func (FfiConverterUint64) Write(writer io.Writer, value uint64) { + writeUint64(writer, value) +} + +func (FfiConverterUint64) Lift(value C.uint64_t) uint64 { + return uint64(value) +} + +func (FfiConverterUint64) Read(reader io.Reader) uint64 { + return readUint64(reader) +} + +type FfiDestroyerUint64 struct{} + +func (FfiDestroyerUint64) Destroy(_ uint64) {} + +type FfiConverterInt64 struct{} + +var FfiConverterInt64INSTANCE = FfiConverterInt64{} + +func (FfiConverterInt64) Lower(value int64) C.int64_t { + return C.int64_t(value) +} + +func (FfiConverterInt64) Write(writer io.Writer, value int64) { + writeInt64(writer, value) +} + +func (FfiConverterInt64) Lift(value C.int64_t) int64 { + return int64(value) +} + +func (FfiConverterInt64) Read(reader io.Reader) int64 { + return readInt64(reader) +} + +type FfiDestroyerInt64 struct{} + +func (FfiDestroyerInt64) Destroy(_ int64) {} + +type FfiConverterFloat64 struct{} + +var FfiConverterFloat64INSTANCE = FfiConverterFloat64{} + +func (FfiConverterFloat64) Lower(value float64) C.double { + return C.double(value) +} + +func (FfiConverterFloat64) Write(writer io.Writer, value float64) { + writeFloat64(writer, value) +} + +func (FfiConverterFloat64) Lift(value C.double) float64 { + return float64(value) +} + +func (FfiConverterFloat64) Read(reader io.Reader) float64 { + return readFloat64(reader) +} + +type FfiDestroyerFloat64 struct{} + +func (FfiDestroyerFloat64) Destroy(_ float64) {} + +type FfiConverterBool struct{} + +var FfiConverterBoolINSTANCE = FfiConverterBool{} + +func (FfiConverterBool) Lower(value bool) C.int8_t { + if value { + return C.int8_t(1) + } + return C.int8_t(0) +} + +func (FfiConverterBool) Write(writer io.Writer, value bool) { + if value { + writeInt8(writer, 1) + } else { + writeInt8(writer, 0) + } +} + +func (FfiConverterBool) Lift(value C.int8_t) bool { + return value != 0 +} + +func (FfiConverterBool) Read(reader io.Reader) bool { + return readInt8(reader) != 0 +} + +type FfiDestroyerBool struct{} + +func (FfiDestroyerBool) Destroy(_ bool) {} + +type FfiConverterString struct{} + +var FfiConverterStringINSTANCE = FfiConverterString{} + +func (FfiConverterString) Lift(rb RustBufferI) string { + defer rb.Free() + reader := rb.AsReader() + b, err := io.ReadAll(reader) + if err != nil { + panic(fmt.Errorf("reading reader: %w", err)) + } + return string(b) +} + +func (FfiConverterString) Read(reader io.Reader) string { + length := readInt32(reader) + buffer := make([]byte, length) + read_length, err := reader.Read(buffer) + if err != nil { + panic(err) + } + if read_length != int(length) { + panic(fmt.Errorf("bad read length when reading string, expected %d, read %d", length, read_length)) + } + return string(buffer) +} + +func (FfiConverterString) Lower(value string) RustBuffer { + return stringToRustBuffer(value) +} + +func (FfiConverterString) Write(writer io.Writer, value string) { + if len(value) > math.MaxInt32 { + panic("String is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + write_length, err := io.WriteString(writer, value) + if err != nil { + panic(err) + } + if write_length != len(value) { + panic(fmt.Errorf("bad write length when writing string, expected %d, written %d", len(value), write_length)) + } +} + +type FfiDestroyerString struct{} + +func (FfiDestroyerString) Destroy(_ string) {} + +type FfiConverterBytes struct{} + +var FfiConverterBytesINSTANCE = FfiConverterBytes{} + +func (c FfiConverterBytes) Lower(value []byte) RustBuffer { + return LowerIntoRustBuffer[[]byte](c, value) +} + +func (c FfiConverterBytes) Write(writer io.Writer, value []byte) { + if len(value) > math.MaxInt32 { + panic("[]byte is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + write_length, err := writer.Write(value) + if err != nil { + panic(err) + } + if write_length != len(value) { + panic(fmt.Errorf("bad write length when writing []byte, expected %d, written %d", len(value), write_length)) + } +} + +func (c FfiConverterBytes) Lift(rb RustBufferI) []byte { + return LiftFromRustBuffer[[]byte](c, rb) +} + +func (c FfiConverterBytes) Read(reader io.Reader) []byte { + length := readInt32(reader) + buffer := make([]byte, length) + read_length, err := reader.Read(buffer) + if err != nil { + panic(err) + } + if read_length != int(length) { + panic(fmt.Errorf("bad read length when reading []byte, expected %d, read %d", length, read_length)) + } + return buffer +} + +type FfiDestroyerBytes struct{} + +func (FfiDestroyerBytes) Destroy(_ []byte) {} + +// Below is an implementation of synchronization requirements outlined in the link. +// https://github.com/mozilla/uniffi-rs/blob/0dc031132d9493ca812c3af6e7dd60ad2ea95bf0/uniffi_bindgen/src/bindings/kotlin/templates/ObjectRuntime.kt#L31 + +type FfiObject struct { + pointer unsafe.Pointer + callCounter atomic.Int64 + freeFunction func(unsafe.Pointer, *C.RustCallStatus) + destroyed atomic.Bool +} + +func newFfiObject(pointer unsafe.Pointer, freeFunction func(unsafe.Pointer, *C.RustCallStatus)) FfiObject { + return FfiObject{ + pointer: pointer, + freeFunction: freeFunction, + } +} + +func (ffiObject *FfiObject) incrementPointer(debugName string) unsafe.Pointer { + for { + counter := ffiObject.callCounter.Load() + if counter <= -1 { + panic(fmt.Errorf("%v object has already been destroyed", debugName)) + } + if counter == math.MaxInt64 { + panic(fmt.Errorf("%v object call counter would overflow", debugName)) + } + if ffiObject.callCounter.CompareAndSwap(counter, counter+1) { + break + } + } + + return ffiObject.pointer +} + +func (ffiObject *FfiObject) decrementPointer() { + if ffiObject.callCounter.Add(-1) == -1 { + ffiObject.freeRustArcPtr() + } +} + +func (ffiObject *FfiObject) destroy() { + if ffiObject.destroyed.CompareAndSwap(false, true) { + if ffiObject.callCounter.Add(-1) == -1 { + ffiObject.freeRustArcPtr() + } + } +} + +func (ffiObject *FfiObject) freeRustArcPtr() { + rustCall(func(status *C.RustCallStatus) int32 { + ffiObject.freeFunction(ffiObject.pointer, status) + return 0 + }) +} + +type Client struct { + ffiObject FfiObject +} + +func (_self *Client) CloudDiagFullCount() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_diag_full_count( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) CloudDownloadAttachment(recordName string) ([]byte, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_download_attachment( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(recordName)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterBytesINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) CloudDownloadAttachmentAvid(recordName string) ([]byte, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_download_attachment_avid( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(recordName)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterBytesINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) CloudDownloadGroupPhoto(recordName string) ([]byte, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_download_group_photo( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(recordName)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterBytesINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) CloudDumpChatsJson() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_dump_chats_json( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) CloudFetchRecentMessages(sinceTimestampMs uint64, chatId *string, maxPages uint32, maxResults uint32) ([]WrappedCloudSyncMessage, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_fetch_recent_messages( + _pointer, FfiConverterUint64INSTANCE.Lower(sinceTimestampMs), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(chatId)), FfiConverterUint32INSTANCE.Lower(maxPages), FfiConverterUint32INSTANCE.Lower(maxResults), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceTypeWrappedCloudSyncMessageINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) CloudQueryAttachmentsFallback(knownRecordNames []string) (WrappedCloudSyncAttachmentsPage, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_query_attachments_fallback( + _pointer, rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(knownRecordNames)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterTypeWrappedCloudSyncAttachmentsPageINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) CloudSupportsAvidDownload() bool { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return FfiConverterBoolINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) C.int8_t { + return C.uniffi_rustpushgo_fn_method_client_cloud_supports_avid_download( + _pointer, _uniffiStatus) + })) +} + +func (_self *Client) CloudSyncAttachments(continuationToken *string) (WrappedCloudSyncAttachmentsPage, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_sync_attachments( + _pointer, rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(continuationToken)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterTypeWrappedCloudSyncAttachmentsPageINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) CloudSyncChats(continuationToken *string) (WrappedCloudSyncChatsPage, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_sync_chats( + _pointer, rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(continuationToken)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterTypeWrappedCloudSyncChatsPageINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) CloudSyncMessages(continuationToken *string) (WrappedCloudSyncMessagesPage, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_cloud_sync_messages( + _pointer, rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(continuationToken)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterTypeWrappedCloudSyncMessagesPageINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) DebugRecoverableZones() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_debug_recoverable_zones( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) DeleteCloudChats(chatIds []string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_delete_cloud_chats( + _pointer, rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(chatIds)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) DeleteCloudMessages(messageIds []string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_delete_cloud_messages( + _pointer, rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(messageIds)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) FetchProfile(recordKey string, decryptionKey []byte, hasPoster bool) (WrappedProfileRecord, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_fetch_profile( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(recordKey)), rustBufferToC(FfiConverterBytesINSTANCE.Lower(decryptionKey)), FfiConverterBoolINSTANCE.Lower(hasPoster), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterTypeWrappedProfileRecordINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) FindmyFriendsImport(daemon bool, url string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_findmy_friends_import( + _pointer, FfiConverterBoolINSTANCE.Lower(daemon), rustBufferToC(FfiConverterStringINSTANCE.Lower(url)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) FindmyFriendsRefreshJson(daemon bool) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_findmy_friends_refresh_json( + _pointer, FfiConverterBoolINSTANCE.Lower(daemon), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) FindmyPhoneRefreshJson() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_findmy_phone_refresh_json( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) GetContactsUrl() (*string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_get_contacts_url( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterOptionalStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) GetDsid() (*string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_get_dsid( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterOptionalStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) GetFacetimeClient() (*WrappedFaceTimeClient, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_get_facetime_client( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterWrappedFaceTimeClientINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) GetFindmyClient() (*WrappedFindMyClient, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_get_findmy_client( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterWrappedFindMyClientINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) GetHandles() []string { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_get_handles( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) GetIcloudAuthHeaders() (*map[string]string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_get_icloud_auth_headers( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterOptionalMapStringStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) GetPasswordsClient() (*WrappedPasswordsClient, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_get_passwords_client( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterWrappedPasswordsClientINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) GetSharedstreamsClient() (*WrappedSharedStreamsClient, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_get_sharedstreams_client( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterWrappedSharedStreamsClientINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) GetStatuskitClient() (*WrappedStatusKitClient, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_get_statuskit_client( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterWrappedStatusKitClientINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) InitStatuskit(callback StatusCallback) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_init_statuskit( + _pointer, FfiConverterCallbackInterfaceStatusCallbackINSTANCE.Lower(callback), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) InviteToStatusSharing(senderHandle string, handles []string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_invite_to_status_sharing( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(senderHandle)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(handles)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) ListRecoverableChats() ([]WrappedCloudSyncChat, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_list_recoverable_chats( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceTypeWrappedCloudSyncChatINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) ListRecoverableMessageGuids() ([]string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_list_recoverable_message_guids( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) PurgeRecoverableZones() error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_purge_recoverable_zones( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) ResetCloudClient() { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + uniffiRustCallAsync(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_reset_cloud_client( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) ResetStatuskitCursors() { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + rustCall(func(_uniffiStatus *C.RustCallStatus) bool { + C.uniffi_rustpushgo_fn_method_client_reset_statuskit_cursors( + _pointer, _uniffiStatus) + return false + }) +} + +func (_self *Client) ResolveHandle(handle string, knownHandles []string) ([]string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_resolve_handle( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(knownHandles)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) ResolveHandleCached(handle string, knownHandles []string) []string { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_resolve_handle_cached( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(knownHandles)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) RestoreCloudChat(recordName string, chatIdentifier string, groupId string, style int64, service string, displayName *string, participants []string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_restore_cloud_chat( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(recordName)), rustBufferToC(FfiConverterStringINSTANCE.Lower(chatIdentifier)), rustBufferToC(FfiConverterStringINSTANCE.Lower(groupId)), FfiConverterInt64INSTANCE.Lower(style), rustBufferToC(FfiConverterStringINSTANCE.Lower(service)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(displayName)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(participants)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendAttachment(conversation WrappedConversation, data []byte, mime string, utiType string, filename string, handle string, replyGuid *string, replyPart *string, body *string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_attachment( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterBytesINSTANCE.Lower(data)), rustBufferToC(FfiConverterStringINSTANCE.Lower(mime)), rustBufferToC(FfiConverterStringINSTANCE.Lower(utiType)), rustBufferToC(FfiConverterStringINSTANCE.Lower(filename)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(replyGuid)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(replyPart)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(body)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendChangeParticipants(conversation WrappedConversation, newParticipants []string, groupVersion uint64, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_change_participants( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(newParticipants)), FfiConverterUint64INSTANCE.Lower(groupVersion), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendDeliveryReceipt(conversation WrappedConversation, handle string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_delivery_receipt( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendEdit(conversation WrappedConversation, targetUuid string, editPart uint64, newText string, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_edit( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(targetUuid)), FfiConverterUint64INSTANCE.Lower(editPart), rustBufferToC(FfiConverterStringINSTANCE.Lower(newText)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendErrorMessage(conversation WrappedConversation, forUuid string, errorStatus uint64, statusStr string, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_error_message( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(forUuid)), FfiConverterUint64INSTANCE.Lower(errorStatus), rustBufferToC(FfiConverterStringINSTANCE.Lower(statusStr)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendIconChange(conversation WrappedConversation, photoData []byte, groupVersion uint64, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_icon_change( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterBytesINSTANCE.Lower(photoData)), FfiConverterUint64INSTANCE.Lower(groupVersion), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendIconClear(conversation WrappedConversation, groupVersion uint64, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_icon_clear( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), FfiConverterUint64INSTANCE.Lower(groupVersion), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendMarkUnread(conversation WrappedConversation, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_mark_unread( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendMessage(conversation WrappedConversation, text string, html *string, handle string, replyGuid *string, replyPart *string, scheduledMs *uint64) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_message( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(text)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(html)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(replyGuid)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(replyPart)), rustBufferToC(FfiConverterOptionalUint64INSTANCE.Lower(scheduledMs)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendMessageReadOnDevice(conversation WrappedConversation, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_message_read_on_device( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendMoveToRecycleBin(conversation WrappedConversation, handle string, chatGuid string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_move_to_recycle_bin( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterStringINSTANCE.Lower(chatGuid)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendNotifyAnyways(conversation WrappedConversation, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_notify_anyways( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendPeerCacheInvalidate(conversation WrappedConversation, handle string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_peer_cache_invalidate( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendPermanentDeleteChat(conversation WrappedConversation, chatGuid string, isScheduled bool, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_permanent_delete_chat( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(chatGuid)), FfiConverterBoolINSTANCE.Lower(isScheduled), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendPermanentDeleteMessages(conversation WrappedConversation, messageUuids []string, isScheduled bool, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_permanent_delete_messages( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(messageUuids)), FfiConverterBoolINSTANCE.Lower(isScheduled), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendReadReceipt(conversation WrappedConversation, handle string, forUuid *string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_read_receipt( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(forUuid)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendRecoverChat(conversation WrappedConversation, handle string, chatGuid string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_recover_chat( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterStringINSTANCE.Lower(chatGuid)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendRenameGroup(conversation WrappedConversation, newName string, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_rename_group( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(newName)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendSetTranscriptBackground(conversation WrappedConversation, groupVersion uint64, imageData *[]byte, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_set_transcript_background( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), FfiConverterUint64INSTANCE.Lower(groupVersion), rustBufferToC(FfiConverterOptionalBytesINSTANCE.Lower(imageData)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendShareProfile(conversation WrappedConversation, cloudKitRecordKey string, cloudKitDecryptionRecordKey []byte, lowResWallpaperTag *[]byte, wallpaperTag *[]byte, messageTag *[]byte, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_share_profile( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(cloudKitRecordKey)), rustBufferToC(FfiConverterBytesINSTANCE.Lower(cloudKitDecryptionRecordKey)), rustBufferToC(FfiConverterOptionalBytesINSTANCE.Lower(lowResWallpaperTag)), rustBufferToC(FfiConverterOptionalBytesINSTANCE.Lower(wallpaperTag)), rustBufferToC(FfiConverterOptionalBytesINSTANCE.Lower(messageTag)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendSmsActivation(conversation WrappedConversation, enable bool, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_sms_activation( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), FfiConverterBoolINSTANCE.Lower(enable), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendSmsConfirmSent(conversation WrappedConversation, smsStatus bool, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_sms_confirm_sent( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), FfiConverterBoolINSTANCE.Lower(smsStatus), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendTapback(conversation WrappedConversation, targetUuid string, targetPart uint64, reaction uint32, emoji *string, remove bool, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_tapback( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(targetUuid)), FfiConverterUint64INSTANCE.Lower(targetPart), FfiConverterUint32INSTANCE.Lower(reaction), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(emoji)), FfiConverterBoolINSTANCE.Lower(remove), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendTyping(conversation WrappedConversation, typing bool, handle string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_typing( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), FfiConverterBoolINSTANCE.Lower(typing), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendTypingWithApp(conversation WrappedConversation, typing bool, handle string, bundleId string, icon []byte) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_typing_with_app( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), FfiConverterBoolINSTANCE.Lower(typing), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterStringINSTANCE.Lower(bundleId)), rustBufferToC(FfiConverterBytesINSTANCE.Lower(icon)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendUnschedule(conversation WrappedConversation, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_unschedule( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendUnsend(conversation WrappedConversation, targetUuid string, editPart uint64, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_unsend( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(targetUuid)), FfiConverterUint64INSTANCE.Lower(editPart), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendUpdateExtension(conversation WrappedConversation, forUuid string, extension WrappedStickerExtension, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_update_extension( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterStringINSTANCE.Lower(forUuid)), rustBufferToC(FfiConverterTypeWrappedStickerExtensionINSTANCE.Lower(extension)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendUpdateProfile(conversation WrappedConversation, profile *WrappedShareProfileData, shareContacts bool, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_update_profile( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterOptionalTypeWrappedShareProfileDataINSTANCE.Lower(profile)), FfiConverterBoolINSTANCE.Lower(shareContacts), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SendUpdateProfileSharing(conversation WrappedConversation, sharedDismissed []string, sharedAll []string, version uint64, handle string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_send_update_profile_sharing( + _pointer, rustBufferToC(FfiConverterTypeWrappedConversationINSTANCE.Lower(conversation)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(sharedDismissed)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(sharedAll)), FfiConverterUint64INSTANCE.Lower(version), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SetStatus(active bool) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_set_status( + _pointer, FfiConverterBoolINSTANCE.Lower(active), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) Stop() { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + uniffiRustCallAsync(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_stop( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) SubscribeToStatus(handles []string) error { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_subscribe_to_status( + _pointer, rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(handles)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) TestCloudMessages() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_test_cloud_messages( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) UnsubscribeAllStatus() { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + uniffiRustCallAsync(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_unsubscribe_all_status( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *Client) ValidateTargets(targets []string, handle string) []string { + _pointer := _self.ffiObject.incrementPointer("*Client") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_client_validate_targets( + _pointer, rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(targets)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (object *Client) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterClient struct{} + +var FfiConverterClientINSTANCE = FfiConverterClient{} + +func (c FfiConverterClient) Lift(pointer unsafe.Pointer) *Client { + result := &Client{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_client(pointer, status) + }), + } + runtime.SetFinalizer(result, (*Client).Destroy) + return result +} + +func (c FfiConverterClient) Read(reader io.Reader) *Client { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterClient) Lower(value *Client) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*Client") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterClient) Write(writer io.Writer, value *Client) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerClient struct{} + +func (_ FfiDestroyerClient) Destroy(value *Client) { + value.Destroy() +} + +type LoginSession struct { + ffiObject FfiObject +} + +func (_self *LoginSession) Finish(config *WrappedOsConfig, connection *WrappedApsConnection, existingIdentity **WrappedIdsngmIdentity, existingUsers **WrappedIdsUsers) (IdsUsersWithIdentityRecord, error) { + _pointer := _self.ffiObject.incrementPointer("*LoginSession") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_loginsession_finish( + _pointer, FfiConverterWrappedOSConfigINSTANCE.Lower(config), FfiConverterWrappedAPSConnectionINSTANCE.Lower(connection), rustBufferToC(FfiConverterOptionalWrappedIDSNGMIdentityINSTANCE.Lower(existingIdentity)), rustBufferToC(FfiConverterOptionalWrappedIDSUsersINSTANCE.Lower(existingUsers)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterTypeIDSUsersWithIdentityRecordINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *LoginSession) Needs2fa() bool { + _pointer := _self.ffiObject.incrementPointer("*LoginSession") + defer _self.ffiObject.decrementPointer() + return FfiConverterBoolINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) C.int8_t { + return C.uniffi_rustpushgo_fn_method_loginsession_needs_2fa( + _pointer, _uniffiStatus) + })) +} + +func (_self *LoginSession) Submit2fa(code string) (bool, error) { + _pointer := _self.ffiObject.incrementPointer("*LoginSession") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_loginsession_submit_2fa( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(code)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_i8(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) C.int8_t { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_i8(unsafe.Pointer(handle), status) + }, + FfiConverterBoolINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_i8(unsafe.Pointer(rustFuture), status) + }) +} + +func (object *LoginSession) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterLoginSession struct{} + +var FfiConverterLoginSessionINSTANCE = FfiConverterLoginSession{} + +func (c FfiConverterLoginSession) Lift(pointer unsafe.Pointer) *LoginSession { + result := &LoginSession{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_loginsession(pointer, status) + }), + } + runtime.SetFinalizer(result, (*LoginSession).Destroy) + return result +} + +func (c FfiConverterLoginSession) Read(reader io.Reader) *LoginSession { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterLoginSession) Lower(value *LoginSession) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*LoginSession") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterLoginSession) Write(writer io.Writer, value *LoginSession) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerLoginSession struct{} + +func (_ FfiDestroyerLoginSession) Destroy(value *LoginSession) { + value.Destroy() +} + +type WrappedApsConnection struct { + ffiObject FfiObject +} + +func (_self *WrappedApsConnection) State() *WrappedApsState { + _pointer := _self.ffiObject.incrementPointer("*WrappedApsConnection") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedapsconnection_state( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterWrappedAPSStateINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} + +func (object *WrappedApsConnection) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedAPSConnection struct{} + +var FfiConverterWrappedAPSConnectionINSTANCE = FfiConverterWrappedAPSConnection{} + +func (c FfiConverterWrappedAPSConnection) Lift(pointer unsafe.Pointer) *WrappedApsConnection { + result := &WrappedApsConnection{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedapsconnection(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedApsConnection).Destroy) + return result +} + +func (c FfiConverterWrappedAPSConnection) Read(reader io.Reader) *WrappedApsConnection { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedAPSConnection) Lower(value *WrappedApsConnection) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedApsConnection") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedAPSConnection) Write(writer io.Writer, value *WrappedApsConnection) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedApsConnection struct{} + +func (_ FfiDestroyerWrappedApsConnection) Destroy(value *WrappedApsConnection) { + value.Destroy() +} + +type WrappedApsState struct { + ffiObject FfiObject +} + +func NewWrappedApsState(string *string) *WrappedApsState { + return FfiConverterWrappedAPSStateINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) unsafe.Pointer { + return C.uniffi_rustpushgo_fn_constructor_wrappedapsstate_new(rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(string)), _uniffiStatus) + })) +} + +func (_self *WrappedApsState) ToString() string { + _pointer := _self.ffiObject.incrementPointer("*WrappedApsState") + defer _self.ffiObject.decrementPointer() + return FfiConverterStringINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) RustBufferI { + return rustBufferFromC(C.uniffi_rustpushgo_fn_method_wrappedapsstate_to_string( + _pointer, _uniffiStatus)) + })) +} + +func (object *WrappedApsState) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedAPSState struct{} + +var FfiConverterWrappedAPSStateINSTANCE = FfiConverterWrappedAPSState{} + +func (c FfiConverterWrappedAPSState) Lift(pointer unsafe.Pointer) *WrappedApsState { + result := &WrappedApsState{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedapsstate(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedApsState).Destroy) + return result +} + +func (c FfiConverterWrappedAPSState) Read(reader io.Reader) *WrappedApsState { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedAPSState) Lower(value *WrappedApsState) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedApsState") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedAPSState) Write(writer io.Writer, value *WrappedApsState) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedApsState struct{} + +func (_ FfiDestroyerWrappedApsState) Destroy(value *WrappedApsState) { + value.Destroy() +} + +type WrappedFaceTimeClient struct { + ffiObject FfiObject +} + +func (_self *WrappedFaceTimeClient) AddMembers(sessionId string, handles []string, letmein bool, toMembers *[]string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_add_members( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(sessionId)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(handles)), FfiConverterBoolINSTANCE.Lower(letmein), rustBufferToC(FfiConverterOptionalSequenceStringINSTANCE.Lower(toMembers)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) BindBridgeLinkToSession(handle string, usage string, groupId string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_bind_bridge_link_to_session( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterStringINSTANCE.Lower(usage)), rustBufferToC(FfiConverterStringINSTANCE.Lower(groupId)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) ClearLinks() error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_clear_links( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) CreateSession(groupId string, handle string, participants []string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_create_session( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(groupId)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(participants)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) CreateSessionNoRing(groupId string, handle string, participants []string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_create_session_no_ring( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(groupId)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(participants)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) DeleteLink(pseud string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_delete_link( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(pseud)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) ExportStateJson() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_export_state_json( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) GetLinkForUsage(handle string, usage string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_get_link_for_usage( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), rustBufferToC(FfiConverterStringINSTANCE.Lower(usage)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) GetSessionLink(guid string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_get_session_link( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(guid)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) ListDelegatedLetmeinRequests() []WrappedLetMeInRequest { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_list_delegated_letmein_requests( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceTypeWrappedLetMeInRequestINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) RegisterPendingRing(sessionId string, callerHandle string, targets []string, ttlSecs uint64) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_register_pending_ring( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(sessionId)), rustBufferToC(FfiConverterStringINSTANCE.Lower(callerHandle)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(targets)), FfiConverterUint64INSTANCE.Lower(ttlSecs), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) RemoveMembers(sessionId string, handles []string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_remove_members( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(sessionId)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(handles)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) RespondDelegatedLetmein(delegationUuid string, approvedGroup *string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_respond_delegated_letmein( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(delegationUuid)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(approvedGroup)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) Ring(sessionId string, targets []string, letmein bool) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_ring( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(sessionId)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(targets)), FfiConverterBoolINSTANCE.Lower(letmein), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFaceTimeClient) UseLinkFor(oldUsage string, usage string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfacetimeclient_use_link_for( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(oldUsage)), rustBufferToC(FfiConverterStringINSTANCE.Lower(usage)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (object *WrappedFaceTimeClient) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedFaceTimeClient struct{} + +var FfiConverterWrappedFaceTimeClientINSTANCE = FfiConverterWrappedFaceTimeClient{} + +func (c FfiConverterWrappedFaceTimeClient) Lift(pointer unsafe.Pointer) *WrappedFaceTimeClient { + result := &WrappedFaceTimeClient{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedfacetimeclient(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedFaceTimeClient).Destroy) + return result +} + +func (c FfiConverterWrappedFaceTimeClient) Read(reader io.Reader) *WrappedFaceTimeClient { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedFaceTimeClient) Lower(value *WrappedFaceTimeClient) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedFaceTimeClient") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedFaceTimeClient) Write(writer io.Writer, value *WrappedFaceTimeClient) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedFaceTimeClient struct{} + +func (_ FfiDestroyerWrappedFaceTimeClient) Destroy(value *WrappedFaceTimeClient) { + value.Destroy() +} + +type WrappedFindMyClient struct { + ffiObject FfiObject +} + +func (_self *WrappedFindMyClient) AcceptItemShare(circleId string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFindMyClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfindmyclient_accept_item_share( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(circleId)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFindMyClient) DeleteSharedItem(id string, removeBeacon bool) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFindMyClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfindmyclient_delete_shared_item( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(id)), FfiConverterBoolINSTANCE.Lower(removeBeacon), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFindMyClient) ExportStateJson() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedFindMyClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfindmyclient_export_state_json( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFindMyClient) SyncItemPositions() error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFindMyClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfindmyclient_sync_item_positions( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedFindMyClient) UpdateBeaconName(associatedBeacon string, roleId int64, name string, emoji string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedFindMyClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedfindmyclient_update_beacon_name( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(associatedBeacon)), FfiConverterInt64INSTANCE.Lower(roleId), rustBufferToC(FfiConverterStringINSTANCE.Lower(name)), rustBufferToC(FfiConverterStringINSTANCE.Lower(emoji)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (object *WrappedFindMyClient) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedFindMyClient struct{} + +var FfiConverterWrappedFindMyClientINSTANCE = FfiConverterWrappedFindMyClient{} + +func (c FfiConverterWrappedFindMyClient) Lift(pointer unsafe.Pointer) *WrappedFindMyClient { + result := &WrappedFindMyClient{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedfindmyclient(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedFindMyClient).Destroy) + return result +} + +func (c FfiConverterWrappedFindMyClient) Read(reader io.Reader) *WrappedFindMyClient { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedFindMyClient) Lower(value *WrappedFindMyClient) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedFindMyClient") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedFindMyClient) Write(writer io.Writer, value *WrappedFindMyClient) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedFindMyClient struct{} + +func (_ FfiDestroyerWrappedFindMyClient) Destroy(value *WrappedFindMyClient) { + value.Destroy() +} + +type WrappedIdsngmIdentity struct { + ffiObject FfiObject +} + +func NewWrappedIdsngmIdentity(string *string) *WrappedIdsngmIdentity { + return FfiConverterWrappedIDSNGMIdentityINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) unsafe.Pointer { + return C.uniffi_rustpushgo_fn_constructor_wrappedidsngmidentity_new(rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(string)), _uniffiStatus) + })) +} + +func (_self *WrappedIdsngmIdentity) ToString() string { + _pointer := _self.ffiObject.incrementPointer("*WrappedIdsngmIdentity") + defer _self.ffiObject.decrementPointer() + return FfiConverterStringINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) RustBufferI { + return rustBufferFromC(C.uniffi_rustpushgo_fn_method_wrappedidsngmidentity_to_string( + _pointer, _uniffiStatus)) + })) +} + +func (object *WrappedIdsngmIdentity) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedIDSNGMIdentity struct{} + +var FfiConverterWrappedIDSNGMIdentityINSTANCE = FfiConverterWrappedIDSNGMIdentity{} + +func (c FfiConverterWrappedIDSNGMIdentity) Lift(pointer unsafe.Pointer) *WrappedIdsngmIdentity { + result := &WrappedIdsngmIdentity{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedidsngmidentity(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedIdsngmIdentity).Destroy) + return result +} + +func (c FfiConverterWrappedIDSNGMIdentity) Read(reader io.Reader) *WrappedIdsngmIdentity { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedIDSNGMIdentity) Lower(value *WrappedIdsngmIdentity) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedIdsngmIdentity") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedIDSNGMIdentity) Write(writer io.Writer, value *WrappedIdsngmIdentity) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedIdsngmIdentity struct{} + +func (_ FfiDestroyerWrappedIdsngmIdentity) Destroy(value *WrappedIdsngmIdentity) { + value.Destroy() +} + +type WrappedIdsUsers struct { + ffiObject FfiObject +} + +func NewWrappedIdsUsers(string *string) *WrappedIdsUsers { + return FfiConverterWrappedIDSUsersINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) unsafe.Pointer { + return C.uniffi_rustpushgo_fn_constructor_wrappedidsusers_new(rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(string)), _uniffiStatus) + })) +} + +func (_self *WrappedIdsUsers) GetHandles() []string { + _pointer := _self.ffiObject.incrementPointer("*WrappedIdsUsers") + defer _self.ffiObject.decrementPointer() + return FfiConverterSequenceStringINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) RustBufferI { + return rustBufferFromC(C.uniffi_rustpushgo_fn_method_wrappedidsusers_get_handles( + _pointer, _uniffiStatus)) + })) +} + +func (_self *WrappedIdsUsers) LoginId(i uint64) string { + _pointer := _self.ffiObject.incrementPointer("*WrappedIdsUsers") + defer _self.ffiObject.decrementPointer() + return FfiConverterStringINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) RustBufferI { + return rustBufferFromC(C.uniffi_rustpushgo_fn_method_wrappedidsusers_login_id( + _pointer, FfiConverterUint64INSTANCE.Lower(i), _uniffiStatus)) + })) +} + +func (_self *WrappedIdsUsers) ToString() string { + _pointer := _self.ffiObject.incrementPointer("*WrappedIdsUsers") + defer _self.ffiObject.decrementPointer() + return FfiConverterStringINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) RustBufferI { + return rustBufferFromC(C.uniffi_rustpushgo_fn_method_wrappedidsusers_to_string( + _pointer, _uniffiStatus)) + })) +} + +func (_self *WrappedIdsUsers) ValidateKeystore() bool { + _pointer := _self.ffiObject.incrementPointer("*WrappedIdsUsers") + defer _self.ffiObject.decrementPointer() + return FfiConverterBoolINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) C.int8_t { + return C.uniffi_rustpushgo_fn_method_wrappedidsusers_validate_keystore( + _pointer, _uniffiStatus) + })) +} + +func (object *WrappedIdsUsers) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedIDSUsers struct{} + +var FfiConverterWrappedIDSUsersINSTANCE = FfiConverterWrappedIDSUsers{} + +func (c FfiConverterWrappedIDSUsers) Lift(pointer unsafe.Pointer) *WrappedIdsUsers { + result := &WrappedIdsUsers{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedidsusers(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedIdsUsers).Destroy) + return result +} + +func (c FfiConverterWrappedIDSUsers) Read(reader io.Reader) *WrappedIdsUsers { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedIDSUsers) Lower(value *WrappedIdsUsers) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedIdsUsers") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedIDSUsers) Write(writer io.Writer, value *WrappedIdsUsers) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedIdsUsers struct{} + +func (_ FfiDestroyerWrappedIdsUsers) Destroy(value *WrappedIdsUsers) { + value.Destroy() +} + +type WrappedOsConfig struct { + ffiObject FfiObject +} + +func (_self *WrappedOsConfig) GetDeviceId() string { + _pointer := _self.ffiObject.incrementPointer("*WrappedOsConfig") + defer _self.ffiObject.decrementPointer() + return FfiConverterStringINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) RustBufferI { + return rustBufferFromC(C.uniffi_rustpushgo_fn_method_wrappedosconfig_get_device_id( + _pointer, _uniffiStatus)) + })) +} + +func (_self *WrappedOsConfig) RequiresNacRelay() bool { + _pointer := _self.ffiObject.incrementPointer("*WrappedOsConfig") + defer _self.ffiObject.decrementPointer() + return FfiConverterBoolINSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) C.int8_t { + return C.uniffi_rustpushgo_fn_method_wrappedosconfig_requires_nac_relay( + _pointer, _uniffiStatus) + })) +} + +func (object *WrappedOsConfig) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedOSConfig struct{} + +var FfiConverterWrappedOSConfigINSTANCE = FfiConverterWrappedOSConfig{} + +func (c FfiConverterWrappedOSConfig) Lift(pointer unsafe.Pointer) *WrappedOsConfig { + result := &WrappedOsConfig{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedosconfig(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedOsConfig).Destroy) + return result +} + +func (c FfiConverterWrappedOSConfig) Read(reader io.Reader) *WrappedOsConfig { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedOSConfig) Lower(value *WrappedOsConfig) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedOsConfig") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedOSConfig) Write(writer io.Writer, value *WrappedOsConfig) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedOsConfig struct{} + +func (_ FfiDestroyerWrappedOsConfig) Destroy(value *WrappedOsConfig) { + value.Destroy() +} + +type WrappedPasswordsClient struct { + ffiObject FfiObject +} + +func (_self *WrappedPasswordsClient) AcceptInvite(inviteId string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_accept_invite( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(inviteId)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) CreateGroup(name string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_create_group( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(name)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) DeclineInvite(inviteId string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_decline_invite( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(inviteId)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) DeletePasswordRawEntry(id string, group *string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_delete_password_raw_entry( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(id)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(group)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) ExportStateJson() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_export_state_json( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) GetPasswordSiteCounts(site string) WrappedPasswordSiteCounts { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_get_password_site_counts( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(site)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterTypeWrappedPasswordSiteCountsINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) InviteUser(groupId string, handle string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_invite_user( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(groupId)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) ListPasswordRawEntryRefs() []WrappedPasswordEntryRef { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_list_password_raw_entry_refs( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceTypeWrappedPasswordEntryRefINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) QueryHandle(handle string) (bool, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_query_handle( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_i8(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) C.int8_t { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_i8(unsafe.Pointer(handle), status) + }, + FfiConverterBoolINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_i8(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) RemoveGroup(id string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_remove_group( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(id)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) RemoveUser(groupId string, handle string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_remove_user( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(groupId)), rustBufferToC(FfiConverterStringINSTANCE.Lower(handle)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) RenameGroup(id string, newName string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_rename_group( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(id)), rustBufferToC(FfiConverterStringINSTANCE.Lower(newName)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) SyncPasswords() error { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_sync_passwords( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedPasswordsClient) UpsertPasswordRawEntry(id string, site string, account string, secretData []byte, group *string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedpasswordsclient_upsert_password_raw_entry( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(id)), rustBufferToC(FfiConverterStringINSTANCE.Lower(site)), rustBufferToC(FfiConverterStringINSTANCE.Lower(account)), rustBufferToC(FfiConverterBytesINSTANCE.Lower(secretData)), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(group)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (object *WrappedPasswordsClient) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedPasswordsClient struct{} + +var FfiConverterWrappedPasswordsClientINSTANCE = FfiConverterWrappedPasswordsClient{} + +func (c FfiConverterWrappedPasswordsClient) Lift(pointer unsafe.Pointer) *WrappedPasswordsClient { + result := &WrappedPasswordsClient{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedpasswordsclient(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedPasswordsClient).Destroy) + return result +} + +func (c FfiConverterWrappedPasswordsClient) Read(reader io.Reader) *WrappedPasswordsClient { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedPasswordsClient) Lower(value *WrappedPasswordsClient) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedPasswordsClient") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedPasswordsClient) Write(writer io.Writer, value *WrappedPasswordsClient) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedPasswordsClient struct{} + +func (_ FfiDestroyerWrappedPasswordsClient) Destroy(value *WrappedPasswordsClient) { + value.Destroy() +} + +type WrappedSharedStreamsClient struct { + ffiObject FfiObject +} + +func (_self *WrappedSharedStreamsClient) DeleteAssets(album string, assets []string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_delete_assets( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(album)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(assets)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) DownloadFile(album string, assetGuid string) ([]byte, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_download_file( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(album)), rustBufferToC(FfiConverterStringINSTANCE.Lower(assetGuid)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterBytesINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) ExportStateJson() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_export_state_json( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) GetAlbumAssets(album string) ([]SharedAssetInfo, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_get_album_assets( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(album)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceTypeSharedAssetInfoINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) GetAlbumSummary(album string) ([]string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_get_album_summary( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(album)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) GetAssetsJson(album string, assets []string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_get_assets_json( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(album)), rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(assets)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) GetChanges() ([]string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_get_changes( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) ListAlbumIds() []string { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_list_album_ids( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) ListAlbums() []SharedAlbumInfo { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_list_albums( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceTypeSharedAlbumInfoINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) Subscribe(album string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_subscribe( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(album)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) SubscribeToken(token string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_subscribe_token( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(token)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedSharedStreamsClient) Unsubscribe(album string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_unsubscribe( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(album)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (object *WrappedSharedStreamsClient) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedSharedStreamsClient struct{} + +var FfiConverterWrappedSharedStreamsClientINSTANCE = FfiConverterWrappedSharedStreamsClient{} + +func (c FfiConverterWrappedSharedStreamsClient) Lift(pointer unsafe.Pointer) *WrappedSharedStreamsClient { + result := &WrappedSharedStreamsClient{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedsharedstreamsclient(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedSharedStreamsClient).Destroy) + return result +} + +func (c FfiConverterWrappedSharedStreamsClient) Read(reader io.Reader) *WrappedSharedStreamsClient { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedSharedStreamsClient) Lower(value *WrappedSharedStreamsClient) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedSharedStreamsClient") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedSharedStreamsClient) Write(writer io.Writer, value *WrappedSharedStreamsClient) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedSharedStreamsClient struct{} + +func (_ FfiDestroyerWrappedSharedStreamsClient) Destroy(value *WrappedSharedStreamsClient) { + value.Destroy() +} + +type WrappedStatusKitClient struct { + ffiObject FfiObject +} + +func (_self *WrappedStatusKitClient) ClearInterestTokens() { + _pointer := _self.ffiObject.incrementPointer("*WrappedStatusKitClient") + defer _self.ffiObject.decrementPointer() + uniffiRustCallAsync(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedstatuskitclient_clear_interest_tokens( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedStatusKitClient) ExportStateJson() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedStatusKitClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedstatuskitclient_export_state_json( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedStatusKitClient) GetKnownHandles() []string { + _pointer := _self.ffiObject.incrementPointer("*WrappedStatusKitClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedstatuskitclient_get_known_handles( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedStatusKitClient) InviteToChannel(senderHandle string, handles []WrappedStatusKitInviteHandle) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedStatusKitClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedstatuskitclient_invite_to_channel( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(senderHandle)), rustBufferToC(FfiConverterSequenceTypeWrappedStatusKitInviteHandleINSTANCE.Lower(handles)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedStatusKitClient) RequestHandles(handles []string) { + _pointer := _self.ffiObject.incrementPointer("*WrappedStatusKitClient") + defer _self.ffiObject.decrementPointer() + uniffiRustCallAsync(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedstatuskitclient_request_handles( + _pointer, rustBufferToC(FfiConverterSequenceStringINSTANCE.Lower(handles)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedStatusKitClient) ResetKeys() { + _pointer := _self.ffiObject.incrementPointer("*WrappedStatusKitClient") + defer _self.ffiObject.decrementPointer() + uniffiRustCallAsync(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedstatuskitclient_reset_keys( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedStatusKitClient) RollKeys() { + _pointer := _self.ffiObject.incrementPointer("*WrappedStatusKitClient") + defer _self.ffiObject.decrementPointer() + uniffiRustCallAsync(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedstatuskitclient_roll_keys( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedStatusKitClient) ShareStatus(active bool, mode *string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedStatusKitClient") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedstatuskitclient_share_status( + _pointer, FfiConverterBoolINSTANCE.Lower(active), rustBufferToC(FfiConverterOptionalStringINSTANCE.Lower(mode)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (object *WrappedStatusKitClient) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedStatusKitClient struct{} + +var FfiConverterWrappedStatusKitClientINSTANCE = FfiConverterWrappedStatusKitClient{} + +func (c FfiConverterWrappedStatusKitClient) Lift(pointer unsafe.Pointer) *WrappedStatusKitClient { + result := &WrappedStatusKitClient{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedstatuskitclient(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedStatusKitClient).Destroy) + return result +} + +func (c FfiConverterWrappedStatusKitClient) Read(reader io.Reader) *WrappedStatusKitClient { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedStatusKitClient) Lower(value *WrappedStatusKitClient) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedStatusKitClient") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedStatusKitClient) Write(writer io.Writer, value *WrappedStatusKitClient) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedStatusKitClient struct{} + +func (_ FfiDestroyerWrappedStatusKitClient) Destroy(value *WrappedStatusKitClient) { + value.Destroy() +} + +type WrappedTokenProvider struct { + ffiObject FfiObject +} + +func (_self *WrappedTokenProvider) GetContactsUrl() (*string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedTokenProvider") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_contacts_url( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterOptionalStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedTokenProvider) GetDsid() (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedTokenProvider") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_dsid( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedTokenProvider) GetEscrowDevices() ([]EscrowDeviceInfo, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedTokenProvider") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_escrow_devices( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterSequenceTypeEscrowDeviceInfoINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedTokenProvider) GetIcloudAuthHeaders() (map[string]string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedTokenProvider") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_icloud_auth_headers( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterMapStringStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedTokenProvider) GetMmeDelegateJson() (*string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedTokenProvider") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_mme_delegate_json( + _pointer, + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterOptionalStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedTokenProvider) JoinKeychainClique(passcode string) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedTokenProvider") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedtokenprovider_join_keychain_clique( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(passcode)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedTokenProvider) JoinKeychainCliqueForDevice(passcode string, deviceIndex uint32) (string, error) { + _pointer := _self.ffiObject.incrementPointer("*WrappedTokenProvider") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedtokenprovider_join_keychain_clique_for_device( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(passcode)), FfiConverterUint32INSTANCE.Lower(deviceIndex), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_rust_buffer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) RustBufferI { + // completeFunc + return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)) + }, + FfiConverterStringINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_rust_buffer(unsafe.Pointer(rustFuture), status) + }) +} + +func (_self *WrappedTokenProvider) SeedMmeDelegateJson(json string) error { + _pointer := _self.ffiObject.incrementPointer("*WrappedTokenProvider") + defer _self.ffiObject.decrementPointer() + return uniffiRustCallAsyncWithError( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_method_wrappedtokenprovider_seed_mme_delegate_json( + _pointer, rustBufferToC(FfiConverterStringINSTANCE.Lower(json)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_void(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) { + // completeFunc + C.ffi_rustpushgo_rust_future_complete_void(unsafe.Pointer(handle), status) + }, + func(bool) {}, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_void(unsafe.Pointer(rustFuture), status) + }) +} + +func (object *WrappedTokenProvider) Destroy() { + runtime.SetFinalizer(object, nil) + object.ffiObject.destroy() +} + +type FfiConverterWrappedTokenProvider struct{} + +var FfiConverterWrappedTokenProviderINSTANCE = FfiConverterWrappedTokenProvider{} + +func (c FfiConverterWrappedTokenProvider) Lift(pointer unsafe.Pointer) *WrappedTokenProvider { + result := &WrappedTokenProvider{ + newFfiObject( + pointer, + func(pointer unsafe.Pointer, status *C.RustCallStatus) { + C.uniffi_rustpushgo_fn_free_wrappedtokenprovider(pointer, status) + }), + } + runtime.SetFinalizer(result, (*WrappedTokenProvider).Destroy) + return result +} + +func (c FfiConverterWrappedTokenProvider) Read(reader io.Reader) *WrappedTokenProvider { + return c.Lift(unsafe.Pointer(uintptr(readUint64(reader)))) +} + +func (c FfiConverterWrappedTokenProvider) Lower(value *WrappedTokenProvider) unsafe.Pointer { + // TODO: this is bad - all synchronization from ObjectRuntime.go is discarded here, + // because the pointer will be decremented immediately after this function returns, + // and someone will be left holding onto a non-locked pointer. + pointer := value.ffiObject.incrementPointer("*WrappedTokenProvider") + defer value.ffiObject.decrementPointer() + return pointer +} + +func (c FfiConverterWrappedTokenProvider) Write(writer io.Writer, value *WrappedTokenProvider) { + writeUint64(writer, uint64(uintptr(c.Lower(value)))) +} + +type FfiDestroyerWrappedTokenProvider struct{} + +func (_ FfiDestroyerWrappedTokenProvider) Destroy(value *WrappedTokenProvider) { + value.Destroy() +} + +type AccountPersistData struct { + Username string + HashedPasswordHex string + Pet string + Adsid string + Dsid string + SpdBase64 string +} + +func (r *AccountPersistData) Destroy() { + FfiDestroyerString{}.Destroy(r.Username) + FfiDestroyerString{}.Destroy(r.HashedPasswordHex) + FfiDestroyerString{}.Destroy(r.Pet) + FfiDestroyerString{}.Destroy(r.Adsid) + FfiDestroyerString{}.Destroy(r.Dsid) + FfiDestroyerString{}.Destroy(r.SpdBase64) +} + +type FfiConverterTypeAccountPersistData struct{} + +var FfiConverterTypeAccountPersistDataINSTANCE = FfiConverterTypeAccountPersistData{} + +func (c FfiConverterTypeAccountPersistData) Lift(rb RustBufferI) AccountPersistData { + return LiftFromRustBuffer[AccountPersistData](c, rb) +} + +func (c FfiConverterTypeAccountPersistData) Read(reader io.Reader) AccountPersistData { + return AccountPersistData{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeAccountPersistData) Lower(value AccountPersistData) RustBuffer { + return LowerIntoRustBuffer[AccountPersistData](c, value) +} + +func (c FfiConverterTypeAccountPersistData) Write(writer io.Writer, value AccountPersistData) { + FfiConverterStringINSTANCE.Write(writer, value.Username) + FfiConverterStringINSTANCE.Write(writer, value.HashedPasswordHex) + FfiConverterStringINSTANCE.Write(writer, value.Pet) + FfiConverterStringINSTANCE.Write(writer, value.Adsid) + FfiConverterStringINSTANCE.Write(writer, value.Dsid) + FfiConverterStringINSTANCE.Write(writer, value.SpdBase64) +} + +type FfiDestroyerTypeAccountPersistData struct{} + +func (_ FfiDestroyerTypeAccountPersistData) Destroy(value AccountPersistData) { + value.Destroy() +} + +type EscrowDeviceInfo struct { + Index uint32 + DeviceName string + DeviceModel string + Serial string + Timestamp string +} + +func (r *EscrowDeviceInfo) Destroy() { + FfiDestroyerUint32{}.Destroy(r.Index) + FfiDestroyerString{}.Destroy(r.DeviceName) + FfiDestroyerString{}.Destroy(r.DeviceModel) + FfiDestroyerString{}.Destroy(r.Serial) + FfiDestroyerString{}.Destroy(r.Timestamp) +} + +type FfiConverterTypeEscrowDeviceInfo struct{} + +var FfiConverterTypeEscrowDeviceInfoINSTANCE = FfiConverterTypeEscrowDeviceInfo{} + +func (c FfiConverterTypeEscrowDeviceInfo) Lift(rb RustBufferI) EscrowDeviceInfo { + return LiftFromRustBuffer[EscrowDeviceInfo](c, rb) +} + +func (c FfiConverterTypeEscrowDeviceInfo) Read(reader io.Reader) EscrowDeviceInfo { + return EscrowDeviceInfo{ + FfiConverterUint32INSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeEscrowDeviceInfo) Lower(value EscrowDeviceInfo) RustBuffer { + return LowerIntoRustBuffer[EscrowDeviceInfo](c, value) +} + +func (c FfiConverterTypeEscrowDeviceInfo) Write(writer io.Writer, value EscrowDeviceInfo) { + FfiConverterUint32INSTANCE.Write(writer, value.Index) + FfiConverterStringINSTANCE.Write(writer, value.DeviceName) + FfiConverterStringINSTANCE.Write(writer, value.DeviceModel) + FfiConverterStringINSTANCE.Write(writer, value.Serial) + FfiConverterStringINSTANCE.Write(writer, value.Timestamp) +} + +type FfiDestroyerTypeEscrowDeviceInfo struct{} + +func (_ FfiDestroyerTypeEscrowDeviceInfo) Destroy(value EscrowDeviceInfo) { + value.Destroy() +} + +type IdsUsersWithIdentityRecord struct { + Users *WrappedIdsUsers + Identity *WrappedIdsngmIdentity + TokenProvider **WrappedTokenProvider + AccountPersist *AccountPersistData +} + +func (r *IdsUsersWithIdentityRecord) Destroy() { + FfiDestroyerWrappedIdsUsers{}.Destroy(r.Users) + FfiDestroyerWrappedIdsngmIdentity{}.Destroy(r.Identity) + FfiDestroyerOptionalWrappedTokenProvider{}.Destroy(r.TokenProvider) + FfiDestroyerOptionalTypeAccountPersistData{}.Destroy(r.AccountPersist) +} + +type FfiConverterTypeIDSUsersWithIdentityRecord struct{} + +var FfiConverterTypeIDSUsersWithIdentityRecordINSTANCE = FfiConverterTypeIDSUsersWithIdentityRecord{} + +func (c FfiConverterTypeIDSUsersWithIdentityRecord) Lift(rb RustBufferI) IdsUsersWithIdentityRecord { + return LiftFromRustBuffer[IdsUsersWithIdentityRecord](c, rb) +} + +func (c FfiConverterTypeIDSUsersWithIdentityRecord) Read(reader io.Reader) IdsUsersWithIdentityRecord { + return IdsUsersWithIdentityRecord{ + FfiConverterWrappedIDSUsersINSTANCE.Read(reader), + FfiConverterWrappedIDSNGMIdentityINSTANCE.Read(reader), + FfiConverterOptionalWrappedTokenProviderINSTANCE.Read(reader), + FfiConverterOptionalTypeAccountPersistDataINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeIDSUsersWithIdentityRecord) Lower(value IdsUsersWithIdentityRecord) RustBuffer { + return LowerIntoRustBuffer[IdsUsersWithIdentityRecord](c, value) +} + +func (c FfiConverterTypeIDSUsersWithIdentityRecord) Write(writer io.Writer, value IdsUsersWithIdentityRecord) { + FfiConverterWrappedIDSUsersINSTANCE.Write(writer, value.Users) + FfiConverterWrappedIDSNGMIdentityINSTANCE.Write(writer, value.Identity) + FfiConverterOptionalWrappedTokenProviderINSTANCE.Write(writer, value.TokenProvider) + FfiConverterOptionalTypeAccountPersistDataINSTANCE.Write(writer, value.AccountPersist) +} + +type FfiDestroyerTypeIdsUsersWithIdentityRecord struct{} + +func (_ FfiDestroyerTypeIdsUsersWithIdentityRecord) Destroy(value IdsUsersWithIdentityRecord) { + value.Destroy() +} + +type SharedAlbumInfo struct { + Albumguid string + Name *string + Fullname *string + Email *string +} + +func (r *SharedAlbumInfo) Destroy() { + FfiDestroyerString{}.Destroy(r.Albumguid) + FfiDestroyerOptionalString{}.Destroy(r.Name) + FfiDestroyerOptionalString{}.Destroy(r.Fullname) + FfiDestroyerOptionalString{}.Destroy(r.Email) +} + +type FfiConverterTypeSharedAlbumInfo struct{} + +var FfiConverterTypeSharedAlbumInfoINSTANCE = FfiConverterTypeSharedAlbumInfo{} + +func (c FfiConverterTypeSharedAlbumInfo) Lift(rb RustBufferI) SharedAlbumInfo { + return LiftFromRustBuffer[SharedAlbumInfo](c, rb) +} + +func (c FfiConverterTypeSharedAlbumInfo) Read(reader io.Reader) SharedAlbumInfo { + return SharedAlbumInfo{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeSharedAlbumInfo) Lower(value SharedAlbumInfo) RustBuffer { + return LowerIntoRustBuffer[SharedAlbumInfo](c, value) +} + +func (c FfiConverterTypeSharedAlbumInfo) Write(writer io.Writer, value SharedAlbumInfo) { + FfiConverterStringINSTANCE.Write(writer, value.Albumguid) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Name) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Fullname) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Email) +} + +type FfiDestroyerTypeSharedAlbumInfo struct{} + +func (_ FfiDestroyerTypeSharedAlbumInfo) Destroy(value SharedAlbumInfo) { + value.Destroy() +} + +type SharedAssetInfo struct { + Assetguid string + Filename string + DateCreated string + MediaType string + Width string + Height string + Size string +} + +func (r *SharedAssetInfo) Destroy() { + FfiDestroyerString{}.Destroy(r.Assetguid) + FfiDestroyerString{}.Destroy(r.Filename) + FfiDestroyerString{}.Destroy(r.DateCreated) + FfiDestroyerString{}.Destroy(r.MediaType) + FfiDestroyerString{}.Destroy(r.Width) + FfiDestroyerString{}.Destroy(r.Height) + FfiDestroyerString{}.Destroy(r.Size) +} + +type FfiConverterTypeSharedAssetInfo struct{} + +var FfiConverterTypeSharedAssetInfoINSTANCE = FfiConverterTypeSharedAssetInfo{} + +func (c FfiConverterTypeSharedAssetInfo) Lift(rb RustBufferI) SharedAssetInfo { + return LiftFromRustBuffer[SharedAssetInfo](c, rb) +} + +func (c FfiConverterTypeSharedAssetInfo) Read(reader io.Reader) SharedAssetInfo { + return SharedAssetInfo{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeSharedAssetInfo) Lower(value SharedAssetInfo) RustBuffer { + return LowerIntoRustBuffer[SharedAssetInfo](c, value) +} + +func (c FfiConverterTypeSharedAssetInfo) Write(writer io.Writer, value SharedAssetInfo) { + FfiConverterStringINSTANCE.Write(writer, value.Assetguid) + FfiConverterStringINSTANCE.Write(writer, value.Filename) + FfiConverterStringINSTANCE.Write(writer, value.DateCreated) + FfiConverterStringINSTANCE.Write(writer, value.MediaType) + FfiConverterStringINSTANCE.Write(writer, value.Width) + FfiConverterStringINSTANCE.Write(writer, value.Height) + FfiConverterStringINSTANCE.Write(writer, value.Size) +} + +type FfiDestroyerTypeSharedAssetInfo struct{} + +func (_ FfiDestroyerTypeSharedAssetInfo) Destroy(value SharedAssetInfo) { + value.Destroy() +} + +type WrappedAttachment struct { + MimeType string + Filename string + UtiType string + Size uint64 + IsInline bool + InlineData *[]byte + Iris bool +} + +func (r *WrappedAttachment) Destroy() { + FfiDestroyerString{}.Destroy(r.MimeType) + FfiDestroyerString{}.Destroy(r.Filename) + FfiDestroyerString{}.Destroy(r.UtiType) + FfiDestroyerUint64{}.Destroy(r.Size) + FfiDestroyerBool{}.Destroy(r.IsInline) + FfiDestroyerOptionalBytes{}.Destroy(r.InlineData) + FfiDestroyerBool{}.Destroy(r.Iris) +} + +type FfiConverterTypeWrappedAttachment struct{} + +var FfiConverterTypeWrappedAttachmentINSTANCE = FfiConverterTypeWrappedAttachment{} + +func (c FfiConverterTypeWrappedAttachment) Lift(rb RustBufferI) WrappedAttachment { + return LiftFromRustBuffer[WrappedAttachment](c, rb) +} + +func (c FfiConverterTypeWrappedAttachment) Read(reader io.Reader) WrappedAttachment { + return WrappedAttachment{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedAttachment) Lower(value WrappedAttachment) RustBuffer { + return LowerIntoRustBuffer[WrappedAttachment](c, value) +} + +func (c FfiConverterTypeWrappedAttachment) Write(writer io.Writer, value WrappedAttachment) { + FfiConverterStringINSTANCE.Write(writer, value.MimeType) + FfiConverterStringINSTANCE.Write(writer, value.Filename) + FfiConverterStringINSTANCE.Write(writer, value.UtiType) + FfiConverterUint64INSTANCE.Write(writer, value.Size) + FfiConverterBoolINSTANCE.Write(writer, value.IsInline) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.InlineData) + FfiConverterBoolINSTANCE.Write(writer, value.Iris) +} + +type FfiDestroyerTypeWrappedAttachment struct{} + +func (_ FfiDestroyerTypeWrappedAttachment) Destroy(value WrappedAttachment) { + value.Destroy() +} + +type WrappedCloudAttachmentInfo struct { + Guid string + MimeType *string + UtiType *string + Filename *string + FileSize int64 + RecordName string + HideAttachment bool + HasAvid bool + FordKey *[]byte + AvidFordKey *[]byte +} + +func (r *WrappedCloudAttachmentInfo) Destroy() { + FfiDestroyerString{}.Destroy(r.Guid) + FfiDestroyerOptionalString{}.Destroy(r.MimeType) + FfiDestroyerOptionalString{}.Destroy(r.UtiType) + FfiDestroyerOptionalString{}.Destroy(r.Filename) + FfiDestroyerInt64{}.Destroy(r.FileSize) + FfiDestroyerString{}.Destroy(r.RecordName) + FfiDestroyerBool{}.Destroy(r.HideAttachment) + FfiDestroyerBool{}.Destroy(r.HasAvid) + FfiDestroyerOptionalBytes{}.Destroy(r.FordKey) + FfiDestroyerOptionalBytes{}.Destroy(r.AvidFordKey) +} + +type FfiConverterTypeWrappedCloudAttachmentInfo struct{} + +var FfiConverterTypeWrappedCloudAttachmentInfoINSTANCE = FfiConverterTypeWrappedCloudAttachmentInfo{} + +func (c FfiConverterTypeWrappedCloudAttachmentInfo) Lift(rb RustBufferI) WrappedCloudAttachmentInfo { + return LiftFromRustBuffer[WrappedCloudAttachmentInfo](c, rb) +} + +func (c FfiConverterTypeWrappedCloudAttachmentInfo) Read(reader io.Reader) WrappedCloudAttachmentInfo { + return WrappedCloudAttachmentInfo{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterInt64INSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedCloudAttachmentInfo) Lower(value WrappedCloudAttachmentInfo) RustBuffer { + return LowerIntoRustBuffer[WrappedCloudAttachmentInfo](c, value) +} + +func (c FfiConverterTypeWrappedCloudAttachmentInfo) Write(writer io.Writer, value WrappedCloudAttachmentInfo) { + FfiConverterStringINSTANCE.Write(writer, value.Guid) + FfiConverterOptionalStringINSTANCE.Write(writer, value.MimeType) + FfiConverterOptionalStringINSTANCE.Write(writer, value.UtiType) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Filename) + FfiConverterInt64INSTANCE.Write(writer, value.FileSize) + FfiConverterStringINSTANCE.Write(writer, value.RecordName) + FfiConverterBoolINSTANCE.Write(writer, value.HideAttachment) + FfiConverterBoolINSTANCE.Write(writer, value.HasAvid) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.FordKey) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.AvidFordKey) +} + +type FfiDestroyerTypeWrappedCloudAttachmentInfo struct{} + +func (_ FfiDestroyerTypeWrappedCloudAttachmentInfo) Destroy(value WrappedCloudAttachmentInfo) { + value.Destroy() +} + +type WrappedCloudSyncAttachmentsPage struct { + ContinuationToken *string + Status int32 + Done bool + Attachments []WrappedCloudAttachmentInfo +} + +func (r *WrappedCloudSyncAttachmentsPage) Destroy() { + FfiDestroyerOptionalString{}.Destroy(r.ContinuationToken) + FfiDestroyerInt32{}.Destroy(r.Status) + FfiDestroyerBool{}.Destroy(r.Done) + FfiDestroyerSequenceTypeWrappedCloudAttachmentInfo{}.Destroy(r.Attachments) +} + +type FfiConverterTypeWrappedCloudSyncAttachmentsPage struct{} + +var FfiConverterTypeWrappedCloudSyncAttachmentsPageINSTANCE = FfiConverterTypeWrappedCloudSyncAttachmentsPage{} + +func (c FfiConverterTypeWrappedCloudSyncAttachmentsPage) Lift(rb RustBufferI) WrappedCloudSyncAttachmentsPage { + return LiftFromRustBuffer[WrappedCloudSyncAttachmentsPage](c, rb) +} + +func (c FfiConverterTypeWrappedCloudSyncAttachmentsPage) Read(reader io.Reader) WrappedCloudSyncAttachmentsPage { + return WrappedCloudSyncAttachmentsPage{ + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterInt32INSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterSequenceTypeWrappedCloudAttachmentInfoINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedCloudSyncAttachmentsPage) Lower(value WrappedCloudSyncAttachmentsPage) RustBuffer { + return LowerIntoRustBuffer[WrappedCloudSyncAttachmentsPage](c, value) +} + +func (c FfiConverterTypeWrappedCloudSyncAttachmentsPage) Write(writer io.Writer, value WrappedCloudSyncAttachmentsPage) { + FfiConverterOptionalStringINSTANCE.Write(writer, value.ContinuationToken) + FfiConverterInt32INSTANCE.Write(writer, value.Status) + FfiConverterBoolINSTANCE.Write(writer, value.Done) + FfiConverterSequenceTypeWrappedCloudAttachmentInfoINSTANCE.Write(writer, value.Attachments) +} + +type FfiDestroyerTypeWrappedCloudSyncAttachmentsPage struct{} + +func (_ FfiDestroyerTypeWrappedCloudSyncAttachmentsPage) Destroy(value WrappedCloudSyncAttachmentsPage) { + value.Destroy() +} + +type WrappedCloudSyncChat struct { + RecordName string + CloudChatId string + GroupId string + Style int64 + Service string + DisplayName *string + Participants []string + Deleted bool + UpdatedTimestampMs uint64 + GroupPhotoGuid *string + IsFiltered int64 +} + +func (r *WrappedCloudSyncChat) Destroy() { + FfiDestroyerString{}.Destroy(r.RecordName) + FfiDestroyerString{}.Destroy(r.CloudChatId) + FfiDestroyerString{}.Destroy(r.GroupId) + FfiDestroyerInt64{}.Destroy(r.Style) + FfiDestroyerString{}.Destroy(r.Service) + FfiDestroyerOptionalString{}.Destroy(r.DisplayName) + FfiDestroyerSequenceString{}.Destroy(r.Participants) + FfiDestroyerBool{}.Destroy(r.Deleted) + FfiDestroyerUint64{}.Destroy(r.UpdatedTimestampMs) + FfiDestroyerOptionalString{}.Destroy(r.GroupPhotoGuid) + FfiDestroyerInt64{}.Destroy(r.IsFiltered) +} + +type FfiConverterTypeWrappedCloudSyncChat struct{} + +var FfiConverterTypeWrappedCloudSyncChatINSTANCE = FfiConverterTypeWrappedCloudSyncChat{} + +func (c FfiConverterTypeWrappedCloudSyncChat) Lift(rb RustBufferI) WrappedCloudSyncChat { + return LiftFromRustBuffer[WrappedCloudSyncChat](c, rb) +} + +func (c FfiConverterTypeWrappedCloudSyncChat) Read(reader io.Reader) WrappedCloudSyncChat { + return WrappedCloudSyncChat{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterInt64INSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterSequenceStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterInt64INSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedCloudSyncChat) Lower(value WrappedCloudSyncChat) RustBuffer { + return LowerIntoRustBuffer[WrappedCloudSyncChat](c, value) +} + +func (c FfiConverterTypeWrappedCloudSyncChat) Write(writer io.Writer, value WrappedCloudSyncChat) { + FfiConverterStringINSTANCE.Write(writer, value.RecordName) + FfiConverterStringINSTANCE.Write(writer, value.CloudChatId) + FfiConverterStringINSTANCE.Write(writer, value.GroupId) + FfiConverterInt64INSTANCE.Write(writer, value.Style) + FfiConverterStringINSTANCE.Write(writer, value.Service) + FfiConverterOptionalStringINSTANCE.Write(writer, value.DisplayName) + FfiConverterSequenceStringINSTANCE.Write(writer, value.Participants) + FfiConverterBoolINSTANCE.Write(writer, value.Deleted) + FfiConverterUint64INSTANCE.Write(writer, value.UpdatedTimestampMs) + FfiConverterOptionalStringINSTANCE.Write(writer, value.GroupPhotoGuid) + FfiConverterInt64INSTANCE.Write(writer, value.IsFiltered) +} + +type FfiDestroyerTypeWrappedCloudSyncChat struct{} + +func (_ FfiDestroyerTypeWrappedCloudSyncChat) Destroy(value WrappedCloudSyncChat) { + value.Destroy() +} + +type WrappedCloudSyncChatsPage struct { + ContinuationToken *string + Status int32 + Done bool + Chats []WrappedCloudSyncChat +} + +func (r *WrappedCloudSyncChatsPage) Destroy() { + FfiDestroyerOptionalString{}.Destroy(r.ContinuationToken) + FfiDestroyerInt32{}.Destroy(r.Status) + FfiDestroyerBool{}.Destroy(r.Done) + FfiDestroyerSequenceTypeWrappedCloudSyncChat{}.Destroy(r.Chats) +} + +type FfiConverterTypeWrappedCloudSyncChatsPage struct{} + +var FfiConverterTypeWrappedCloudSyncChatsPageINSTANCE = FfiConverterTypeWrappedCloudSyncChatsPage{} + +func (c FfiConverterTypeWrappedCloudSyncChatsPage) Lift(rb RustBufferI) WrappedCloudSyncChatsPage { + return LiftFromRustBuffer[WrappedCloudSyncChatsPage](c, rb) +} + +func (c FfiConverterTypeWrappedCloudSyncChatsPage) Read(reader io.Reader) WrappedCloudSyncChatsPage { + return WrappedCloudSyncChatsPage{ + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterInt32INSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterSequenceTypeWrappedCloudSyncChatINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedCloudSyncChatsPage) Lower(value WrappedCloudSyncChatsPage) RustBuffer { + return LowerIntoRustBuffer[WrappedCloudSyncChatsPage](c, value) +} + +func (c FfiConverterTypeWrappedCloudSyncChatsPage) Write(writer io.Writer, value WrappedCloudSyncChatsPage) { + FfiConverterOptionalStringINSTANCE.Write(writer, value.ContinuationToken) + FfiConverterInt32INSTANCE.Write(writer, value.Status) + FfiConverterBoolINSTANCE.Write(writer, value.Done) + FfiConverterSequenceTypeWrappedCloudSyncChatINSTANCE.Write(writer, value.Chats) +} + +type FfiDestroyerTypeWrappedCloudSyncChatsPage struct{} + +func (_ FfiDestroyerTypeWrappedCloudSyncChatsPage) Destroy(value WrappedCloudSyncChatsPage) { + value.Destroy() +} + +type WrappedCloudSyncMessage struct { + RecordName string + Guid string + CloudChatId string + Sender string + IsFromMe bool + Text *string + Subject *string + Service string + TimestampMs int64 + Deleted bool + TapbackType *uint32 + TapbackTargetGuid *string + TapbackEmoji *string + AttachmentGuids []string + DateReadMs int64 + MsgType int64 + HasBody bool +} + +func (r *WrappedCloudSyncMessage) Destroy() { + FfiDestroyerString{}.Destroy(r.RecordName) + FfiDestroyerString{}.Destroy(r.Guid) + FfiDestroyerString{}.Destroy(r.CloudChatId) + FfiDestroyerString{}.Destroy(r.Sender) + FfiDestroyerBool{}.Destroy(r.IsFromMe) + FfiDestroyerOptionalString{}.Destroy(r.Text) + FfiDestroyerOptionalString{}.Destroy(r.Subject) + FfiDestroyerString{}.Destroy(r.Service) + FfiDestroyerInt64{}.Destroy(r.TimestampMs) + FfiDestroyerBool{}.Destroy(r.Deleted) + FfiDestroyerOptionalUint32{}.Destroy(r.TapbackType) + FfiDestroyerOptionalString{}.Destroy(r.TapbackTargetGuid) + FfiDestroyerOptionalString{}.Destroy(r.TapbackEmoji) + FfiDestroyerSequenceString{}.Destroy(r.AttachmentGuids) + FfiDestroyerInt64{}.Destroy(r.DateReadMs) + FfiDestroyerInt64{}.Destroy(r.MsgType) + FfiDestroyerBool{}.Destroy(r.HasBody) +} + +type FfiConverterTypeWrappedCloudSyncMessage struct{} + +var FfiConverterTypeWrappedCloudSyncMessageINSTANCE = FfiConverterTypeWrappedCloudSyncMessage{} + +func (c FfiConverterTypeWrappedCloudSyncMessage) Lift(rb RustBufferI) WrappedCloudSyncMessage { + return LiftFromRustBuffer[WrappedCloudSyncMessage](c, rb) +} + +func (c FfiConverterTypeWrappedCloudSyncMessage) Read(reader io.Reader) WrappedCloudSyncMessage { + return WrappedCloudSyncMessage{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterInt64INSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalUint32INSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterSequenceStringINSTANCE.Read(reader), + FfiConverterInt64INSTANCE.Read(reader), + FfiConverterInt64INSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedCloudSyncMessage) Lower(value WrappedCloudSyncMessage) RustBuffer { + return LowerIntoRustBuffer[WrappedCloudSyncMessage](c, value) +} + +func (c FfiConverterTypeWrappedCloudSyncMessage) Write(writer io.Writer, value WrappedCloudSyncMessage) { + FfiConverterStringINSTANCE.Write(writer, value.RecordName) + FfiConverterStringINSTANCE.Write(writer, value.Guid) + FfiConverterStringINSTANCE.Write(writer, value.CloudChatId) + FfiConverterStringINSTANCE.Write(writer, value.Sender) + FfiConverterBoolINSTANCE.Write(writer, value.IsFromMe) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Text) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Subject) + FfiConverterStringINSTANCE.Write(writer, value.Service) + FfiConverterInt64INSTANCE.Write(writer, value.TimestampMs) + FfiConverterBoolINSTANCE.Write(writer, value.Deleted) + FfiConverterOptionalUint32INSTANCE.Write(writer, value.TapbackType) + FfiConverterOptionalStringINSTANCE.Write(writer, value.TapbackTargetGuid) + FfiConverterOptionalStringINSTANCE.Write(writer, value.TapbackEmoji) + FfiConverterSequenceStringINSTANCE.Write(writer, value.AttachmentGuids) + FfiConverterInt64INSTANCE.Write(writer, value.DateReadMs) + FfiConverterInt64INSTANCE.Write(writer, value.MsgType) + FfiConverterBoolINSTANCE.Write(writer, value.HasBody) +} + +type FfiDestroyerTypeWrappedCloudSyncMessage struct{} + +func (_ FfiDestroyerTypeWrappedCloudSyncMessage) Destroy(value WrappedCloudSyncMessage) { + value.Destroy() +} + +type WrappedCloudSyncMessagesPage struct { + ContinuationToken *string + Status int32 + Done bool + Messages []WrappedCloudSyncMessage +} + +func (r *WrappedCloudSyncMessagesPage) Destroy() { + FfiDestroyerOptionalString{}.Destroy(r.ContinuationToken) + FfiDestroyerInt32{}.Destroy(r.Status) + FfiDestroyerBool{}.Destroy(r.Done) + FfiDestroyerSequenceTypeWrappedCloudSyncMessage{}.Destroy(r.Messages) +} + +type FfiConverterTypeWrappedCloudSyncMessagesPage struct{} + +var FfiConverterTypeWrappedCloudSyncMessagesPageINSTANCE = FfiConverterTypeWrappedCloudSyncMessagesPage{} + +func (c FfiConverterTypeWrappedCloudSyncMessagesPage) Lift(rb RustBufferI) WrappedCloudSyncMessagesPage { + return LiftFromRustBuffer[WrappedCloudSyncMessagesPage](c, rb) +} + +func (c FfiConverterTypeWrappedCloudSyncMessagesPage) Read(reader io.Reader) WrappedCloudSyncMessagesPage { + return WrappedCloudSyncMessagesPage{ + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterInt32INSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterSequenceTypeWrappedCloudSyncMessageINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedCloudSyncMessagesPage) Lower(value WrappedCloudSyncMessagesPage) RustBuffer { + return LowerIntoRustBuffer[WrappedCloudSyncMessagesPage](c, value) +} + +func (c FfiConverterTypeWrappedCloudSyncMessagesPage) Write(writer io.Writer, value WrappedCloudSyncMessagesPage) { + FfiConverterOptionalStringINSTANCE.Write(writer, value.ContinuationToken) + FfiConverterInt32INSTANCE.Write(writer, value.Status) + FfiConverterBoolINSTANCE.Write(writer, value.Done) + FfiConverterSequenceTypeWrappedCloudSyncMessageINSTANCE.Write(writer, value.Messages) +} + +type FfiDestroyerTypeWrappedCloudSyncMessagesPage struct{} + +func (_ FfiDestroyerTypeWrappedCloudSyncMessagesPage) Destroy(value WrappedCloudSyncMessagesPage) { + value.Destroy() +} + +type WrappedConversation struct { + Participants []string + GroupName *string + SenderGuid *string + IsSms bool +} + +func (r *WrappedConversation) Destroy() { + FfiDestroyerSequenceString{}.Destroy(r.Participants) + FfiDestroyerOptionalString{}.Destroy(r.GroupName) + FfiDestroyerOptionalString{}.Destroy(r.SenderGuid) + FfiDestroyerBool{}.Destroy(r.IsSms) +} + +type FfiConverterTypeWrappedConversation struct{} + +var FfiConverterTypeWrappedConversationINSTANCE = FfiConverterTypeWrappedConversation{} + +func (c FfiConverterTypeWrappedConversation) Lift(rb RustBufferI) WrappedConversation { + return LiftFromRustBuffer[WrappedConversation](c, rb) +} + +func (c FfiConverterTypeWrappedConversation) Read(reader io.Reader) WrappedConversation { + return WrappedConversation{ + FfiConverterSequenceStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedConversation) Lower(value WrappedConversation) RustBuffer { + return LowerIntoRustBuffer[WrappedConversation](c, value) +} + +func (c FfiConverterTypeWrappedConversation) Write(writer io.Writer, value WrappedConversation) { + FfiConverterSequenceStringINSTANCE.Write(writer, value.Participants) + FfiConverterOptionalStringINSTANCE.Write(writer, value.GroupName) + FfiConverterOptionalStringINSTANCE.Write(writer, value.SenderGuid) + FfiConverterBoolINSTANCE.Write(writer, value.IsSms) +} + +type FfiDestroyerTypeWrappedConversation struct{} + +func (_ FfiDestroyerTypeWrappedConversation) Destroy(value WrappedConversation) { + value.Destroy() +} + +type WrappedLetMeInRequest struct { + DelegationUuid string + Pseud string + Requestor string + Nickname *string + Usage *string +} + +func (r *WrappedLetMeInRequest) Destroy() { + FfiDestroyerString{}.Destroy(r.DelegationUuid) + FfiDestroyerString{}.Destroy(r.Pseud) + FfiDestroyerString{}.Destroy(r.Requestor) + FfiDestroyerOptionalString{}.Destroy(r.Nickname) + FfiDestroyerOptionalString{}.Destroy(r.Usage) +} + +type FfiConverterTypeWrappedLetMeInRequest struct{} + +var FfiConverterTypeWrappedLetMeInRequestINSTANCE = FfiConverterTypeWrappedLetMeInRequest{} + +func (c FfiConverterTypeWrappedLetMeInRequest) Lift(rb RustBufferI) WrappedLetMeInRequest { + return LiftFromRustBuffer[WrappedLetMeInRequest](c, rb) +} + +func (c FfiConverterTypeWrappedLetMeInRequest) Read(reader io.Reader) WrappedLetMeInRequest { + return WrappedLetMeInRequest{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedLetMeInRequest) Lower(value WrappedLetMeInRequest) RustBuffer { + return LowerIntoRustBuffer[WrappedLetMeInRequest](c, value) +} + +func (c FfiConverterTypeWrappedLetMeInRequest) Write(writer io.Writer, value WrappedLetMeInRequest) { + FfiConverterStringINSTANCE.Write(writer, value.DelegationUuid) + FfiConverterStringINSTANCE.Write(writer, value.Pseud) + FfiConverterStringINSTANCE.Write(writer, value.Requestor) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Nickname) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Usage) +} + +type FfiDestroyerTypeWrappedLetMeInRequest struct{} + +func (_ FfiDestroyerTypeWrappedLetMeInRequest) Destroy(value WrappedLetMeInRequest) { + value.Destroy() +} + +type WrappedMessage struct { + Uuid string + Sender *string + Text *string + Subject *string + Participants []string + GroupName *string + TimestampMs uint64 + IsSms bool + IsTapback bool + TapbackType *uint32 + TapbackTargetUuid *string + TapbackTargetPart *uint64 + TapbackEmoji *string + TapbackRemove bool + IsEdit bool + EditTargetUuid *string + EditPart *uint64 + EditNewText *string + IsUnsend bool + UnsendTargetUuid *string + UnsendEditPart *uint64 + IsRename bool + NewChatName *string + IsParticipantChange bool + NewParticipants []string + Attachments []WrappedAttachment + ReplyGuid *string + ReplyPart *string + IsTyping bool + TypingAppBundleId *string + TypingAppIcon *[]byte + IsReadReceipt bool + IsDelivered bool + IsError bool + ErrorForUuid *string + ErrorStatus *uint64 + ErrorStatusStr *string + IsPeerCacheInvalidate bool + SendDelivered bool + SenderGuid *string + IsMoveToRecycleBin bool + IsPermanentDelete bool + IsRecoverChat bool + DeleteChatParticipants []string + DeleteChatGroupId *string + DeleteChatGuid *string + DeleteMessageUuids []string + IsStoredMessage bool + IsIconChange bool + GroupPhotoCleared bool + IconChangePhotoData *[]byte + Html *string + IsVoice bool + Effect *string + ScheduledMs *uint64 + IsSmsActivation *bool + IsSmsConfirmSent *bool + IsMarkUnread bool + IsMessageReadOnDevice bool + IsUnschedule bool + IsUpdateExtension bool + UpdateExtensionForUuid *string + IsUpdateProfileSharing bool + UpdateProfileSharingDismissed []string + UpdateProfileSharingAll []string + UpdateProfileSharingVersion *uint64 + IsUpdateProfile bool + UpdateProfileShareContacts *bool + IsNotifyAnyways bool + IsSetTranscriptBackground bool + TranscriptBackgroundRemove *bool + TranscriptBackgroundChatId *string + TranscriptBackgroundObjectId *string + TranscriptBackgroundUrl *string + TranscriptBackgroundFileSize *uint64 + StickerData *[]byte + StickerMime *string + IsShareProfile bool + ShareProfileRecordKey *string + ShareProfileDecryptionKey *[]byte + ShareProfileHasPoster bool + ShareProfileDisplayName *string + ShareProfileFirstName *string + ShareProfileLastName *string + ShareProfileAvatar *[]byte +} + +func (r *WrappedMessage) Destroy() { + FfiDestroyerString{}.Destroy(r.Uuid) + FfiDestroyerOptionalString{}.Destroy(r.Sender) + FfiDestroyerOptionalString{}.Destroy(r.Text) + FfiDestroyerOptionalString{}.Destroy(r.Subject) + FfiDestroyerSequenceString{}.Destroy(r.Participants) + FfiDestroyerOptionalString{}.Destroy(r.GroupName) + FfiDestroyerUint64{}.Destroy(r.TimestampMs) + FfiDestroyerBool{}.Destroy(r.IsSms) + FfiDestroyerBool{}.Destroy(r.IsTapback) + FfiDestroyerOptionalUint32{}.Destroy(r.TapbackType) + FfiDestroyerOptionalString{}.Destroy(r.TapbackTargetUuid) + FfiDestroyerOptionalUint64{}.Destroy(r.TapbackTargetPart) + FfiDestroyerOptionalString{}.Destroy(r.TapbackEmoji) + FfiDestroyerBool{}.Destroy(r.TapbackRemove) + FfiDestroyerBool{}.Destroy(r.IsEdit) + FfiDestroyerOptionalString{}.Destroy(r.EditTargetUuid) + FfiDestroyerOptionalUint64{}.Destroy(r.EditPart) + FfiDestroyerOptionalString{}.Destroy(r.EditNewText) + FfiDestroyerBool{}.Destroy(r.IsUnsend) + FfiDestroyerOptionalString{}.Destroy(r.UnsendTargetUuid) + FfiDestroyerOptionalUint64{}.Destroy(r.UnsendEditPart) + FfiDestroyerBool{}.Destroy(r.IsRename) + FfiDestroyerOptionalString{}.Destroy(r.NewChatName) + FfiDestroyerBool{}.Destroy(r.IsParticipantChange) + FfiDestroyerSequenceString{}.Destroy(r.NewParticipants) + FfiDestroyerSequenceTypeWrappedAttachment{}.Destroy(r.Attachments) + FfiDestroyerOptionalString{}.Destroy(r.ReplyGuid) + FfiDestroyerOptionalString{}.Destroy(r.ReplyPart) + FfiDestroyerBool{}.Destroy(r.IsTyping) + FfiDestroyerOptionalString{}.Destroy(r.TypingAppBundleId) + FfiDestroyerOptionalBytes{}.Destroy(r.TypingAppIcon) + FfiDestroyerBool{}.Destroy(r.IsReadReceipt) + FfiDestroyerBool{}.Destroy(r.IsDelivered) + FfiDestroyerBool{}.Destroy(r.IsError) + FfiDestroyerOptionalString{}.Destroy(r.ErrorForUuid) + FfiDestroyerOptionalUint64{}.Destroy(r.ErrorStatus) + FfiDestroyerOptionalString{}.Destroy(r.ErrorStatusStr) + FfiDestroyerBool{}.Destroy(r.IsPeerCacheInvalidate) + FfiDestroyerBool{}.Destroy(r.SendDelivered) + FfiDestroyerOptionalString{}.Destroy(r.SenderGuid) + FfiDestroyerBool{}.Destroy(r.IsMoveToRecycleBin) + FfiDestroyerBool{}.Destroy(r.IsPermanentDelete) + FfiDestroyerBool{}.Destroy(r.IsRecoverChat) + FfiDestroyerSequenceString{}.Destroy(r.DeleteChatParticipants) + FfiDestroyerOptionalString{}.Destroy(r.DeleteChatGroupId) + FfiDestroyerOptionalString{}.Destroy(r.DeleteChatGuid) + FfiDestroyerSequenceString{}.Destroy(r.DeleteMessageUuids) + FfiDestroyerBool{}.Destroy(r.IsStoredMessage) + FfiDestroyerBool{}.Destroy(r.IsIconChange) + FfiDestroyerBool{}.Destroy(r.GroupPhotoCleared) + FfiDestroyerOptionalBytes{}.Destroy(r.IconChangePhotoData) + FfiDestroyerOptionalString{}.Destroy(r.Html) + FfiDestroyerBool{}.Destroy(r.IsVoice) + FfiDestroyerOptionalString{}.Destroy(r.Effect) + FfiDestroyerOptionalUint64{}.Destroy(r.ScheduledMs) + FfiDestroyerOptionalBool{}.Destroy(r.IsSmsActivation) + FfiDestroyerOptionalBool{}.Destroy(r.IsSmsConfirmSent) + FfiDestroyerBool{}.Destroy(r.IsMarkUnread) + FfiDestroyerBool{}.Destroy(r.IsMessageReadOnDevice) + FfiDestroyerBool{}.Destroy(r.IsUnschedule) + FfiDestroyerBool{}.Destroy(r.IsUpdateExtension) + FfiDestroyerOptionalString{}.Destroy(r.UpdateExtensionForUuid) + FfiDestroyerBool{}.Destroy(r.IsUpdateProfileSharing) + FfiDestroyerSequenceString{}.Destroy(r.UpdateProfileSharingDismissed) + FfiDestroyerSequenceString{}.Destroy(r.UpdateProfileSharingAll) + FfiDestroyerOptionalUint64{}.Destroy(r.UpdateProfileSharingVersion) + FfiDestroyerBool{}.Destroy(r.IsUpdateProfile) + FfiDestroyerOptionalBool{}.Destroy(r.UpdateProfileShareContacts) + FfiDestroyerBool{}.Destroy(r.IsNotifyAnyways) + FfiDestroyerBool{}.Destroy(r.IsSetTranscriptBackground) + FfiDestroyerOptionalBool{}.Destroy(r.TranscriptBackgroundRemove) + FfiDestroyerOptionalString{}.Destroy(r.TranscriptBackgroundChatId) + FfiDestroyerOptionalString{}.Destroy(r.TranscriptBackgroundObjectId) + FfiDestroyerOptionalString{}.Destroy(r.TranscriptBackgroundUrl) + FfiDestroyerOptionalUint64{}.Destroy(r.TranscriptBackgroundFileSize) + FfiDestroyerOptionalBytes{}.Destroy(r.StickerData) + FfiDestroyerOptionalString{}.Destroy(r.StickerMime) + FfiDestroyerBool{}.Destroy(r.IsShareProfile) + FfiDestroyerOptionalString{}.Destroy(r.ShareProfileRecordKey) + FfiDestroyerOptionalBytes{}.Destroy(r.ShareProfileDecryptionKey) + FfiDestroyerBool{}.Destroy(r.ShareProfileHasPoster) + FfiDestroyerOptionalString{}.Destroy(r.ShareProfileDisplayName) + FfiDestroyerOptionalString{}.Destroy(r.ShareProfileFirstName) + FfiDestroyerOptionalString{}.Destroy(r.ShareProfileLastName) + FfiDestroyerOptionalBytes{}.Destroy(r.ShareProfileAvatar) +} + +type FfiConverterTypeWrappedMessage struct{} + +var FfiConverterTypeWrappedMessageINSTANCE = FfiConverterTypeWrappedMessage{} + +func (c FfiConverterTypeWrappedMessage) Lift(rb RustBufferI) WrappedMessage { + return LiftFromRustBuffer[WrappedMessage](c, rb) +} + +func (c FfiConverterTypeWrappedMessage) Read(reader io.Reader) WrappedMessage { + return WrappedMessage{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterSequenceStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalUint32INSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalUint64INSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalUint64INSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalUint64INSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterSequenceStringINSTANCE.Read(reader), + FfiConverterSequenceTypeWrappedAttachmentINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalUint64INSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterSequenceStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterSequenceStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalUint64INSTANCE.Read(reader), + FfiConverterOptionalBoolINSTANCE.Read(reader), + FfiConverterOptionalBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterSequenceStringINSTANCE.Read(reader), + FfiConverterSequenceStringINSTANCE.Read(reader), + FfiConverterOptionalUint64INSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalUint64INSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + FfiConverterBoolINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedMessage) Lower(value WrappedMessage) RustBuffer { + return LowerIntoRustBuffer[WrappedMessage](c, value) +} + +func (c FfiConverterTypeWrappedMessage) Write(writer io.Writer, value WrappedMessage) { + FfiConverterStringINSTANCE.Write(writer, value.Uuid) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Sender) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Text) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Subject) + FfiConverterSequenceStringINSTANCE.Write(writer, value.Participants) + FfiConverterOptionalStringINSTANCE.Write(writer, value.GroupName) + FfiConverterUint64INSTANCE.Write(writer, value.TimestampMs) + FfiConverterBoolINSTANCE.Write(writer, value.IsSms) + FfiConverterBoolINSTANCE.Write(writer, value.IsTapback) + FfiConverterOptionalUint32INSTANCE.Write(writer, value.TapbackType) + FfiConverterOptionalStringINSTANCE.Write(writer, value.TapbackTargetUuid) + FfiConverterOptionalUint64INSTANCE.Write(writer, value.TapbackTargetPart) + FfiConverterOptionalStringINSTANCE.Write(writer, value.TapbackEmoji) + FfiConverterBoolINSTANCE.Write(writer, value.TapbackRemove) + FfiConverterBoolINSTANCE.Write(writer, value.IsEdit) + FfiConverterOptionalStringINSTANCE.Write(writer, value.EditTargetUuid) + FfiConverterOptionalUint64INSTANCE.Write(writer, value.EditPart) + FfiConverterOptionalStringINSTANCE.Write(writer, value.EditNewText) + FfiConverterBoolINSTANCE.Write(writer, value.IsUnsend) + FfiConverterOptionalStringINSTANCE.Write(writer, value.UnsendTargetUuid) + FfiConverterOptionalUint64INSTANCE.Write(writer, value.UnsendEditPart) + FfiConverterBoolINSTANCE.Write(writer, value.IsRename) + FfiConverterOptionalStringINSTANCE.Write(writer, value.NewChatName) + FfiConverterBoolINSTANCE.Write(writer, value.IsParticipantChange) + FfiConverterSequenceStringINSTANCE.Write(writer, value.NewParticipants) + FfiConverterSequenceTypeWrappedAttachmentINSTANCE.Write(writer, value.Attachments) + FfiConverterOptionalStringINSTANCE.Write(writer, value.ReplyGuid) + FfiConverterOptionalStringINSTANCE.Write(writer, value.ReplyPart) + FfiConverterBoolINSTANCE.Write(writer, value.IsTyping) + FfiConverterOptionalStringINSTANCE.Write(writer, value.TypingAppBundleId) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.TypingAppIcon) + FfiConverterBoolINSTANCE.Write(writer, value.IsReadReceipt) + FfiConverterBoolINSTANCE.Write(writer, value.IsDelivered) + FfiConverterBoolINSTANCE.Write(writer, value.IsError) + FfiConverterOptionalStringINSTANCE.Write(writer, value.ErrorForUuid) + FfiConverterOptionalUint64INSTANCE.Write(writer, value.ErrorStatus) + FfiConverterOptionalStringINSTANCE.Write(writer, value.ErrorStatusStr) + FfiConverterBoolINSTANCE.Write(writer, value.IsPeerCacheInvalidate) + FfiConverterBoolINSTANCE.Write(writer, value.SendDelivered) + FfiConverterOptionalStringINSTANCE.Write(writer, value.SenderGuid) + FfiConverterBoolINSTANCE.Write(writer, value.IsMoveToRecycleBin) + FfiConverterBoolINSTANCE.Write(writer, value.IsPermanentDelete) + FfiConverterBoolINSTANCE.Write(writer, value.IsRecoverChat) + FfiConverterSequenceStringINSTANCE.Write(writer, value.DeleteChatParticipants) + FfiConverterOptionalStringINSTANCE.Write(writer, value.DeleteChatGroupId) + FfiConverterOptionalStringINSTANCE.Write(writer, value.DeleteChatGuid) + FfiConverterSequenceStringINSTANCE.Write(writer, value.DeleteMessageUuids) + FfiConverterBoolINSTANCE.Write(writer, value.IsStoredMessage) + FfiConverterBoolINSTANCE.Write(writer, value.IsIconChange) + FfiConverterBoolINSTANCE.Write(writer, value.GroupPhotoCleared) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.IconChangePhotoData) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Html) + FfiConverterBoolINSTANCE.Write(writer, value.IsVoice) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Effect) + FfiConverterOptionalUint64INSTANCE.Write(writer, value.ScheduledMs) + FfiConverterOptionalBoolINSTANCE.Write(writer, value.IsSmsActivation) + FfiConverterOptionalBoolINSTANCE.Write(writer, value.IsSmsConfirmSent) + FfiConverterBoolINSTANCE.Write(writer, value.IsMarkUnread) + FfiConverterBoolINSTANCE.Write(writer, value.IsMessageReadOnDevice) + FfiConverterBoolINSTANCE.Write(writer, value.IsUnschedule) + FfiConverterBoolINSTANCE.Write(writer, value.IsUpdateExtension) + FfiConverterOptionalStringINSTANCE.Write(writer, value.UpdateExtensionForUuid) + FfiConverterBoolINSTANCE.Write(writer, value.IsUpdateProfileSharing) + FfiConverterSequenceStringINSTANCE.Write(writer, value.UpdateProfileSharingDismissed) + FfiConverterSequenceStringINSTANCE.Write(writer, value.UpdateProfileSharingAll) + FfiConverterOptionalUint64INSTANCE.Write(writer, value.UpdateProfileSharingVersion) + FfiConverterBoolINSTANCE.Write(writer, value.IsUpdateProfile) + FfiConverterOptionalBoolINSTANCE.Write(writer, value.UpdateProfileShareContacts) + FfiConverterBoolINSTANCE.Write(writer, value.IsNotifyAnyways) + FfiConverterBoolINSTANCE.Write(writer, value.IsSetTranscriptBackground) + FfiConverterOptionalBoolINSTANCE.Write(writer, value.TranscriptBackgroundRemove) + FfiConverterOptionalStringINSTANCE.Write(writer, value.TranscriptBackgroundChatId) + FfiConverterOptionalStringINSTANCE.Write(writer, value.TranscriptBackgroundObjectId) + FfiConverterOptionalStringINSTANCE.Write(writer, value.TranscriptBackgroundUrl) + FfiConverterOptionalUint64INSTANCE.Write(writer, value.TranscriptBackgroundFileSize) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.StickerData) + FfiConverterOptionalStringINSTANCE.Write(writer, value.StickerMime) + FfiConverterBoolINSTANCE.Write(writer, value.IsShareProfile) + FfiConverterOptionalStringINSTANCE.Write(writer, value.ShareProfileRecordKey) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.ShareProfileDecryptionKey) + FfiConverterBoolINSTANCE.Write(writer, value.ShareProfileHasPoster) + FfiConverterOptionalStringINSTANCE.Write(writer, value.ShareProfileDisplayName) + FfiConverterOptionalStringINSTANCE.Write(writer, value.ShareProfileFirstName) + FfiConverterOptionalStringINSTANCE.Write(writer, value.ShareProfileLastName) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.ShareProfileAvatar) +} + +type FfiDestroyerTypeWrappedMessage struct{} + +func (_ FfiDestroyerTypeWrappedMessage) Destroy(value WrappedMessage) { + value.Destroy() +} + +type WrappedPasswordEntryRef struct { + Id string + Group *string +} + +func (r *WrappedPasswordEntryRef) Destroy() { + FfiDestroyerString{}.Destroy(r.Id) + FfiDestroyerOptionalString{}.Destroy(r.Group) +} + +type FfiConverterTypeWrappedPasswordEntryRef struct{} + +var FfiConverterTypeWrappedPasswordEntryRefINSTANCE = FfiConverterTypeWrappedPasswordEntryRef{} + +func (c FfiConverterTypeWrappedPasswordEntryRef) Lift(rb RustBufferI) WrappedPasswordEntryRef { + return LiftFromRustBuffer[WrappedPasswordEntryRef](c, rb) +} + +func (c FfiConverterTypeWrappedPasswordEntryRef) Read(reader io.Reader) WrappedPasswordEntryRef { + return WrappedPasswordEntryRef{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterOptionalStringINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedPasswordEntryRef) Lower(value WrappedPasswordEntryRef) RustBuffer { + return LowerIntoRustBuffer[WrappedPasswordEntryRef](c, value) +} + +func (c FfiConverterTypeWrappedPasswordEntryRef) Write(writer io.Writer, value WrappedPasswordEntryRef) { + FfiConverterStringINSTANCE.Write(writer, value.Id) + FfiConverterOptionalStringINSTANCE.Write(writer, value.Group) +} + +type FfiDestroyerTypeWrappedPasswordEntryRef struct{} + +func (_ FfiDestroyerTypeWrappedPasswordEntryRef) Destroy(value WrappedPasswordEntryRef) { + value.Destroy() +} + +type WrappedPasswordSiteCounts struct { + WebsiteMetaCount uint64 + PasswordCount uint64 + PasswordMetaCount uint64 + PasskeyCount uint64 +} + +func (r *WrappedPasswordSiteCounts) Destroy() { + FfiDestroyerUint64{}.Destroy(r.WebsiteMetaCount) + FfiDestroyerUint64{}.Destroy(r.PasswordCount) + FfiDestroyerUint64{}.Destroy(r.PasswordMetaCount) + FfiDestroyerUint64{}.Destroy(r.PasskeyCount) +} + +type FfiConverterTypeWrappedPasswordSiteCounts struct{} + +var FfiConverterTypeWrappedPasswordSiteCountsINSTANCE = FfiConverterTypeWrappedPasswordSiteCounts{} + +func (c FfiConverterTypeWrappedPasswordSiteCounts) Lift(rb RustBufferI) WrappedPasswordSiteCounts { + return LiftFromRustBuffer[WrappedPasswordSiteCounts](c, rb) +} + +func (c FfiConverterTypeWrappedPasswordSiteCounts) Read(reader io.Reader) WrappedPasswordSiteCounts { + return WrappedPasswordSiteCounts{ + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedPasswordSiteCounts) Lower(value WrappedPasswordSiteCounts) RustBuffer { + return LowerIntoRustBuffer[WrappedPasswordSiteCounts](c, value) +} + +func (c FfiConverterTypeWrappedPasswordSiteCounts) Write(writer io.Writer, value WrappedPasswordSiteCounts) { + FfiConverterUint64INSTANCE.Write(writer, value.WebsiteMetaCount) + FfiConverterUint64INSTANCE.Write(writer, value.PasswordCount) + FfiConverterUint64INSTANCE.Write(writer, value.PasswordMetaCount) + FfiConverterUint64INSTANCE.Write(writer, value.PasskeyCount) +} + +type FfiDestroyerTypeWrappedPasswordSiteCounts struct{} + +func (_ FfiDestroyerTypeWrappedPasswordSiteCounts) Destroy(value WrappedPasswordSiteCounts) { + value.Destroy() +} + +type WrappedProfileRecord struct { + DisplayName string + FirstName string + LastName string + Avatar *[]byte +} + +func (r *WrappedProfileRecord) Destroy() { + FfiDestroyerString{}.Destroy(r.DisplayName) + FfiDestroyerString{}.Destroy(r.FirstName) + FfiDestroyerString{}.Destroy(r.LastName) + FfiDestroyerOptionalBytes{}.Destroy(r.Avatar) +} + +type FfiConverterTypeWrappedProfileRecord struct{} + +var FfiConverterTypeWrappedProfileRecordINSTANCE = FfiConverterTypeWrappedProfileRecord{} + +func (c FfiConverterTypeWrappedProfileRecord) Lift(rb RustBufferI) WrappedProfileRecord { + return LiftFromRustBuffer[WrappedProfileRecord](c, rb) +} + +func (c FfiConverterTypeWrappedProfileRecord) Read(reader io.Reader) WrappedProfileRecord { + return WrappedProfileRecord{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedProfileRecord) Lower(value WrappedProfileRecord) RustBuffer { + return LowerIntoRustBuffer[WrappedProfileRecord](c, value) +} + +func (c FfiConverterTypeWrappedProfileRecord) Write(writer io.Writer, value WrappedProfileRecord) { + FfiConverterStringINSTANCE.Write(writer, value.DisplayName) + FfiConverterStringINSTANCE.Write(writer, value.FirstName) + FfiConverterStringINSTANCE.Write(writer, value.LastName) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.Avatar) +} + +type FfiDestroyerTypeWrappedProfileRecord struct{} + +func (_ FfiDestroyerTypeWrappedProfileRecord) Destroy(value WrappedProfileRecord) { + value.Destroy() +} + +type WrappedShareProfileData struct { + CloudKitRecordKey string + CloudKitDecryptionRecordKey []byte + LowResWallpaperTag *[]byte + WallpaperTag *[]byte + MessageTag *[]byte +} + +func (r *WrappedShareProfileData) Destroy() { + FfiDestroyerString{}.Destroy(r.CloudKitRecordKey) + FfiDestroyerBytes{}.Destroy(r.CloudKitDecryptionRecordKey) + FfiDestroyerOptionalBytes{}.Destroy(r.LowResWallpaperTag) + FfiDestroyerOptionalBytes{}.Destroy(r.WallpaperTag) + FfiDestroyerOptionalBytes{}.Destroy(r.MessageTag) +} + +type FfiConverterTypeWrappedShareProfileData struct{} + +var FfiConverterTypeWrappedShareProfileDataINSTANCE = FfiConverterTypeWrappedShareProfileData{} + +func (c FfiConverterTypeWrappedShareProfileData) Lift(rb RustBufferI) WrappedShareProfileData { + return LiftFromRustBuffer[WrappedShareProfileData](c, rb) +} + +func (c FfiConverterTypeWrappedShareProfileData) Read(reader io.Reader) WrappedShareProfileData { + return WrappedShareProfileData{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterBytesINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + FfiConverterOptionalBytesINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedShareProfileData) Lower(value WrappedShareProfileData) RustBuffer { + return LowerIntoRustBuffer[WrappedShareProfileData](c, value) +} + +func (c FfiConverterTypeWrappedShareProfileData) Write(writer io.Writer, value WrappedShareProfileData) { + FfiConverterStringINSTANCE.Write(writer, value.CloudKitRecordKey) + FfiConverterBytesINSTANCE.Write(writer, value.CloudKitDecryptionRecordKey) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.LowResWallpaperTag) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.WallpaperTag) + FfiConverterOptionalBytesINSTANCE.Write(writer, value.MessageTag) +} + +type FfiDestroyerTypeWrappedShareProfileData struct{} + +func (_ FfiDestroyerTypeWrappedShareProfileData) Destroy(value WrappedShareProfileData) { + value.Destroy() +} + +type WrappedStatusKitInviteHandle struct { + Handle string + AllowedModes []string +} + +func (r *WrappedStatusKitInviteHandle) Destroy() { + FfiDestroyerString{}.Destroy(r.Handle) + FfiDestroyerSequenceString{}.Destroy(r.AllowedModes) +} + +type FfiConverterTypeWrappedStatusKitInviteHandle struct{} + +var FfiConverterTypeWrappedStatusKitInviteHandleINSTANCE = FfiConverterTypeWrappedStatusKitInviteHandle{} + +func (c FfiConverterTypeWrappedStatusKitInviteHandle) Lift(rb RustBufferI) WrappedStatusKitInviteHandle { + return LiftFromRustBuffer[WrappedStatusKitInviteHandle](c, rb) +} + +func (c FfiConverterTypeWrappedStatusKitInviteHandle) Read(reader io.Reader) WrappedStatusKitInviteHandle { + return WrappedStatusKitInviteHandle{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterSequenceStringINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedStatusKitInviteHandle) Lower(value WrappedStatusKitInviteHandle) RustBuffer { + return LowerIntoRustBuffer[WrappedStatusKitInviteHandle](c, value) +} + +func (c FfiConverterTypeWrappedStatusKitInviteHandle) Write(writer io.Writer, value WrappedStatusKitInviteHandle) { + FfiConverterStringINSTANCE.Write(writer, value.Handle) + FfiConverterSequenceStringINSTANCE.Write(writer, value.AllowedModes) +} + +type FfiDestroyerTypeWrappedStatusKitInviteHandle struct{} + +func (_ FfiDestroyerTypeWrappedStatusKitInviteHandle) Destroy(value WrappedStatusKitInviteHandle) { + value.Destroy() +} + +type WrappedStickerExtension struct { + MsgWidth float64 + Rotation float64 + Sai uint64 + Scale float64 + Sli uint64 + NormalizedX float64 + NormalizedY float64 + Version uint64 + Hash string + Safi uint64 + EffectType int64 + StickerId string +} + +func (r *WrappedStickerExtension) Destroy() { + FfiDestroyerFloat64{}.Destroy(r.MsgWidth) + FfiDestroyerFloat64{}.Destroy(r.Rotation) + FfiDestroyerUint64{}.Destroy(r.Sai) + FfiDestroyerFloat64{}.Destroy(r.Scale) + FfiDestroyerUint64{}.Destroy(r.Sli) + FfiDestroyerFloat64{}.Destroy(r.NormalizedX) + FfiDestroyerFloat64{}.Destroy(r.NormalizedY) + FfiDestroyerUint64{}.Destroy(r.Version) + FfiDestroyerString{}.Destroy(r.Hash) + FfiDestroyerUint64{}.Destroy(r.Safi) + FfiDestroyerInt64{}.Destroy(r.EffectType) + FfiDestroyerString{}.Destroy(r.StickerId) +} + +type FfiConverterTypeWrappedStickerExtension struct{} + +var FfiConverterTypeWrappedStickerExtensionINSTANCE = FfiConverterTypeWrappedStickerExtension{} + +func (c FfiConverterTypeWrappedStickerExtension) Lift(rb RustBufferI) WrappedStickerExtension { + return LiftFromRustBuffer[WrappedStickerExtension](c, rb) +} + +func (c FfiConverterTypeWrappedStickerExtension) Read(reader io.Reader) WrappedStickerExtension { + return WrappedStickerExtension{ + FfiConverterFloat64INSTANCE.Read(reader), + FfiConverterFloat64INSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterFloat64INSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterFloat64INSTANCE.Read(reader), + FfiConverterFloat64INSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + FfiConverterUint64INSTANCE.Read(reader), + FfiConverterInt64INSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + } +} + +func (c FfiConverterTypeWrappedStickerExtension) Lower(value WrappedStickerExtension) RustBuffer { + return LowerIntoRustBuffer[WrappedStickerExtension](c, value) +} + +func (c FfiConverterTypeWrappedStickerExtension) Write(writer io.Writer, value WrappedStickerExtension) { + FfiConverterFloat64INSTANCE.Write(writer, value.MsgWidth) + FfiConverterFloat64INSTANCE.Write(writer, value.Rotation) + FfiConverterUint64INSTANCE.Write(writer, value.Sai) + FfiConverterFloat64INSTANCE.Write(writer, value.Scale) + FfiConverterUint64INSTANCE.Write(writer, value.Sli) + FfiConverterFloat64INSTANCE.Write(writer, value.NormalizedX) + FfiConverterFloat64INSTANCE.Write(writer, value.NormalizedY) + FfiConverterUint64INSTANCE.Write(writer, value.Version) + FfiConverterStringINSTANCE.Write(writer, value.Hash) + FfiConverterUint64INSTANCE.Write(writer, value.Safi) + FfiConverterInt64INSTANCE.Write(writer, value.EffectType) + FfiConverterStringINSTANCE.Write(writer, value.StickerId) +} + +type FfiDestroyerTypeWrappedStickerExtension struct{} + +func (_ FfiDestroyerTypeWrappedStickerExtension) Destroy(value WrappedStickerExtension) { + value.Destroy() +} + +type WrappedError struct { + err error +} + +func (err WrappedError) Error() string { + return fmt.Sprintf("WrappedError: %s", err.err.Error()) +} + +func (err WrappedError) Unwrap() error { + return err.err +} + +// Err* are used for checking error type with `errors.Is` +var ErrWrappedErrorGenericError = fmt.Errorf("WrappedErrorGenericError") + +// Variant structs +type WrappedErrorGenericError struct { + Msg string +} + +func NewWrappedErrorGenericError( + msg string, +) *WrappedError { + return &WrappedError{ + err: &WrappedErrorGenericError{ + Msg: msg, + }, + } +} + +func (err WrappedErrorGenericError) Error() string { + return fmt.Sprint("GenericError", + ": ", + + "Msg=", + err.Msg, + ) +} + +func (self WrappedErrorGenericError) Is(target error) bool { + return target == ErrWrappedErrorGenericError +} + +type FfiConverterTypeWrappedError struct{} + +var FfiConverterTypeWrappedErrorINSTANCE = FfiConverterTypeWrappedError{} + +func (c FfiConverterTypeWrappedError) Lift(eb RustBufferI) error { + return LiftFromRustBuffer[*WrappedError](c, eb) +} + +func (c FfiConverterTypeWrappedError) Lower(value *WrappedError) RustBuffer { + return LowerIntoRustBuffer[*WrappedError](c, value) +} + +func (c FfiConverterTypeWrappedError) Read(reader io.Reader) *WrappedError { + errorID := readUint32(reader) + + switch errorID { + case 1: + return &WrappedError{&WrappedErrorGenericError{ + Msg: FfiConverterStringINSTANCE.Read(reader), + }} + default: + panic(fmt.Sprintf("Unknown error code %d in FfiConverterTypeWrappedError.Read()", errorID)) + } +} + +func (c FfiConverterTypeWrappedError) Write(writer io.Writer, value *WrappedError) { + switch variantValue := value.err.(type) { + case *WrappedErrorGenericError: + writeInt32(writer, 1) + FfiConverterStringINSTANCE.Write(writer, variantValue.Msg) + default: + _ = variantValue + panic(fmt.Sprintf("invalid error value `%v` in FfiConverterTypeWrappedError.Write", value)) + } +} + +type uniffiCallbackResult C.int32_t + +const ( + uniffiIdxCallbackFree uniffiCallbackResult = 0 + uniffiCallbackResultSuccess uniffiCallbackResult = 0 + uniffiCallbackResultError uniffiCallbackResult = 1 + uniffiCallbackUnexpectedResultError uniffiCallbackResult = 2 + uniffiCallbackCancelled uniffiCallbackResult = 3 +) + +type concurrentHandleMap[T any] struct { + handles map[uint64]T + currentHandle uint64 + lock sync.RWMutex +} + +func newConcurrentHandleMap[T any]() *concurrentHandleMap[T] { + return &concurrentHandleMap[T]{ + handles: map[uint64]T{}, + } +} + +func (cm *concurrentHandleMap[T]) insert(obj T) uint64 { + cm.lock.Lock() + defer cm.lock.Unlock() + + cm.currentHandle = cm.currentHandle + 1 + cm.handles[cm.currentHandle] = obj + return cm.currentHandle +} + +func (cm *concurrentHandleMap[T]) remove(handle uint64) { + cm.lock.Lock() + defer cm.lock.Unlock() + + delete(cm.handles, handle) +} + +func (cm *concurrentHandleMap[T]) tryGet(handle uint64) (T, bool) { + cm.lock.RLock() + defer cm.lock.RUnlock() + + val, ok := cm.handles[handle] + return val, ok +} + +type FfiConverterCallbackInterface[CallbackInterface any] struct { + handleMap *concurrentHandleMap[CallbackInterface] +} + +func (c *FfiConverterCallbackInterface[CallbackInterface]) drop(handle uint64) RustBuffer { + c.handleMap.remove(handle) + return RustBuffer{} +} + +func (c *FfiConverterCallbackInterface[CallbackInterface]) Lift(handle uint64) CallbackInterface { + val, ok := c.handleMap.tryGet(handle) + if !ok { + panic(fmt.Errorf("no callback in handle map: %d", handle)) + } + return val +} + +func (c *FfiConverterCallbackInterface[CallbackInterface]) Read(reader io.Reader) CallbackInterface { + return c.Lift(readUint64(reader)) +} + +func (c *FfiConverterCallbackInterface[CallbackInterface]) Lower(value CallbackInterface) C.uint64_t { + return C.uint64_t(c.handleMap.insert(value)) +} + +func (c *FfiConverterCallbackInterface[CallbackInterface]) Write(writer io.Writer, value CallbackInterface) { + writeUint64(writer, uint64(c.Lower(value))) +} + +type MessageCallback interface { + OnMessage(msg WrappedMessage) +} + +// foreignCallbackCallbackInterfaceMessageCallback cannot be callable be a compiled function at a same time +type foreignCallbackCallbackInterfaceMessageCallback struct{} + +//export rustpushgo_cgo_MessageCallback +func rustpushgo_cgo_MessageCallback(handle C.uint64_t, method C.int32_t, argsPtr *C.uint8_t, argsLen C.int32_t, outBuf *C.RustBuffer) C.int32_t { + cb := FfiConverterCallbackInterfaceMessageCallbackINSTANCE.Lift(uint64(handle)) + switch method { + case 0: + // 0 means Rust is done with the callback, and the callback + // can be dropped by the foreign language. + *outBuf = rustBufferToC(FfiConverterCallbackInterfaceMessageCallbackINSTANCE.drop(uint64(handle))) + // See docs of ForeignCallback in `uniffi/src/ffi/foreigncallbacks.rs` + return C.int32_t(uniffiIdxCallbackFree) + + case 1: + var result uniffiCallbackResult + args := unsafe.Slice((*byte)(argsPtr), argsLen) + result = foreignCallbackCallbackInterfaceMessageCallback{}.InvokeOnMessage(cb, args, outBuf) + return C.int32_t(result) + + default: + // This should never happen, because an out of bounds method index won't + // ever be used. Once we can catch errors, we should return an InternalException. + // https://github.com/mozilla/uniffi-rs/issues/351 + return C.int32_t(uniffiCallbackUnexpectedResultError) + } +} + +func (foreignCallbackCallbackInterfaceMessageCallback) InvokeOnMessage(callback MessageCallback, args []byte, outBuf *C.RustBuffer) uniffiCallbackResult { + reader := bytes.NewReader(args) + callback.OnMessage(FfiConverterTypeWrappedMessageINSTANCE.Read(reader)) + + return uniffiCallbackResultSuccess +} + +type FfiConverterCallbackInterfaceMessageCallback struct { + FfiConverterCallbackInterface[MessageCallback] +} + +var FfiConverterCallbackInterfaceMessageCallbackINSTANCE = &FfiConverterCallbackInterfaceMessageCallback{ + FfiConverterCallbackInterface: FfiConverterCallbackInterface[MessageCallback]{ + handleMap: newConcurrentHandleMap[MessageCallback](), + }, +} + +// This is a static function because only 1 instance is supported for registering +func (c *FfiConverterCallbackInterfaceMessageCallback) register() { + rustCall(func(status *C.RustCallStatus) int32 { + C.uniffi_rustpushgo_fn_init_callback_messagecallback(C.ForeignCallback(C.rustpushgo_cgo_MessageCallback), status) + return 0 + }) +} + +type FfiDestroyerCallbackInterfaceMessageCallback struct{} + +func (FfiDestroyerCallbackInterfaceMessageCallback) Destroy(value MessageCallback) { +} + +type StatusCallback interface { + OnStatusUpdate(user string, mode *string, available bool) + + OnKeysReceived() +} + +// foreignCallbackCallbackInterfaceStatusCallback cannot be callable be a compiled function at a same time +type foreignCallbackCallbackInterfaceStatusCallback struct{} + +//export rustpushgo_cgo_StatusCallback +func rustpushgo_cgo_StatusCallback(handle C.uint64_t, method C.int32_t, argsPtr *C.uint8_t, argsLen C.int32_t, outBuf *C.RustBuffer) C.int32_t { + cb := FfiConverterCallbackInterfaceStatusCallbackINSTANCE.Lift(uint64(handle)) + switch method { + case 0: + // 0 means Rust is done with the callback, and the callback + // can be dropped by the foreign language. + *outBuf = rustBufferToC(FfiConverterCallbackInterfaceStatusCallbackINSTANCE.drop(uint64(handle))) + // See docs of ForeignCallback in `uniffi/src/ffi/foreigncallbacks.rs` + return C.int32_t(uniffiIdxCallbackFree) + + case 1: + var result uniffiCallbackResult + args := unsafe.Slice((*byte)(argsPtr), argsLen) + result = foreignCallbackCallbackInterfaceStatusCallback{}.InvokeOnStatusUpdate(cb, args, outBuf) + return C.int32_t(result) + case 2: + var result uniffiCallbackResult + args := unsafe.Slice((*byte)(argsPtr), argsLen) + result = foreignCallbackCallbackInterfaceStatusCallback{}.InvokeOnKeysReceived(cb, args, outBuf) + return C.int32_t(result) + + default: + // This should never happen, because an out of bounds method index won't + // ever be used. Once we can catch errors, we should return an InternalException. + // https://github.com/mozilla/uniffi-rs/issues/351 + return C.int32_t(uniffiCallbackUnexpectedResultError) + } +} + +func (foreignCallbackCallbackInterfaceStatusCallback) InvokeOnStatusUpdate(callback StatusCallback, args []byte, outBuf *C.RustBuffer) uniffiCallbackResult { + reader := bytes.NewReader(args) + callback.OnStatusUpdate(FfiConverterStringINSTANCE.Read(reader), FfiConverterOptionalStringINSTANCE.Read(reader), FfiConverterBoolINSTANCE.Read(reader)) + + return uniffiCallbackResultSuccess +} +func (foreignCallbackCallbackInterfaceStatusCallback) InvokeOnKeysReceived(callback StatusCallback, args []byte, outBuf *C.RustBuffer) uniffiCallbackResult { + callback.OnKeysReceived() + + return uniffiCallbackResultSuccess +} + +type FfiConverterCallbackInterfaceStatusCallback struct { + FfiConverterCallbackInterface[StatusCallback] +} + +var FfiConverterCallbackInterfaceStatusCallbackINSTANCE = &FfiConverterCallbackInterfaceStatusCallback{ + FfiConverterCallbackInterface: FfiConverterCallbackInterface[StatusCallback]{ + handleMap: newConcurrentHandleMap[StatusCallback](), + }, +} + +// This is a static function because only 1 instance is supported for registering +func (c *FfiConverterCallbackInterfaceStatusCallback) register() { + rustCall(func(status *C.RustCallStatus) int32 { + C.uniffi_rustpushgo_fn_init_callback_statuscallback(C.ForeignCallback(C.rustpushgo_cgo_StatusCallback), status) + return 0 + }) +} + +type FfiDestroyerCallbackInterfaceStatusCallback struct{} + +func (FfiDestroyerCallbackInterfaceStatusCallback) Destroy(value StatusCallback) { +} + +type UpdateUsersCallback interface { + UpdateUsers(users *WrappedIdsUsers) +} + +// foreignCallbackCallbackInterfaceUpdateUsersCallback cannot be callable be a compiled function at a same time +type foreignCallbackCallbackInterfaceUpdateUsersCallback struct{} + +//export rustpushgo_cgo_UpdateUsersCallback +func rustpushgo_cgo_UpdateUsersCallback(handle C.uint64_t, method C.int32_t, argsPtr *C.uint8_t, argsLen C.int32_t, outBuf *C.RustBuffer) C.int32_t { + cb := FfiConverterCallbackInterfaceUpdateUsersCallbackINSTANCE.Lift(uint64(handle)) + switch method { + case 0: + // 0 means Rust is done with the callback, and the callback + // can be dropped by the foreign language. + *outBuf = rustBufferToC(FfiConverterCallbackInterfaceUpdateUsersCallbackINSTANCE.drop(uint64(handle))) + // See docs of ForeignCallback in `uniffi/src/ffi/foreigncallbacks.rs` + return C.int32_t(uniffiIdxCallbackFree) + + case 1: + var result uniffiCallbackResult + args := unsafe.Slice((*byte)(argsPtr), argsLen) + result = foreignCallbackCallbackInterfaceUpdateUsersCallback{}.InvokeUpdateUsers(cb, args, outBuf) + return C.int32_t(result) + + default: + // This should never happen, because an out of bounds method index won't + // ever be used. Once we can catch errors, we should return an InternalException. + // https://github.com/mozilla/uniffi-rs/issues/351 + return C.int32_t(uniffiCallbackUnexpectedResultError) + } +} + +func (foreignCallbackCallbackInterfaceUpdateUsersCallback) InvokeUpdateUsers(callback UpdateUsersCallback, args []byte, outBuf *C.RustBuffer) uniffiCallbackResult { + reader := bytes.NewReader(args) + callback.UpdateUsers(FfiConverterWrappedIDSUsersINSTANCE.Read(reader)) + + return uniffiCallbackResultSuccess +} + +type FfiConverterCallbackInterfaceUpdateUsersCallback struct { + FfiConverterCallbackInterface[UpdateUsersCallback] +} + +var FfiConverterCallbackInterfaceUpdateUsersCallbackINSTANCE = &FfiConverterCallbackInterfaceUpdateUsersCallback{ + FfiConverterCallbackInterface: FfiConverterCallbackInterface[UpdateUsersCallback]{ + handleMap: newConcurrentHandleMap[UpdateUsersCallback](), + }, +} + +// This is a static function because only 1 instance is supported for registering +func (c *FfiConverterCallbackInterfaceUpdateUsersCallback) register() { + rustCall(func(status *C.RustCallStatus) int32 { + C.uniffi_rustpushgo_fn_init_callback_updateuserscallback(C.ForeignCallback(C.rustpushgo_cgo_UpdateUsersCallback), status) + return 0 + }) +} + +type FfiDestroyerCallbackInterfaceUpdateUsersCallback struct{} + +func (FfiDestroyerCallbackInterfaceUpdateUsersCallback) Destroy(value UpdateUsersCallback) { +} + +type FfiConverterOptionalUint32 struct{} + +var FfiConverterOptionalUint32INSTANCE = FfiConverterOptionalUint32{} + +func (c FfiConverterOptionalUint32) Lift(rb RustBufferI) *uint32 { + return LiftFromRustBuffer[*uint32](c, rb) +} + +func (_ FfiConverterOptionalUint32) Read(reader io.Reader) *uint32 { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterUint32INSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalUint32) Lower(value *uint32) RustBuffer { + return LowerIntoRustBuffer[*uint32](c, value) +} + +func (_ FfiConverterOptionalUint32) Write(writer io.Writer, value *uint32) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterUint32INSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalUint32 struct{} + +func (_ FfiDestroyerOptionalUint32) Destroy(value *uint32) { + if value != nil { + FfiDestroyerUint32{}.Destroy(*value) + } +} + +type FfiConverterOptionalUint64 struct{} + +var FfiConverterOptionalUint64INSTANCE = FfiConverterOptionalUint64{} + +func (c FfiConverterOptionalUint64) Lift(rb RustBufferI) *uint64 { + return LiftFromRustBuffer[*uint64](c, rb) +} + +func (_ FfiConverterOptionalUint64) Read(reader io.Reader) *uint64 { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterUint64INSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalUint64) Lower(value *uint64) RustBuffer { + return LowerIntoRustBuffer[*uint64](c, value) +} + +func (_ FfiConverterOptionalUint64) Write(writer io.Writer, value *uint64) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterUint64INSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalUint64 struct{} + +func (_ FfiDestroyerOptionalUint64) Destroy(value *uint64) { + if value != nil { + FfiDestroyerUint64{}.Destroy(*value) + } +} + +type FfiConverterOptionalBool struct{} + +var FfiConverterOptionalBoolINSTANCE = FfiConverterOptionalBool{} + +func (c FfiConverterOptionalBool) Lift(rb RustBufferI) *bool { + return LiftFromRustBuffer[*bool](c, rb) +} + +func (_ FfiConverterOptionalBool) Read(reader io.Reader) *bool { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterBoolINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalBool) Lower(value *bool) RustBuffer { + return LowerIntoRustBuffer[*bool](c, value) +} + +func (_ FfiConverterOptionalBool) Write(writer io.Writer, value *bool) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterBoolINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalBool struct{} + +func (_ FfiDestroyerOptionalBool) Destroy(value *bool) { + if value != nil { + FfiDestroyerBool{}.Destroy(*value) + } +} + +type FfiConverterOptionalString struct{} + +var FfiConverterOptionalStringINSTANCE = FfiConverterOptionalString{} + +func (c FfiConverterOptionalString) Lift(rb RustBufferI) *string { + return LiftFromRustBuffer[*string](c, rb) +} + +func (_ FfiConverterOptionalString) Read(reader io.Reader) *string { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterStringINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalString) Lower(value *string) RustBuffer { + return LowerIntoRustBuffer[*string](c, value) +} + +func (_ FfiConverterOptionalString) Write(writer io.Writer, value *string) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterStringINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalString struct{} + +func (_ FfiDestroyerOptionalString) Destroy(value *string) { + if value != nil { + FfiDestroyerString{}.Destroy(*value) + } +} + +type FfiConverterOptionalBytes struct{} + +var FfiConverterOptionalBytesINSTANCE = FfiConverterOptionalBytes{} + +func (c FfiConverterOptionalBytes) Lift(rb RustBufferI) *[]byte { + return LiftFromRustBuffer[*[]byte](c, rb) +} + +func (_ FfiConverterOptionalBytes) Read(reader io.Reader) *[]byte { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterBytesINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalBytes) Lower(value *[]byte) RustBuffer { + return LowerIntoRustBuffer[*[]byte](c, value) +} + +func (_ FfiConverterOptionalBytes) Write(writer io.Writer, value *[]byte) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterBytesINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalBytes struct{} + +func (_ FfiDestroyerOptionalBytes) Destroy(value *[]byte) { + if value != nil { + FfiDestroyerBytes{}.Destroy(*value) + } +} + +type FfiConverterOptionalWrappedIDSNGMIdentity struct{} + +var FfiConverterOptionalWrappedIDSNGMIdentityINSTANCE = FfiConverterOptionalWrappedIDSNGMIdentity{} + +func (c FfiConverterOptionalWrappedIDSNGMIdentity) Lift(rb RustBufferI) **WrappedIdsngmIdentity { + return LiftFromRustBuffer[**WrappedIdsngmIdentity](c, rb) +} + +func (_ FfiConverterOptionalWrappedIDSNGMIdentity) Read(reader io.Reader) **WrappedIdsngmIdentity { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterWrappedIDSNGMIdentityINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalWrappedIDSNGMIdentity) Lower(value **WrappedIdsngmIdentity) RustBuffer { + return LowerIntoRustBuffer[**WrappedIdsngmIdentity](c, value) +} + +func (_ FfiConverterOptionalWrappedIDSNGMIdentity) Write(writer io.Writer, value **WrappedIdsngmIdentity) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterWrappedIDSNGMIdentityINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalWrappedIdsngmIdentity struct{} + +func (_ FfiDestroyerOptionalWrappedIdsngmIdentity) Destroy(value **WrappedIdsngmIdentity) { + if value != nil { + FfiDestroyerWrappedIdsngmIdentity{}.Destroy(*value) + } +} + +type FfiConverterOptionalWrappedIDSUsers struct{} + +var FfiConverterOptionalWrappedIDSUsersINSTANCE = FfiConverterOptionalWrappedIDSUsers{} + +func (c FfiConverterOptionalWrappedIDSUsers) Lift(rb RustBufferI) **WrappedIdsUsers { + return LiftFromRustBuffer[**WrappedIdsUsers](c, rb) +} + +func (_ FfiConverterOptionalWrappedIDSUsers) Read(reader io.Reader) **WrappedIdsUsers { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterWrappedIDSUsersINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalWrappedIDSUsers) Lower(value **WrappedIdsUsers) RustBuffer { + return LowerIntoRustBuffer[**WrappedIdsUsers](c, value) +} + +func (_ FfiConverterOptionalWrappedIDSUsers) Write(writer io.Writer, value **WrappedIdsUsers) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterWrappedIDSUsersINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalWrappedIdsUsers struct{} + +func (_ FfiDestroyerOptionalWrappedIdsUsers) Destroy(value **WrappedIdsUsers) { + if value != nil { + FfiDestroyerWrappedIdsUsers{}.Destroy(*value) + } +} + +type FfiConverterOptionalWrappedTokenProvider struct{} + +var FfiConverterOptionalWrappedTokenProviderINSTANCE = FfiConverterOptionalWrappedTokenProvider{} + +func (c FfiConverterOptionalWrappedTokenProvider) Lift(rb RustBufferI) **WrappedTokenProvider { + return LiftFromRustBuffer[**WrappedTokenProvider](c, rb) +} + +func (_ FfiConverterOptionalWrappedTokenProvider) Read(reader io.Reader) **WrappedTokenProvider { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterWrappedTokenProviderINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalWrappedTokenProvider) Lower(value **WrappedTokenProvider) RustBuffer { + return LowerIntoRustBuffer[**WrappedTokenProvider](c, value) +} + +func (_ FfiConverterOptionalWrappedTokenProvider) Write(writer io.Writer, value **WrappedTokenProvider) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterWrappedTokenProviderINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalWrappedTokenProvider struct{} + +func (_ FfiDestroyerOptionalWrappedTokenProvider) Destroy(value **WrappedTokenProvider) { + if value != nil { + FfiDestroyerWrappedTokenProvider{}.Destroy(*value) + } +} + +type FfiConverterOptionalTypeAccountPersistData struct{} + +var FfiConverterOptionalTypeAccountPersistDataINSTANCE = FfiConverterOptionalTypeAccountPersistData{} + +func (c FfiConverterOptionalTypeAccountPersistData) Lift(rb RustBufferI) *AccountPersistData { + return LiftFromRustBuffer[*AccountPersistData](c, rb) +} + +func (_ FfiConverterOptionalTypeAccountPersistData) Read(reader io.Reader) *AccountPersistData { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterTypeAccountPersistDataINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalTypeAccountPersistData) Lower(value *AccountPersistData) RustBuffer { + return LowerIntoRustBuffer[*AccountPersistData](c, value) +} + +func (_ FfiConverterOptionalTypeAccountPersistData) Write(writer io.Writer, value *AccountPersistData) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterTypeAccountPersistDataINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalTypeAccountPersistData struct{} + +func (_ FfiDestroyerOptionalTypeAccountPersistData) Destroy(value *AccountPersistData) { + if value != nil { + FfiDestroyerTypeAccountPersistData{}.Destroy(*value) + } +} + +type FfiConverterOptionalTypeWrappedShareProfileData struct{} + +var FfiConverterOptionalTypeWrappedShareProfileDataINSTANCE = FfiConverterOptionalTypeWrappedShareProfileData{} + +func (c FfiConverterOptionalTypeWrappedShareProfileData) Lift(rb RustBufferI) *WrappedShareProfileData { + return LiftFromRustBuffer[*WrappedShareProfileData](c, rb) +} + +func (_ FfiConverterOptionalTypeWrappedShareProfileData) Read(reader io.Reader) *WrappedShareProfileData { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterTypeWrappedShareProfileDataINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalTypeWrappedShareProfileData) Lower(value *WrappedShareProfileData) RustBuffer { + return LowerIntoRustBuffer[*WrappedShareProfileData](c, value) +} + +func (_ FfiConverterOptionalTypeWrappedShareProfileData) Write(writer io.Writer, value *WrappedShareProfileData) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterTypeWrappedShareProfileDataINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalTypeWrappedShareProfileData struct{} + +func (_ FfiDestroyerOptionalTypeWrappedShareProfileData) Destroy(value *WrappedShareProfileData) { + if value != nil { + FfiDestroyerTypeWrappedShareProfileData{}.Destroy(*value) + } +} + +type FfiConverterOptionalSequenceString struct{} + +var FfiConverterOptionalSequenceStringINSTANCE = FfiConverterOptionalSequenceString{} + +func (c FfiConverterOptionalSequenceString) Lift(rb RustBufferI) *[]string { + return LiftFromRustBuffer[*[]string](c, rb) +} + +func (_ FfiConverterOptionalSequenceString) Read(reader io.Reader) *[]string { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterSequenceStringINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalSequenceString) Lower(value *[]string) RustBuffer { + return LowerIntoRustBuffer[*[]string](c, value) +} + +func (_ FfiConverterOptionalSequenceString) Write(writer io.Writer, value *[]string) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterSequenceStringINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalSequenceString struct{} + +func (_ FfiDestroyerOptionalSequenceString) Destroy(value *[]string) { + if value != nil { + FfiDestroyerSequenceString{}.Destroy(*value) + } +} + +type FfiConverterOptionalMapStringString struct{} + +var FfiConverterOptionalMapStringStringINSTANCE = FfiConverterOptionalMapStringString{} + +func (c FfiConverterOptionalMapStringString) Lift(rb RustBufferI) *map[string]string { + return LiftFromRustBuffer[*map[string]string](c, rb) +} + +func (_ FfiConverterOptionalMapStringString) Read(reader io.Reader) *map[string]string { + if readInt8(reader) == 0 { + return nil + } + temp := FfiConverterMapStringStringINSTANCE.Read(reader) + return &temp +} + +func (c FfiConverterOptionalMapStringString) Lower(value *map[string]string) RustBuffer { + return LowerIntoRustBuffer[*map[string]string](c, value) +} + +func (_ FfiConverterOptionalMapStringString) Write(writer io.Writer, value *map[string]string) { + if value == nil { + writeInt8(writer, 0) + } else { + writeInt8(writer, 1) + FfiConverterMapStringStringINSTANCE.Write(writer, *value) + } +} + +type FfiDestroyerOptionalMapStringString struct{} + +func (_ FfiDestroyerOptionalMapStringString) Destroy(value *map[string]string) { + if value != nil { + FfiDestroyerMapStringString{}.Destroy(*value) + } +} + +type FfiConverterSequenceString struct{} + +var FfiConverterSequenceStringINSTANCE = FfiConverterSequenceString{} + +func (c FfiConverterSequenceString) Lift(rb RustBufferI) []string { + return LiftFromRustBuffer[[]string](c, rb) +} + +func (c FfiConverterSequenceString) Read(reader io.Reader) []string { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]string, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterStringINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceString) Lower(value []string) RustBuffer { + return LowerIntoRustBuffer[[]string](c, value) +} + +func (c FfiConverterSequenceString) Write(writer io.Writer, value []string) { + if len(value) > math.MaxInt32 { + panic("[]string is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterStringINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceString struct{} + +func (FfiDestroyerSequenceString) Destroy(sequence []string) { + for _, value := range sequence { + FfiDestroyerString{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeEscrowDeviceInfo struct{} + +var FfiConverterSequenceTypeEscrowDeviceInfoINSTANCE = FfiConverterSequenceTypeEscrowDeviceInfo{} + +func (c FfiConverterSequenceTypeEscrowDeviceInfo) Lift(rb RustBufferI) []EscrowDeviceInfo { + return LiftFromRustBuffer[[]EscrowDeviceInfo](c, rb) +} + +func (c FfiConverterSequenceTypeEscrowDeviceInfo) Read(reader io.Reader) []EscrowDeviceInfo { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]EscrowDeviceInfo, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeEscrowDeviceInfoINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeEscrowDeviceInfo) Lower(value []EscrowDeviceInfo) RustBuffer { + return LowerIntoRustBuffer[[]EscrowDeviceInfo](c, value) +} + +func (c FfiConverterSequenceTypeEscrowDeviceInfo) Write(writer io.Writer, value []EscrowDeviceInfo) { + if len(value) > math.MaxInt32 { + panic("[]EscrowDeviceInfo is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeEscrowDeviceInfoINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeEscrowDeviceInfo struct{} + +func (FfiDestroyerSequenceTypeEscrowDeviceInfo) Destroy(sequence []EscrowDeviceInfo) { + for _, value := range sequence { + FfiDestroyerTypeEscrowDeviceInfo{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeSharedAlbumInfo struct{} + +var FfiConverterSequenceTypeSharedAlbumInfoINSTANCE = FfiConverterSequenceTypeSharedAlbumInfo{} + +func (c FfiConverterSequenceTypeSharedAlbumInfo) Lift(rb RustBufferI) []SharedAlbumInfo { + return LiftFromRustBuffer[[]SharedAlbumInfo](c, rb) +} + +func (c FfiConverterSequenceTypeSharedAlbumInfo) Read(reader io.Reader) []SharedAlbumInfo { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]SharedAlbumInfo, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeSharedAlbumInfoINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeSharedAlbumInfo) Lower(value []SharedAlbumInfo) RustBuffer { + return LowerIntoRustBuffer[[]SharedAlbumInfo](c, value) +} + +func (c FfiConverterSequenceTypeSharedAlbumInfo) Write(writer io.Writer, value []SharedAlbumInfo) { + if len(value) > math.MaxInt32 { + panic("[]SharedAlbumInfo is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeSharedAlbumInfoINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeSharedAlbumInfo struct{} + +func (FfiDestroyerSequenceTypeSharedAlbumInfo) Destroy(sequence []SharedAlbumInfo) { + for _, value := range sequence { + FfiDestroyerTypeSharedAlbumInfo{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeSharedAssetInfo struct{} + +var FfiConverterSequenceTypeSharedAssetInfoINSTANCE = FfiConverterSequenceTypeSharedAssetInfo{} + +func (c FfiConverterSequenceTypeSharedAssetInfo) Lift(rb RustBufferI) []SharedAssetInfo { + return LiftFromRustBuffer[[]SharedAssetInfo](c, rb) +} + +func (c FfiConverterSequenceTypeSharedAssetInfo) Read(reader io.Reader) []SharedAssetInfo { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]SharedAssetInfo, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeSharedAssetInfoINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeSharedAssetInfo) Lower(value []SharedAssetInfo) RustBuffer { + return LowerIntoRustBuffer[[]SharedAssetInfo](c, value) +} + +func (c FfiConverterSequenceTypeSharedAssetInfo) Write(writer io.Writer, value []SharedAssetInfo) { + if len(value) > math.MaxInt32 { + panic("[]SharedAssetInfo is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeSharedAssetInfoINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeSharedAssetInfo struct{} + +func (FfiDestroyerSequenceTypeSharedAssetInfo) Destroy(sequence []SharedAssetInfo) { + for _, value := range sequence { + FfiDestroyerTypeSharedAssetInfo{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeWrappedAttachment struct{} + +var FfiConverterSequenceTypeWrappedAttachmentINSTANCE = FfiConverterSequenceTypeWrappedAttachment{} + +func (c FfiConverterSequenceTypeWrappedAttachment) Lift(rb RustBufferI) []WrappedAttachment { + return LiftFromRustBuffer[[]WrappedAttachment](c, rb) +} + +func (c FfiConverterSequenceTypeWrappedAttachment) Read(reader io.Reader) []WrappedAttachment { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]WrappedAttachment, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeWrappedAttachmentINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeWrappedAttachment) Lower(value []WrappedAttachment) RustBuffer { + return LowerIntoRustBuffer[[]WrappedAttachment](c, value) +} + +func (c FfiConverterSequenceTypeWrappedAttachment) Write(writer io.Writer, value []WrappedAttachment) { + if len(value) > math.MaxInt32 { + panic("[]WrappedAttachment is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeWrappedAttachmentINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeWrappedAttachment struct{} + +func (FfiDestroyerSequenceTypeWrappedAttachment) Destroy(sequence []WrappedAttachment) { + for _, value := range sequence { + FfiDestroyerTypeWrappedAttachment{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeWrappedCloudAttachmentInfo struct{} + +var FfiConverterSequenceTypeWrappedCloudAttachmentInfoINSTANCE = FfiConverterSequenceTypeWrappedCloudAttachmentInfo{} + +func (c FfiConverterSequenceTypeWrappedCloudAttachmentInfo) Lift(rb RustBufferI) []WrappedCloudAttachmentInfo { + return LiftFromRustBuffer[[]WrappedCloudAttachmentInfo](c, rb) +} + +func (c FfiConverterSequenceTypeWrappedCloudAttachmentInfo) Read(reader io.Reader) []WrappedCloudAttachmentInfo { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]WrappedCloudAttachmentInfo, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeWrappedCloudAttachmentInfoINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeWrappedCloudAttachmentInfo) Lower(value []WrappedCloudAttachmentInfo) RustBuffer { + return LowerIntoRustBuffer[[]WrappedCloudAttachmentInfo](c, value) +} + +func (c FfiConverterSequenceTypeWrappedCloudAttachmentInfo) Write(writer io.Writer, value []WrappedCloudAttachmentInfo) { + if len(value) > math.MaxInt32 { + panic("[]WrappedCloudAttachmentInfo is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeWrappedCloudAttachmentInfoINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeWrappedCloudAttachmentInfo struct{} + +func (FfiDestroyerSequenceTypeWrappedCloudAttachmentInfo) Destroy(sequence []WrappedCloudAttachmentInfo) { + for _, value := range sequence { + FfiDestroyerTypeWrappedCloudAttachmentInfo{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeWrappedCloudSyncChat struct{} + +var FfiConverterSequenceTypeWrappedCloudSyncChatINSTANCE = FfiConverterSequenceTypeWrappedCloudSyncChat{} + +func (c FfiConverterSequenceTypeWrappedCloudSyncChat) Lift(rb RustBufferI) []WrappedCloudSyncChat { + return LiftFromRustBuffer[[]WrappedCloudSyncChat](c, rb) +} + +func (c FfiConverterSequenceTypeWrappedCloudSyncChat) Read(reader io.Reader) []WrappedCloudSyncChat { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]WrappedCloudSyncChat, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeWrappedCloudSyncChatINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeWrappedCloudSyncChat) Lower(value []WrappedCloudSyncChat) RustBuffer { + return LowerIntoRustBuffer[[]WrappedCloudSyncChat](c, value) +} + +func (c FfiConverterSequenceTypeWrappedCloudSyncChat) Write(writer io.Writer, value []WrappedCloudSyncChat) { + if len(value) > math.MaxInt32 { + panic("[]WrappedCloudSyncChat is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeWrappedCloudSyncChatINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeWrappedCloudSyncChat struct{} + +func (FfiDestroyerSequenceTypeWrappedCloudSyncChat) Destroy(sequence []WrappedCloudSyncChat) { + for _, value := range sequence { + FfiDestroyerTypeWrappedCloudSyncChat{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeWrappedCloudSyncMessage struct{} + +var FfiConverterSequenceTypeWrappedCloudSyncMessageINSTANCE = FfiConverterSequenceTypeWrappedCloudSyncMessage{} + +func (c FfiConverterSequenceTypeWrappedCloudSyncMessage) Lift(rb RustBufferI) []WrappedCloudSyncMessage { + return LiftFromRustBuffer[[]WrappedCloudSyncMessage](c, rb) +} + +func (c FfiConverterSequenceTypeWrappedCloudSyncMessage) Read(reader io.Reader) []WrappedCloudSyncMessage { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]WrappedCloudSyncMessage, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeWrappedCloudSyncMessageINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeWrappedCloudSyncMessage) Lower(value []WrappedCloudSyncMessage) RustBuffer { + return LowerIntoRustBuffer[[]WrappedCloudSyncMessage](c, value) +} + +func (c FfiConverterSequenceTypeWrappedCloudSyncMessage) Write(writer io.Writer, value []WrappedCloudSyncMessage) { + if len(value) > math.MaxInt32 { + panic("[]WrappedCloudSyncMessage is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeWrappedCloudSyncMessageINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeWrappedCloudSyncMessage struct{} + +func (FfiDestroyerSequenceTypeWrappedCloudSyncMessage) Destroy(sequence []WrappedCloudSyncMessage) { + for _, value := range sequence { + FfiDestroyerTypeWrappedCloudSyncMessage{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeWrappedLetMeInRequest struct{} + +var FfiConverterSequenceTypeWrappedLetMeInRequestINSTANCE = FfiConverterSequenceTypeWrappedLetMeInRequest{} + +func (c FfiConverterSequenceTypeWrappedLetMeInRequest) Lift(rb RustBufferI) []WrappedLetMeInRequest { + return LiftFromRustBuffer[[]WrappedLetMeInRequest](c, rb) +} + +func (c FfiConverterSequenceTypeWrappedLetMeInRequest) Read(reader io.Reader) []WrappedLetMeInRequest { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]WrappedLetMeInRequest, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeWrappedLetMeInRequestINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeWrappedLetMeInRequest) Lower(value []WrappedLetMeInRequest) RustBuffer { + return LowerIntoRustBuffer[[]WrappedLetMeInRequest](c, value) +} + +func (c FfiConverterSequenceTypeWrappedLetMeInRequest) Write(writer io.Writer, value []WrappedLetMeInRequest) { + if len(value) > math.MaxInt32 { + panic("[]WrappedLetMeInRequest is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeWrappedLetMeInRequestINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeWrappedLetMeInRequest struct{} + +func (FfiDestroyerSequenceTypeWrappedLetMeInRequest) Destroy(sequence []WrappedLetMeInRequest) { + for _, value := range sequence { + FfiDestroyerTypeWrappedLetMeInRequest{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeWrappedPasswordEntryRef struct{} + +var FfiConverterSequenceTypeWrappedPasswordEntryRefINSTANCE = FfiConverterSequenceTypeWrappedPasswordEntryRef{} + +func (c FfiConverterSequenceTypeWrappedPasswordEntryRef) Lift(rb RustBufferI) []WrappedPasswordEntryRef { + return LiftFromRustBuffer[[]WrappedPasswordEntryRef](c, rb) +} + +func (c FfiConverterSequenceTypeWrappedPasswordEntryRef) Read(reader io.Reader) []WrappedPasswordEntryRef { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]WrappedPasswordEntryRef, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeWrappedPasswordEntryRefINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeWrappedPasswordEntryRef) Lower(value []WrappedPasswordEntryRef) RustBuffer { + return LowerIntoRustBuffer[[]WrappedPasswordEntryRef](c, value) +} + +func (c FfiConverterSequenceTypeWrappedPasswordEntryRef) Write(writer io.Writer, value []WrappedPasswordEntryRef) { + if len(value) > math.MaxInt32 { + panic("[]WrappedPasswordEntryRef is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeWrappedPasswordEntryRefINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeWrappedPasswordEntryRef struct{} + +func (FfiDestroyerSequenceTypeWrappedPasswordEntryRef) Destroy(sequence []WrappedPasswordEntryRef) { + for _, value := range sequence { + FfiDestroyerTypeWrappedPasswordEntryRef{}.Destroy(value) + } +} + +type FfiConverterSequenceTypeWrappedStatusKitInviteHandle struct{} + +var FfiConverterSequenceTypeWrappedStatusKitInviteHandleINSTANCE = FfiConverterSequenceTypeWrappedStatusKitInviteHandle{} + +func (c FfiConverterSequenceTypeWrappedStatusKitInviteHandle) Lift(rb RustBufferI) []WrappedStatusKitInviteHandle { + return LiftFromRustBuffer[[]WrappedStatusKitInviteHandle](c, rb) +} + +func (c FfiConverterSequenceTypeWrappedStatusKitInviteHandle) Read(reader io.Reader) []WrappedStatusKitInviteHandle { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]WrappedStatusKitInviteHandle, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTypeWrappedStatusKitInviteHandleINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTypeWrappedStatusKitInviteHandle) Lower(value []WrappedStatusKitInviteHandle) RustBuffer { + return LowerIntoRustBuffer[[]WrappedStatusKitInviteHandle](c, value) +} + +func (c FfiConverterSequenceTypeWrappedStatusKitInviteHandle) Write(writer io.Writer, value []WrappedStatusKitInviteHandle) { + if len(value) > math.MaxInt32 { + panic("[]WrappedStatusKitInviteHandle is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTypeWrappedStatusKitInviteHandleINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTypeWrappedStatusKitInviteHandle struct{} + +func (FfiDestroyerSequenceTypeWrappedStatusKitInviteHandle) Destroy(sequence []WrappedStatusKitInviteHandle) { + for _, value := range sequence { + FfiDestroyerTypeWrappedStatusKitInviteHandle{}.Destroy(value) + } +} + +type FfiConverterMapStringString struct{} + +var FfiConverterMapStringStringINSTANCE = FfiConverterMapStringString{} + +func (c FfiConverterMapStringString) Lift(rb RustBufferI) map[string]string { + return LiftFromRustBuffer[map[string]string](c, rb) +} + +func (_ FfiConverterMapStringString) Read(reader io.Reader) map[string]string { + result := make(map[string]string) + length := readInt32(reader) + for i := int32(0); i < length; i++ { + key := FfiConverterStringINSTANCE.Read(reader) + value := FfiConverterStringINSTANCE.Read(reader) + result[key] = value + } + return result +} + +func (c FfiConverterMapStringString) Lower(value map[string]string) RustBuffer { + return LowerIntoRustBuffer[map[string]string](c, value) +} + +func (_ FfiConverterMapStringString) Write(writer io.Writer, mapValue map[string]string) { + if len(mapValue) > math.MaxInt32 { + panic("map[string]string is too large to fit into Int32") + } + + writeInt32(writer, int32(len(mapValue))) + for key, value := range mapValue { + FfiConverterStringINSTANCE.Write(writer, key) + FfiConverterStringINSTANCE.Write(writer, value) + } +} + +type FfiDestroyerMapStringString struct{} + +func (_ FfiDestroyerMapStringString) Destroy(mapValue map[string]string) { + for key, value := range mapValue { + FfiDestroyerString{}.Destroy(key) + FfiDestroyerString{}.Destroy(value) + } +} + +const ( + uniffiRustFuturePollReady int8 = 0 + uniffiRustFuturePollMaybeReady int8 = 1 +) + +func uniffiRustCallAsync( + rustFutureFunc func(*C.RustCallStatus) *C.void, + pollFunc func(*C.void, unsafe.Pointer, *C.RustCallStatus), + completeFunc func(*C.void, *C.RustCallStatus), + _liftFunc func(bool), + freeFunc func(*C.void, *C.RustCallStatus), +) { + rustFuture, err := uniffiRustCallAsyncInner(nil, rustFutureFunc, pollFunc, freeFunc) + if err != nil { + panic(err) + } + defer rustCall(func(status *C.RustCallStatus) int { + freeFunc(rustFuture, status) + return 0 + }) + + rustCall(func(status *C.RustCallStatus) int { + completeFunc(rustFuture, status) + return 0 + }) +} + +func uniffiRustCallAsyncWithResult[T any, U any]( + rustFutureFunc func(*C.RustCallStatus) *C.void, + pollFunc func(*C.void, unsafe.Pointer, *C.RustCallStatus), + completeFunc func(*C.void, *C.RustCallStatus) T, + liftFunc func(T) U, + freeFunc func(*C.void, *C.RustCallStatus), +) U { + rustFuture, err := uniffiRustCallAsyncInner(nil, rustFutureFunc, pollFunc, freeFunc) + if err != nil { + panic(err) + } + + defer rustCall(func(status *C.RustCallStatus) int { + freeFunc(rustFuture, status) + return 0 + }) + + res := rustCall(func(status *C.RustCallStatus) T { + return completeFunc(rustFuture, status) + }) + return liftFunc(res) +} + +func uniffiRustCallAsyncWithError( + converter BufLifter[error], + rustFutureFunc func(*C.RustCallStatus) *C.void, + pollFunc func(*C.void, unsafe.Pointer, *C.RustCallStatus), + completeFunc func(*C.void, *C.RustCallStatus), + _liftFunc func(bool), + freeFunc func(*C.void, *C.RustCallStatus), +) error { + rustFuture, err := uniffiRustCallAsyncInner(converter, rustFutureFunc, pollFunc, freeFunc) + if err != nil { + return err + } + + defer rustCall(func(status *C.RustCallStatus) int { + freeFunc(rustFuture, status) + return 0 + }) + + _, err = rustCallWithError(converter, func(status *C.RustCallStatus) int { + completeFunc(rustFuture, status) + return 0 + }) + return err +} + +func uniffiRustCallAsyncWithErrorAndResult[T any, U any]( + converter BufLifter[error], + rustFutureFunc func(*C.RustCallStatus) *C.void, + pollFunc func(*C.void, unsafe.Pointer, *C.RustCallStatus), + completeFunc func(*C.void, *C.RustCallStatus) T, + liftFunc func(T) U, + freeFunc func(*C.void, *C.RustCallStatus), +) (U, error) { + var returnValue U + rustFuture, err := uniffiRustCallAsyncInner(converter, rustFutureFunc, pollFunc, freeFunc) + if err != nil { + return returnValue, err + } + + defer rustCall(func(status *C.RustCallStatus) int { + freeFunc(rustFuture, status) + return 0 + }) + + res, err := rustCallWithError(converter, func(status *C.RustCallStatus) T { + return completeFunc(rustFuture, status) + }) + if err != nil { + return returnValue, err + } + return liftFunc(res), nil +} + +func uniffiRustCallAsyncInner( + converter BufLifter[error], + rustFutureFunc func(*C.RustCallStatus) *C.void, + pollFunc func(*C.void, unsafe.Pointer, *C.RustCallStatus), + freeFunc func(*C.void, *C.RustCallStatus), +) (*C.void, error) { + pollResult := int8(-1) + waiter := make(chan int8, 1) + chanHandle := cgo.NewHandle(waiter) + + rustFuture, err := rustCallWithError(converter, func(status *C.RustCallStatus) *C.void { + return rustFutureFunc(status) + }) + if err != nil { + return nil, err + } + + defer chanHandle.Delete() + + for pollResult != uniffiRustFuturePollReady { + ptr := unsafe.Pointer(&chanHandle) + _, err = rustCallWithError(converter, func(status *C.RustCallStatus) int { + pollFunc(rustFuture, ptr, status) + return 0 + }) + if err != nil { + return nil, err + } + res := <-waiter + pollResult = res + } + + return rustFuture, nil +} + +// Callback handlers for an async calls. These are invoked by Rust when the future is ready. They +// lift the return value or error and resume the suspended function. + +//export uniffiFutureContinuationCallbackrustpushgo +func uniffiFutureContinuationCallbackrustpushgo(ptr unsafe.Pointer, pollResult C.int8_t) { + doneHandle := *(*cgo.Handle)(ptr) + done := doneHandle.Value().((chan int8)) + done <- int8(pollResult) +} + +func uniffiInitContinuationCallback() { + rustCall(func(uniffiStatus *C.RustCallStatus) bool { + C.ffi_rustpushgo_rust_future_continuation_callback_set( + C.RustFutureContinuation(C.uniffiFutureContinuationCallbackrustpushgo), + uniffiStatus, + ) + return false + }) +} + +func Connect(config *WrappedOsConfig, state *WrappedApsState) *WrappedApsConnection { + return uniffiRustCallAsyncWithResult(func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_func_connect(FfiConverterWrappedOSConfigINSTANCE.Lower(config), FfiConverterWrappedAPSStateINSTANCE.Lower(state), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterWrappedAPSConnectionINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} + +func CreateConfigFromHardwareKey(base64Key string) (*WrappedOsConfig, error) { + _uniffiRV, _uniffiErr := rustCallWithError(FfiConverterTypeWrappedError{}, func(_uniffiStatus *C.RustCallStatus) unsafe.Pointer { + return C.uniffi_rustpushgo_fn_func_create_config_from_hardware_key(rustBufferToC(FfiConverterStringINSTANCE.Lower(base64Key)), _uniffiStatus) + }) + if _uniffiErr != nil { + var _uniffiDefaultValue *WrappedOsConfig + return _uniffiDefaultValue, _uniffiErr + } else { + return FfiConverterWrappedOSConfigINSTANCE.Lift(_uniffiRV), _uniffiErr + } +} + +func CreateConfigFromHardwareKeyWithDeviceId(base64Key string, deviceId string) (*WrappedOsConfig, error) { + _uniffiRV, _uniffiErr := rustCallWithError(FfiConverterTypeWrappedError{}, func(_uniffiStatus *C.RustCallStatus) unsafe.Pointer { + return C.uniffi_rustpushgo_fn_func_create_config_from_hardware_key_with_device_id(rustBufferToC(FfiConverterStringINSTANCE.Lower(base64Key)), rustBufferToC(FfiConverterStringINSTANCE.Lower(deviceId)), _uniffiStatus) + }) + if _uniffiErr != nil { + var _uniffiDefaultValue *WrappedOsConfig + return _uniffiDefaultValue, _uniffiErr + } else { + return FfiConverterWrappedOSConfigINSTANCE.Lift(_uniffiRV), _uniffiErr + } +} + +func CreateLocalMacosConfig() (*WrappedOsConfig, error) { + _uniffiRV, _uniffiErr := rustCallWithError(FfiConverterTypeWrappedError{}, func(_uniffiStatus *C.RustCallStatus) unsafe.Pointer { + return C.uniffi_rustpushgo_fn_func_create_local_macos_config(_uniffiStatus) + }) + if _uniffiErr != nil { + var _uniffiDefaultValue *WrappedOsConfig + return _uniffiDefaultValue, _uniffiErr + } else { + return FfiConverterWrappedOSConfigINSTANCE.Lift(_uniffiRV), _uniffiErr + } +} + +func CreateLocalMacosConfigWithDeviceId(deviceId string) (*WrappedOsConfig, error) { + _uniffiRV, _uniffiErr := rustCallWithError(FfiConverterTypeWrappedError{}, func(_uniffiStatus *C.RustCallStatus) unsafe.Pointer { + return C.uniffi_rustpushgo_fn_func_create_local_macos_config_with_device_id(rustBufferToC(FfiConverterStringINSTANCE.Lower(deviceId)), _uniffiStatus) + }) + if _uniffiErr != nil { + var _uniffiDefaultValue *WrappedOsConfig + return _uniffiDefaultValue, _uniffiErr + } else { + return FfiConverterWrappedOSConfigINSTANCE.Lift(_uniffiRV), _uniffiErr + } +} + +func FordKeyCacheSize() uint64 { + return FfiConverterUint64INSTANCE.Lift(rustCall(func(_uniffiStatus *C.RustCallStatus) C.uint64_t { + return C.uniffi_rustpushgo_fn_func_ford_key_cache_size(_uniffiStatus) + })) +} + +func InitLogger() { + rustCall(func(_uniffiStatus *C.RustCallStatus) bool { + C.uniffi_rustpushgo_fn_func_init_logger(_uniffiStatus) + return false + }) +} + +func LoginStart(appleId string, password string, config *WrappedOsConfig, connection *WrappedApsConnection) (*LoginSession, error) { + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_func_login_start(rustBufferToC(FfiConverterStringINSTANCE.Lower(appleId)), rustBufferToC(FfiConverterStringINSTANCE.Lower(password)), FfiConverterWrappedOSConfigINSTANCE.Lower(config), FfiConverterWrappedAPSConnectionINSTANCE.Lower(connection), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterLoginSessionINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} + +func NewClient(connection *WrappedApsConnection, users *WrappedIdsUsers, identity *WrappedIdsngmIdentity, config *WrappedOsConfig, tokenProvider **WrappedTokenProvider, messageCallback MessageCallback, updateUsersCallback UpdateUsersCallback) (*Client, error) { + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_func_new_client(FfiConverterWrappedAPSConnectionINSTANCE.Lower(connection), FfiConverterWrappedIDSUsersINSTANCE.Lower(users), FfiConverterWrappedIDSNGMIdentityINSTANCE.Lower(identity), FfiConverterWrappedOSConfigINSTANCE.Lower(config), rustBufferToC(FfiConverterOptionalWrappedTokenProviderINSTANCE.Lower(tokenProvider)), FfiConverterCallbackInterfaceMessageCallbackINSTANCE.Lower(messageCallback), FfiConverterCallbackInterfaceUpdateUsersCallbackINSTANCE.Lower(updateUsersCallback), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterClientINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} + +func RegisterFordKey(key []byte) { + rustCall(func(_uniffiStatus *C.RustCallStatus) bool { + C.uniffi_rustpushgo_fn_func_register_ford_key(rustBufferToC(FfiConverterBytesINSTANCE.Lower(key)), _uniffiStatus) + return false + }) +} + +func RestoreTokenProvider(config *WrappedOsConfig, connection *WrappedApsConnection, username string, hashedPasswordHex string, pet string, spdBase64 string) (*WrappedTokenProvider, error) { + return uniffiRustCallAsyncWithErrorAndResult( + FfiConverterTypeWrappedError{}, func(status *C.RustCallStatus) *C.void { + // rustFutureFunc + return (*C.void)(C.uniffi_rustpushgo_fn_func_restore_token_provider(FfiConverterWrappedOSConfigINSTANCE.Lower(config), FfiConverterWrappedAPSConnectionINSTANCE.Lower(connection), rustBufferToC(FfiConverterStringINSTANCE.Lower(username)), rustBufferToC(FfiConverterStringINSTANCE.Lower(hashedPasswordHex)), rustBufferToC(FfiConverterStringINSTANCE.Lower(pet)), rustBufferToC(FfiConverterStringINSTANCE.Lower(spdBase64)), + status, + )) + }, + func(handle *C.void, ptr unsafe.Pointer, status *C.RustCallStatus) { + // pollFunc + C.ffi_rustpushgo_rust_future_poll_pointer(unsafe.Pointer(handle), ptr, status) + }, + func(handle *C.void, status *C.RustCallStatus) unsafe.Pointer { + // completeFunc + return C.ffi_rustpushgo_rust_future_complete_pointer(unsafe.Pointer(handle), status) + }, + FfiConverterWrappedTokenProviderINSTANCE.Lift, func(rustFuture *C.void, status *C.RustCallStatus) { + // freeFunc + C.ffi_rustpushgo_rust_future_free_pointer(unsafe.Pointer(rustFuture), status) + }) +} diff --git a/pkg/rustpushgo/rustpushgo.h b/pkg/rustpushgo/rustpushgo.h new file mode 100644 index 00000000..8720e611 --- /dev/null +++ b/pkg/rustpushgo/rustpushgo.h @@ -0,0 +1,2173 @@ + + +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + + + +#include <stdbool.h> +#include <stdint.h> + +// The following structs are used to implement the lowest level +// of the FFI, and thus useful to multiple uniffied crates. +// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H. +#ifdef UNIFFI_SHARED_H + // We also try to prevent mixing versions of shared uniffi header structs. + // If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V6 + #ifndef UNIFFI_SHARED_HEADER_V6 + #error Combining helper code from multiple versions of uniffi is not supported + #endif // ndef UNIFFI_SHARED_HEADER_V6 +#else +#define UNIFFI_SHARED_H +#define UNIFFI_SHARED_HEADER_V6 +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V6 in this file. ⚠️ + +typedef struct RustBuffer { + int32_t capacity; + int32_t len; + uint8_t *data; +} RustBuffer; + +typedef int32_t (*ForeignCallback)(uint64_t, int32_t, uint8_t *, int32_t, RustBuffer *); + +// Task defined in Rust that Go executes +typedef void (*RustTaskCallback)(const void *, int8_t); + +// Callback to execute Rust tasks using a Go routine +// +// Args: +// executor: ForeignExecutor lowered into a uint64_t value +// delay: Delay in MS +// task: RustTaskCallback to call +// task_data: data to pass the task callback +typedef int8_t (*ForeignExecutorCallback)(uint64_t, uint32_t, RustTaskCallback, void *); + +typedef struct ForeignBytes { + int32_t len; + const uint8_t *data; +} ForeignBytes; + +// Error definitions +typedef struct RustCallStatus { + int8_t code; + RustBuffer errorBuf; +} RustCallStatus; + +// Continuation callback for UniFFI Futures +typedef void (*RustFutureContinuation)(void * , int8_t); + +// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️ +// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V6 in this file. ⚠️ +#endif // def UNIFFI_SHARED_H + +// Needed because we can't execute the callback directly from go. +void cgo_rust_task_callback_bridge_rustpushgo(RustTaskCallback, const void *, int8_t); + +int8_t uniffiForeignExecutorCallbackrustpushgo(uint64_t, uint32_t, RustTaskCallback, void*); + +void uniffiFutureContinuationCallbackrustpushgo(void*, int8_t); + +void uniffi_rustpushgo_fn_free_client( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_diag_full_count( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_download_attachment( + void* ptr, + RustBuffer record_name, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_download_attachment_avid( + void* ptr, + RustBuffer record_name, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_download_group_photo( + void* ptr, + RustBuffer record_name, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_dump_chats_json( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_fetch_recent_messages( + void* ptr, + uint64_t since_timestamp_ms, + RustBuffer chat_id, + uint32_t max_pages, + uint32_t max_results, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_query_attachments_fallback( + void* ptr, + RustBuffer known_record_names, + RustCallStatus* out_status +); + +int8_t uniffi_rustpushgo_fn_method_client_cloud_supports_avid_download( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_sync_attachments( + void* ptr, + RustBuffer continuation_token, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_sync_chats( + void* ptr, + RustBuffer continuation_token, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_cloud_sync_messages( + void* ptr, + RustBuffer continuation_token, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_debug_recoverable_zones( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_delete_cloud_chats( + void* ptr, + RustBuffer chat_ids, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_delete_cloud_messages( + void* ptr, + RustBuffer message_ids, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_fetch_profile( + void* ptr, + RustBuffer record_key, + RustBuffer decryption_key, + int8_t has_poster, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_findmy_friends_import( + void* ptr, + int8_t daemon, + RustBuffer url, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_findmy_friends_refresh_json( + void* ptr, + int8_t daemon, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_findmy_phone_refresh_json( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_get_contacts_url( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_get_dsid( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_get_facetime_client( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_get_findmy_client( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_get_handles( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_get_icloud_auth_headers( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_get_passwords_client( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_get_sharedstreams_client( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_get_statuskit_client( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_init_statuskit( + void* ptr, + uint64_t callback, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_invite_to_status_sharing( + void* ptr, + RustBuffer sender_handle, + RustBuffer handles, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_list_recoverable_chats( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_list_recoverable_message_guids( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_purge_recoverable_zones( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_reset_cloud_client( + void* ptr, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_method_client_reset_statuskit_cursors( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_resolve_handle( + void* ptr, + RustBuffer handle, + RustBuffer known_handles, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_resolve_handle_cached( + void* ptr, + RustBuffer handle, + RustBuffer known_handles, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_restore_cloud_chat( + void* ptr, + RustBuffer record_name, + RustBuffer chat_identifier, + RustBuffer group_id, + int64_t style, + RustBuffer service, + RustBuffer display_name, + RustBuffer participants, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_attachment( + void* ptr, + RustBuffer conversation, + RustBuffer data, + RustBuffer mime, + RustBuffer uti_type, + RustBuffer filename, + RustBuffer handle, + RustBuffer reply_guid, + RustBuffer reply_part, + RustBuffer body, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_change_participants( + void* ptr, + RustBuffer conversation, + RustBuffer new_participants, + uint64_t group_version, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_delivery_receipt( + void* ptr, + RustBuffer conversation, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_edit( + void* ptr, + RustBuffer conversation, + RustBuffer target_uuid, + uint64_t edit_part, + RustBuffer new_text, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_error_message( + void* ptr, + RustBuffer conversation, + RustBuffer for_uuid, + uint64_t error_status, + RustBuffer status_str, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_icon_change( + void* ptr, + RustBuffer conversation, + RustBuffer photo_data, + uint64_t group_version, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_icon_clear( + void* ptr, + RustBuffer conversation, + uint64_t group_version, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_mark_unread( + void* ptr, + RustBuffer conversation, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_message( + void* ptr, + RustBuffer conversation, + RustBuffer text, + RustBuffer html, + RustBuffer handle, + RustBuffer reply_guid, + RustBuffer reply_part, + RustBuffer scheduled_ms, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_message_read_on_device( + void* ptr, + RustBuffer conversation, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_move_to_recycle_bin( + void* ptr, + RustBuffer conversation, + RustBuffer handle, + RustBuffer chat_guid, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_notify_anyways( + void* ptr, + RustBuffer conversation, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_peer_cache_invalidate( + void* ptr, + RustBuffer conversation, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_permanent_delete_chat( + void* ptr, + RustBuffer conversation, + RustBuffer chat_guid, + int8_t is_scheduled, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_permanent_delete_messages( + void* ptr, + RustBuffer conversation, + RustBuffer message_uuids, + int8_t is_scheduled, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_read_receipt( + void* ptr, + RustBuffer conversation, + RustBuffer handle, + RustBuffer for_uuid, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_recover_chat( + void* ptr, + RustBuffer conversation, + RustBuffer handle, + RustBuffer chat_guid, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_rename_group( + void* ptr, + RustBuffer conversation, + RustBuffer new_name, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_set_transcript_background( + void* ptr, + RustBuffer conversation, + uint64_t group_version, + RustBuffer image_data, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_share_profile( + void* ptr, + RustBuffer conversation, + RustBuffer cloud_kit_record_key, + RustBuffer cloud_kit_decryption_record_key, + RustBuffer low_res_wallpaper_tag, + RustBuffer wallpaper_tag, + RustBuffer message_tag, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_sms_activation( + void* ptr, + RustBuffer conversation, + int8_t enable, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_sms_confirm_sent( + void* ptr, + RustBuffer conversation, + int8_t sms_status, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_tapback( + void* ptr, + RustBuffer conversation, + RustBuffer target_uuid, + uint64_t target_part, + uint32_t reaction, + RustBuffer emoji, + int8_t remove, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_typing( + void* ptr, + RustBuffer conversation, + int8_t typing, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_typing_with_app( + void* ptr, + RustBuffer conversation, + int8_t typing, + RustBuffer handle, + RustBuffer bundle_id, + RustBuffer icon, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_unschedule( + void* ptr, + RustBuffer conversation, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_unsend( + void* ptr, + RustBuffer conversation, + RustBuffer target_uuid, + uint64_t edit_part, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_update_extension( + void* ptr, + RustBuffer conversation, + RustBuffer for_uuid, + RustBuffer extension, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_update_profile( + void* ptr, + RustBuffer conversation, + RustBuffer profile, + int8_t share_contacts, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_send_update_profile_sharing( + void* ptr, + RustBuffer conversation, + RustBuffer shared_dismissed, + RustBuffer shared_all, + uint64_t version, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_set_status( + void* ptr, + int8_t active, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_stop( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_subscribe_to_status( + void* ptr, + RustBuffer handles, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_test_cloud_messages( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_unsubscribe_all_status( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_client_validate_targets( + void* ptr, + RustBuffer targets, + RustBuffer handle, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_loginsession( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_loginsession_finish( + void* ptr, + void* config, + void* connection, + RustBuffer existing_identity, + RustBuffer existing_users, + RustCallStatus* out_status +); + +int8_t uniffi_rustpushgo_fn_method_loginsession_needs_2fa( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_loginsession_submit_2fa( + void* ptr, + RustBuffer code, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedapsconnection( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedapsconnection_state( + void* ptr, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedapsstate( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_constructor_wrappedapsstate_new( + RustBuffer string, + RustCallStatus* out_status +); + +RustBuffer uniffi_rustpushgo_fn_method_wrappedapsstate_to_string( + void* ptr, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedfacetimeclient( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_add_members( + void* ptr, + RustBuffer session_id, + RustBuffer handles, + int8_t letmein, + RustBuffer to_members, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_bind_bridge_link_to_session( + void* ptr, + RustBuffer handle, + RustBuffer usage, + RustBuffer group_id, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_clear_links( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_create_session( + void* ptr, + RustBuffer group_id, + RustBuffer handle, + RustBuffer participants, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_create_session_no_ring( + void* ptr, + RustBuffer group_id, + RustBuffer handle, + RustBuffer participants, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_delete_link( + void* ptr, + RustBuffer pseud, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_export_state_json( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_get_link_for_usage( + void* ptr, + RustBuffer handle, + RustBuffer usage, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_get_session_link( + void* ptr, + RustBuffer guid, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_list_delegated_letmein_requests( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_register_pending_ring( + void* ptr, + RustBuffer session_id, + RustBuffer caller_handle, + RustBuffer targets, + uint64_t ttl_secs, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_remove_members( + void* ptr, + RustBuffer session_id, + RustBuffer handles, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_respond_delegated_letmein( + void* ptr, + RustBuffer delegation_uuid, + RustBuffer approved_group, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_ring( + void* ptr, + RustBuffer session_id, + RustBuffer targets, + int8_t letmein, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfacetimeclient_use_link_for( + void* ptr, + RustBuffer old_usage, + RustBuffer usage, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedfindmyclient( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfindmyclient_accept_item_share( + void* ptr, + RustBuffer circle_id, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfindmyclient_delete_shared_item( + void* ptr, + RustBuffer id, + int8_t remove_beacon, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfindmyclient_export_state_json( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfindmyclient_sync_item_positions( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedfindmyclient_update_beacon_name( + void* ptr, + RustBuffer associated_beacon, + int64_t role_id, + RustBuffer name, + RustBuffer emoji, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedidsngmidentity( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_constructor_wrappedidsngmidentity_new( + RustBuffer string, + RustCallStatus* out_status +); + +RustBuffer uniffi_rustpushgo_fn_method_wrappedidsngmidentity_to_string( + void* ptr, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedidsusers( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_constructor_wrappedidsusers_new( + RustBuffer string, + RustCallStatus* out_status +); + +RustBuffer uniffi_rustpushgo_fn_method_wrappedidsusers_get_handles( + void* ptr, + RustCallStatus* out_status +); + +RustBuffer uniffi_rustpushgo_fn_method_wrappedidsusers_login_id( + void* ptr, + uint64_t i, + RustCallStatus* out_status +); + +RustBuffer uniffi_rustpushgo_fn_method_wrappedidsusers_to_string( + void* ptr, + RustCallStatus* out_status +); + +int8_t uniffi_rustpushgo_fn_method_wrappedidsusers_validate_keystore( + void* ptr, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedosconfig( + void* ptr, + RustCallStatus* out_status +); + +RustBuffer uniffi_rustpushgo_fn_method_wrappedosconfig_get_device_id( + void* ptr, + RustCallStatus* out_status +); + +int8_t uniffi_rustpushgo_fn_method_wrappedosconfig_requires_nac_relay( + void* ptr, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedpasswordsclient( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_accept_invite( + void* ptr, + RustBuffer invite_id, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_create_group( + void* ptr, + RustBuffer name, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_decline_invite( + void* ptr, + RustBuffer invite_id, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_delete_password_raw_entry( + void* ptr, + RustBuffer id, + RustBuffer group, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_export_state_json( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_get_password_site_counts( + void* ptr, + RustBuffer site, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_invite_user( + void* ptr, + RustBuffer group_id, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_list_password_raw_entry_refs( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_query_handle( + void* ptr, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_remove_group( + void* ptr, + RustBuffer id, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_remove_user( + void* ptr, + RustBuffer group_id, + RustBuffer handle, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_rename_group( + void* ptr, + RustBuffer id, + RustBuffer new_name, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_sync_passwords( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedpasswordsclient_upsert_password_raw_entry( + void* ptr, + RustBuffer id, + RustBuffer site, + RustBuffer account, + RustBuffer secret_data, + RustBuffer group, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedsharedstreamsclient( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_delete_assets( + void* ptr, + RustBuffer album, + RustBuffer assets, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_download_file( + void* ptr, + RustBuffer album, + RustBuffer asset_guid, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_export_state_json( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_get_album_assets( + void* ptr, + RustBuffer album, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_get_album_summary( + void* ptr, + RustBuffer album, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_get_assets_json( + void* ptr, + RustBuffer album, + RustBuffer assets, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_get_changes( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_list_album_ids( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_list_albums( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_subscribe( + void* ptr, + RustBuffer album, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_subscribe_token( + void* ptr, + RustBuffer token, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedsharedstreamsclient_unsubscribe( + void* ptr, + RustBuffer album, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedstatuskitclient( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedstatuskitclient_clear_interest_tokens( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedstatuskitclient_export_state_json( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedstatuskitclient_get_known_handles( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedstatuskitclient_invite_to_channel( + void* ptr, + RustBuffer sender_handle, + RustBuffer handles, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedstatuskitclient_request_handles( + void* ptr, + RustBuffer handles, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedstatuskitclient_reset_keys( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedstatuskitclient_roll_keys( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedstatuskitclient_share_status( + void* ptr, + int8_t active, + RustBuffer mode, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_free_wrappedtokenprovider( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_contacts_url( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_dsid( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_escrow_devices( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_icloud_auth_headers( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedtokenprovider_get_mme_delegate_json( + void* ptr, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedtokenprovider_join_keychain_clique( + void* ptr, + RustBuffer passcode, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedtokenprovider_join_keychain_clique_for_device( + void* ptr, + RustBuffer passcode, + uint32_t device_index, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_method_wrappedtokenprovider_seed_mme_delegate_json( + void* ptr, + RustBuffer json, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_init_callback_messagecallback( + ForeignCallback callback_stub, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_init_callback_statuscallback( + ForeignCallback callback_stub, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_init_callback_updateuserscallback( + ForeignCallback callback_stub, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_func_connect( + void* config, + void* state, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_func_create_config_from_hardware_key( + RustBuffer base64_key, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_func_create_config_from_hardware_key_with_device_id( + RustBuffer base64_key, + RustBuffer device_id, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_func_create_local_macos_config( + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_func_create_local_macos_config_with_device_id( + RustBuffer device_id, + RustCallStatus* out_status +); + +uint64_t uniffi_rustpushgo_fn_func_ford_key_cache_size( + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_func_init_logger( + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_func_login_start( + RustBuffer apple_id, + RustBuffer password, + void* config, + void* connection, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_func_new_client( + void* connection, + void* users, + void* identity, + void* config, + RustBuffer token_provider, + uint64_t message_callback, + uint64_t update_users_callback, + RustCallStatus* out_status +); + +void uniffi_rustpushgo_fn_func_register_ford_key( + RustBuffer key, + RustCallStatus* out_status +); + +void* uniffi_rustpushgo_fn_func_restore_token_provider( + void* config, + void* connection, + RustBuffer username, + RustBuffer hashed_password_hex, + RustBuffer pet, + RustBuffer spd_base64, + RustCallStatus* out_status +); + +RustBuffer ffi_rustpushgo_rustbuffer_alloc( + int32_t size, + RustCallStatus* out_status +); + +RustBuffer ffi_rustpushgo_rustbuffer_from_bytes( + ForeignBytes bytes, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rustbuffer_free( + RustBuffer buf, + RustCallStatus* out_status +); + +RustBuffer ffi_rustpushgo_rustbuffer_reserve( + RustBuffer buf, + int32_t additional, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_continuation_callback_set( + RustFutureContinuation callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_u8( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_u8( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_u8( + void* handle, + RustCallStatus* out_status +); + +uint8_t ffi_rustpushgo_rust_future_complete_u8( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_i8( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_i8( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_i8( + void* handle, + RustCallStatus* out_status +); + +int8_t ffi_rustpushgo_rust_future_complete_i8( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_u16( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_u16( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_u16( + void* handle, + RustCallStatus* out_status +); + +uint16_t ffi_rustpushgo_rust_future_complete_u16( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_i16( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_i16( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_i16( + void* handle, + RustCallStatus* out_status +); + +int16_t ffi_rustpushgo_rust_future_complete_i16( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_u32( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_u32( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_u32( + void* handle, + RustCallStatus* out_status +); + +uint32_t ffi_rustpushgo_rust_future_complete_u32( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_i32( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_i32( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_i32( + void* handle, + RustCallStatus* out_status +); + +int32_t ffi_rustpushgo_rust_future_complete_i32( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_u64( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_u64( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_u64( + void* handle, + RustCallStatus* out_status +); + +uint64_t ffi_rustpushgo_rust_future_complete_u64( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_i64( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_i64( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_i64( + void* handle, + RustCallStatus* out_status +); + +int64_t ffi_rustpushgo_rust_future_complete_i64( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_f32( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_f32( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_f32( + void* handle, + RustCallStatus* out_status +); + +float ffi_rustpushgo_rust_future_complete_f32( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_f64( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_f64( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_f64( + void* handle, + RustCallStatus* out_status +); + +double ffi_rustpushgo_rust_future_complete_f64( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_pointer( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_pointer( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_pointer( + void* handle, + RustCallStatus* out_status +); + +void* ffi_rustpushgo_rust_future_complete_pointer( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_rust_buffer( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_rust_buffer( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_rust_buffer( + void* handle, + RustCallStatus* out_status +); + +RustBuffer ffi_rustpushgo_rust_future_complete_rust_buffer( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_poll_void( + void* handle, + void* uniffi_callback, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_cancel_void( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_free_void( + void* handle, + RustCallStatus* out_status +); + +void ffi_rustpushgo_rust_future_complete_void( + void* handle, + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_connect( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_create_config_from_hardware_key( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_create_config_from_hardware_key_with_device_id( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_create_local_macos_config( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_create_local_macos_config_with_device_id( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_ford_key_cache_size( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_init_logger( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_login_start( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_new_client( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_register_ford_key( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_func_restore_token_provider( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_diag_full_count( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_download_attachment( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_download_attachment_avid( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_download_group_photo( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_dump_chats_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_fetch_recent_messages( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_query_attachments_fallback( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_supports_avid_download( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_sync_attachments( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_sync_chats( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_cloud_sync_messages( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_debug_recoverable_zones( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_delete_cloud_chats( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_delete_cloud_messages( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_fetch_profile( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_findmy_friends_import( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_findmy_friends_refresh_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_findmy_phone_refresh_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_get_contacts_url( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_get_dsid( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_get_facetime_client( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_get_findmy_client( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_get_handles( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_get_icloud_auth_headers( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_get_passwords_client( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_get_sharedstreams_client( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_get_statuskit_client( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_init_statuskit( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_invite_to_status_sharing( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_list_recoverable_chats( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_list_recoverable_message_guids( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_purge_recoverable_zones( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_reset_cloud_client( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_reset_statuskit_cursors( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_resolve_handle( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_resolve_handle_cached( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_restore_cloud_chat( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_attachment( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_change_participants( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_delivery_receipt( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_edit( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_error_message( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_icon_change( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_icon_clear( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_mark_unread( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_message( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_message_read_on_device( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_move_to_recycle_bin( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_notify_anyways( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_peer_cache_invalidate( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_permanent_delete_chat( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_permanent_delete_messages( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_read_receipt( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_recover_chat( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_rename_group( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_set_transcript_background( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_share_profile( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_sms_activation( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_sms_confirm_sent( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_tapback( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_typing( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_typing_with_app( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_unschedule( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_unsend( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_update_extension( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_update_profile( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_send_update_profile_sharing( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_set_status( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_stop( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_subscribe_to_status( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_test_cloud_messages( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_unsubscribe_all_status( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_client_validate_targets( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_loginsession_finish( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_loginsession_needs_2fa( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_loginsession_submit_2fa( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedapsconnection_state( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedapsstate_to_string( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_add_members( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_bind_bridge_link_to_session( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_clear_links( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_create_session( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_create_session_no_ring( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_delete_link( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_export_state_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_get_link_for_usage( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_get_session_link( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_list_delegated_letmein_requests( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_register_pending_ring( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_remove_members( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_respond_delegated_letmein( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_ring( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfacetimeclient_use_link_for( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfindmyclient_accept_item_share( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfindmyclient_delete_shared_item( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfindmyclient_export_state_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfindmyclient_sync_item_positions( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedfindmyclient_update_beacon_name( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedidsngmidentity_to_string( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedidsusers_get_handles( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedidsusers_login_id( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedidsusers_to_string( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedidsusers_validate_keystore( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedosconfig_get_device_id( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedosconfig_requires_nac_relay( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_accept_invite( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_create_group( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_decline_invite( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_delete_password_raw_entry( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_export_state_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_get_password_site_counts( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_invite_user( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_list_password_raw_entry_refs( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_query_handle( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_remove_group( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_remove_user( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_rename_group( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_sync_passwords( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedpasswordsclient_upsert_password_raw_entry( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_delete_assets( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_download_file( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_export_state_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_album_assets( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_album_summary( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_assets_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_get_changes( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_list_album_ids( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_list_albums( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_subscribe( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_subscribe_token( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedsharedstreamsclient_unsubscribe( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_clear_interest_tokens( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_export_state_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_get_known_handles( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_invite_to_channel( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_request_handles( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_reset_keys( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_roll_keys( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedstatuskitclient_share_status( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_contacts_url( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_dsid( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_escrow_devices( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_icloud_auth_headers( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedtokenprovider_get_mme_delegate_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedtokenprovider_join_keychain_clique( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedtokenprovider_join_keychain_clique_for_device( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_wrappedtokenprovider_seed_mme_delegate_json( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_constructor_wrappedapsstate_new( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_constructor_wrappedidsngmidentity_new( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_constructor_wrappedidsusers_new( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_messagecallback_on_message( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_statuscallback_on_status_update( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_statuscallback_on_keys_received( + RustCallStatus* out_status +); + +uint16_t uniffi_rustpushgo_checksum_method_updateuserscallback_update_users( + RustCallStatus* out_status +); + +uint32_t ffi_rustpushgo_uniffi_contract_version( + RustCallStatus* out_status +); + + +int32_t rustpushgo_cgo_MessageCallback(uint64_t, int32_t, uint8_t *, int32_t, RustBuffer *); +int32_t rustpushgo_cgo_StatusCallback(uint64_t, int32_t, uint8_t *, int32_t, RustBuffer *); +int32_t rustpushgo_cgo_UpdateUsersCallback(uint64_t, int32_t, uint8_t *, int32_t, RustBuffer *); + diff --git a/pkg/rustpushgo/src/anisette.rs b/pkg/rustpushgo/src/anisette.rs new file mode 100644 index 00000000..d6e878c8 --- /dev/null +++ b/pkg/rustpushgo/src/anisette.rs @@ -0,0 +1,155 @@ +//! Linux anisette wrapper around upstream's `RemoteAnisetteProviderV3`. +//! +//! Upstream's provisioning has three bugs we work around here: +//! 1. The `ProvisionInput` enum is missing `EndProvisioningError`, so a +//! transient Apple rejection crashes serde instead of returning an error. +//! 2. The provision() loop (`let Some(Ok(data)) = ... else { continue }`) +//! spins forever if the WebSocket stream closes. +//! 3. `get_anisette_headers` contains a bare `panic!()` for any +//! non-`AnisetteNotProvisioned` error from `get_headers` (see +//! `remote_anisette_v3.rs:417`). If that panic unwinds across the +//! uniffi FFI boundary while the caller holds the shared +//! `tokio::sync::Mutex<anisette>` (TokenProvider, CloudKitClient, +//! KeychainClient all share it), every subsequent anisette-touching +//! operation deadlocks — including message send. +//! +//! This wrapper catches those failures, cleans state, retries, and adds a +//! timeout. All Apple-facing requests go through upstream's code unchanged. + +use std::collections::HashMap; +use std::panic::AssertUnwindSafe; +use std::path::PathBuf; +use std::time::Duration; + +use futures::FutureExt; +use log::{info, warn}; +use omnisette::remote_anisette_v3::RemoteAnisetteProviderV3; +use omnisette::{AnisetteError, AnisetteProvider, LoginClientInfo}; + +const ANISETTE_URL: &str = "https://ani.sidestore.io"; +const PROVISION_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_RETRIES: usize = 3; + +pub struct BridgeAnisetteProvider { + info: LoginClientInfo, + state_path: PathBuf, +} + +impl BridgeAnisetteProvider { + pub fn new(info: LoginClientInfo, state_path: PathBuf) -> Self { + Self { info, state_path } + } + + /// Delete the cached anisette state so the next attempt provisions fresh. + fn clear_state(&self) { + let p = self.state_path.join("state.plist"); + if p.exists() { + if let Err(e) = std::fs::remove_file(&p) { + warn!("anisette: failed to remove stale state: {}", e); + } else { + info!("anisette: cleared stale state for retry"); + } + } + } +} + +impl AnisetteProvider for BridgeAnisetteProvider { + fn get_anisette_headers( + &mut self, + ) -> impl std::future::Future<Output = Result<HashMap<String, String>, AnisetteError>> + Send + { + async move { + let mut last_err = None; + + for attempt in 0..MAX_RETRIES { + // Fresh upstream provider each attempt — it reads state from + // disk so a cleared state.plist forces re-provisioning. + let mut upstream = RemoteAnisetteProviderV3::new( + ANISETTE_URL.to_string(), + self.info.clone(), + self.state_path.clone(), + ); + + // AssertUnwindSafe + catch_unwind turns upstream's bare + // `panic!()` into a caught panic payload. Without this the + // panic unwinds into the caller's critical section and can + // leave shared mutexes locked. + let inner = AssertUnwindSafe(upstream.get_anisette_headers()).catch_unwind(); + match tokio::time::timeout(PROVISION_TIMEOUT, inner).await { + Ok(Ok(Ok(headers))) => return Ok(headers), + Ok(Ok(Err(AnisetteError::SerdeError(ref e)))) => { + // Upstream's ProvisionInput enum is missing variants + // (e.g. EndProvisioningError). Clear state and retry — + // the rejection may be transient. + warn!( + "anisette: upstream serde error on attempt {}/{}: {}", + attempt + 1, + MAX_RETRIES, + e + ); + self.clear_state(); + last_err = Some(AnisetteError::InvalidArgument(format!( + "Anisette provisioning was rejected by the server \ + (attempt {}/{}). Error: {}", + attempt + 1, + MAX_RETRIES, + e + ))); + } + Ok(Ok(Err(e))) => { + // Non-serde error — don't retry blindly. + return Err(e); + } + Ok(Err(panic_payload)) => { + // Upstream `RemoteAnisetteProviderV3::get_anisette_headers` + // contains `panic!()` for non-`AnisetteNotProvisioned` + // errors. Convert to a retryable error so the panic + // doesn't unwind past this point. + let msg = if let Some(s) = panic_payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = panic_payload.downcast_ref::<String>() { + s.clone() + } else { + "unknown panic payload".into() + }; + warn!( + "anisette: upstream panicked on attempt {}/{}: {}", + attempt + 1, + MAX_RETRIES, + msg + ); + self.clear_state(); + last_err = Some(AnisetteError::InvalidArgument(format!( + "Anisette call panicked (attempt {}/{}): {}", + attempt + 1, + MAX_RETRIES, + msg + ))); + } + Err(_) => { + // Timeout — likely the upstream infinite-loop bug. + warn!( + "anisette: upstream timed out on attempt {}/{} \ + (likely infinite loop on WS drop)", + attempt + 1, + MAX_RETRIES, + ); + self.clear_state(); + last_err = Some(AnisetteError::InvalidArgument( + format!( + "Anisette provisioning timed out (attempt {}/{}). \ + The anisette server (ani.sidestore.io) may be down.", + attempt + 1, + MAX_RETRIES, + ), + )); + } + } + } + + Err(last_err.unwrap_or_else(|| { + AnisetteError::InvalidArgument("Anisette provisioning failed".into()) + })) + } + } +} diff --git a/pkg/rustpushgo/src/hardware_info.h b/pkg/rustpushgo/src/hardware_info.h new file mode 100644 index 00000000..91998b26 --- /dev/null +++ b/pkg/rustpushgo/src/hardware_info.h @@ -0,0 +1,30 @@ +#ifndef HARDWARE_INFO_H +#define HARDWARE_INFO_H + +#include <stdint.h> +#include <stddef.h> + +typedef struct { + char *product_name; // e.g., "Mac15,3" + char *serial_number; // e.g., "C02XX..." + char *platform_uuid; // hardware UUID + char *board_id; // e.g., "Mac-..." + char *os_build_num; // e.g., "25B78" + char *os_version; // e.g., "26.1" + uint8_t *rom; // EFI ROM + size_t rom_len; + char *mlb; // Main Logic Board serial + uint8_t *mac_address; // 6-byte MAC + size_t mac_address_len; + char *root_disk_uuid; // root volume UUID + char *darwin_version; // e.g., "24.3.0" (from uname) + char *error; // set on failure +} HardwareInfo; + +/// Read hardware identifiers from IOKit/sysctl. Caller must call hw_info_free(). +HardwareInfo hw_info_read(void); + +/// Free a HardwareInfo's allocated strings/buffers. +void hw_info_free(HardwareInfo *info); + +#endif diff --git a/pkg/rustpushgo/src/hardware_info.m b/pkg/rustpushgo/src/hardware_info.m new file mode 100644 index 00000000..abdb208d --- /dev/null +++ b/pkg/rustpushgo/src/hardware_info.m @@ -0,0 +1,233 @@ +/** + * hardware_info.m — Read Mac hardware identifiers from IOKit for iMessage registration. + * + * Provides: model, serial number, platform UUID, board ID, ROM, MLB, MAC address, + * root disk UUID, OS build number, and OS version. + */ + +#import <Foundation/Foundation.h> +#import <IOKit/IOKitLib.h> +#import <sys/sysctl.h> +#import <sys/mount.h> +#include "hardware_info.h" + +// ---- IOKit helpers ---- + +static char *iokit_string(io_service_t service, CFStringRef key) { + CFTypeRef ref = IORegistryEntryCreateCFProperty(service, key, kCFAllocatorDefault, 0); + if (!ref) return NULL; + char *result = NULL; + if (CFGetTypeID(ref) == CFStringGetTypeID()) { + const char *utf8 = CFStringGetCStringPtr(ref, kCFStringEncodingUTF8); + if (utf8) { + result = strdup(utf8); + } else { + char buf[256]; + if (CFStringGetCString(ref, buf, sizeof(buf), kCFStringEncodingUTF8)) { + result = strdup(buf); + } + } + } + CFRelease(ref); + return result; +} + +static uint8_t *iokit_data(io_service_t service, CFStringRef key, size_t *out_len) { + CFTypeRef ref = IORegistryEntryCreateCFProperty(service, key, kCFAllocatorDefault, 0); + if (!ref) { *out_len = 0; return NULL; } + uint8_t *result = NULL; + if (CFGetTypeID(ref) == CFDataGetTypeID()) { + CFDataRef data = (CFDataRef)ref; + CFIndex len = CFDataGetLength(data); + result = (uint8_t *)malloc(len); + CFDataGetBytes(data, CFRangeMake(0, len), result); + *out_len = (size_t)len; + } else { + *out_len = 0; + } + CFRelease(ref); + return result; +} + +// ---- Main ---- + +HardwareInfo hw_info_read(void) { + HardwareInfo info = {0}; + + // --- Platform expert (serial, UUID, model, board ID) --- + io_service_t platformExpert = IOServiceGetMatchingService( + kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")); + + if (!platformExpert) { + info.error = strdup("Failed to find IOPlatformExpertDevice"); + return info; + } + + info.serial_number = iokit_string(platformExpert, CFSTR("IOPlatformSerialNumber")); + info.platform_uuid = iokit_string(platformExpert, CFSTR("IOPlatformUUID")); + info.board_id = iokit_string(platformExpert, CFSTR("board-id")); + info.product_name = iokit_string(platformExpert, CFSTR("model")); + + // Fallback for product_name via sysctl + if (!info.product_name) { + char model[64] = {0}; + size_t len = sizeof(model); + if (sysctlbyname("hw.model", model, &len, NULL, 0) == 0) { + info.product_name = strdup(model); + } + } + + // --- MLB (Main Logic Board serial) --- + // On Apple Silicon, mlb-serial-number is on the platform expert as raw data with trailing NULLs + { + size_t mlbDataLen = 0; + uint8_t *mlbData = iokit_data(platformExpert, CFSTR("mlb-serial-number"), &mlbDataLen); + if (mlbData && mlbDataLen > 0) { + // Strip trailing NUL bytes + size_t realLen = mlbDataLen; + while (realLen > 0 && mlbData[realLen - 1] == 0) realLen--; + if (realLen > 0) { + info.mlb = strndup((char *)mlbData, realLen); + } + free(mlbData); + } + } + + IOObjectRelease(platformExpert); + + // --- EFI ROM --- + // On Intel: IODeviceTree:/options. On Apple Silicon: may not exist. + // Try NVRAM first, fall back to MAC address as ROM (common for AS Macs). + io_registry_entry_t options = IORegistryEntryFromPath( + kIOMainPortDefault, "IODeviceTree:/options"); + + if (options != MACH_PORT_NULL) { + info.rom = iokit_data(options, CFSTR("4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:ROM"), &info.rom_len); + if (!info.rom || info.rom_len == 0) { + info.rom = iokit_data(options, CFSTR("ROM"), &info.rom_len); + } + if (!info.mlb) { + info.mlb = iokit_string(options, CFSTR("4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:MLB")); + } + if (!info.mlb) { + info.mlb = iokit_string(options, CFSTR("MLB")); + } + IOObjectRelease(options); + } + + // --- MAC address (en0) --- + CFMutableDictionaryRef matchDict = IOServiceMatching("IOEthernetInterface"); + io_iterator_t iterator; + if (IOServiceGetMatchingServices(kIOMainPortDefault, matchDict, &iterator) == KERN_SUCCESS) { + io_service_t service; + while ((service = IOIteratorNext(iterator)) != 0) { + // Check if this is en0 (primary) + CFTypeRef bsdName = IORegistryEntryCreateCFProperty(service, CFSTR("BSD Name"), kCFAllocatorDefault, 0); + BOOL isPrimary = NO; + if (bsdName && CFGetTypeID(bsdName) == CFStringGetTypeID()) { + isPrimary = CFStringCompare(bsdName, CFSTR("en0"), 0) == kCFCompareEqualTo; + } + if (bsdName) CFRelease(bsdName); + + if (isPrimary) { + // Get MAC from parent (IOEthernetController) + io_service_t parent; + if (IORegistryEntryGetParentEntry(service, kIOServicePlane, &parent) == KERN_SUCCESS) { + info.mac_address = iokit_data(parent, CFSTR("IOMACAddress"), &info.mac_address_len); + IOObjectRelease(parent); + } + IOObjectRelease(service); + break; + } + IOObjectRelease(service); + } + IOObjectRelease(iterator); + } + + // --- ROM fallback for Apple Silicon: use MAC address --- + if ((!info.rom || info.rom_len == 0) && info.mac_address && info.mac_address_len == 6) { + info.rom_len = 6; + info.rom = (uint8_t *)malloc(6); + memcpy(info.rom, info.mac_address, 6); + } + + // --- Root disk UUID --- + // Use DiskArbitration or IOKit. Simpler: parse from `diskutil info /` or IOKit. + // For now, read from IOKit APFS container + io_service_t mediaService = IOServiceGetMatchingService( + kIOMainPortDefault, IOServiceMatching("IOMediaBSDClient")); + if (mediaService) { + // This doesn't directly give us the root UUID. Use a different approach. + IOObjectRelease(mediaService); + } + // Fallback: use a fixed approach with sysctl or getfsstat + { + struct statfs sfs; + if (statfs("/", &sfs) == 0) { + // sfs.f_mntfromname is like "/dev/disk3s1s1" + // Get UUID via DADiskCreateFromBSDName... but that requires DiskArbitration. + // Simpler: use IOKit to find the matching media + char *bsdDisk = sfs.f_mntfromname; + if (strncmp(bsdDisk, "/dev/", 5) == 0) bsdDisk += 5; + + // Strip trailing snapshot suffixes for APFS + char diskName[64]; + strncpy(diskName, bsdDisk, sizeof(diskName) - 1); + diskName[sizeof(diskName) - 1] = '\0'; + // Remove trailing 's' suffixes (e.g., disk3s1s1 → disk3s1) + // Actually we want the volume group UUID, not the partition + // For registration purposes, use the platform UUID as fallback + } + // Use platform UUID as root disk UUID fallback (common in Apple registration) + if (!info.root_disk_uuid && info.platform_uuid) { + info.root_disk_uuid = strdup(info.platform_uuid); + } + } + + // --- OS build number and version --- + { + char build[32] = {0}; + size_t len = sizeof(build); + if (sysctlbyname("kern.osversion", build, &len, NULL, 0) == 0) { + info.os_build_num = strdup(build); + } + } + // Get macOS version from NSProcessInfo + { + NSOperatingSystemVersion ver = [[NSProcessInfo processInfo] operatingSystemVersion]; + NSString *verStr = [NSString stringWithFormat:@"%ld.%ld", + (long)ver.majorVersion, (long)ver.minorVersion]; + if (ver.patchVersion > 0) { + verStr = [NSString stringWithFormat:@"%@.%ld", verStr, (long)ver.patchVersion]; + } + info.os_version = strdup([verStr UTF8String]); + } + + // --- Darwin/kernel version (e.g., "24.3.0" for macOS 15.x) --- + { + char release[32] = {0}; + size_t len = sizeof(release); + if (sysctlbyname("kern.osrelease", release, &len, NULL, 0) == 0) { + info.darwin_version = strdup(release); + } + } + + return info; +} + +void hw_info_free(HardwareInfo *info) { + if (!info) return; + free(info->product_name); + free(info->serial_number); + free(info->platform_uuid); + free(info->board_id); + free(info->os_build_num); + free(info->os_version); + free(info->rom); + free(info->mlb); + free(info->mac_address); + free(info->root_disk_uuid); + free(info->darwin_version); + free(info->error); + memset(info, 0, sizeof(HardwareInfo)); +} diff --git a/pkg/rustpushgo/src/lib.rs b/pkg/rustpushgo/src/lib.rs new file mode 100644 index 00000000..aa811707 --- /dev/null +++ b/pkg/rustpushgo/src/lib.rs @@ -0,0 +1,10856 @@ +pub mod util; +#[cfg(target_os = "macos")] +pub mod local_config; +#[cfg(not(target_os = "macos"))] +pub mod anisette; +#[cfg(test)] +mod test_hwinfo; + +use std::{collections::HashMap, io::Cursor, path::PathBuf, str::FromStr, sync::Arc, time::Duration, sync::atomic::{AtomicU64, Ordering}}; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use icloud_auth::AppleAccount; +use keystore::{init_keystore, keystore, software::{NoEncryptor, SoftwareKeystore, SoftwareKeystoreState}}; +use log::{debug, error, info, warn}; +use rustpush::{ + authenticate_apple, login_apple_delegates, register, APSConnectionResource, + APSState, Attachment, AttachmentType, ConversationData, DeleteTarget, EditMessage, + IDSNGMIdentity, IDSUser, IMClient, LoginDelegate, MADRID_SERVICE, MMCSFile, Message, + MessageInst, MessagePart, MessageParts, MessageType, MoveToRecycleBinMessage, NormalMessage, PermanentDeleteMessage, + OperatedChat, OSConfig, ReactMessage, ReactMessageType, Reaction, RenameMessage, + ChangeParticipantMessage, IconChangeMessage, UnsendMessage, TypingApp, + IndexedMessagePart, LinkMeta, LPLinkMetadata, NSURL, + TextFlags, TextFormat, TextEffect, + ShareProfileMessage, SharedPoster, PartExtension, UpdateExtensionMessage, UpdateProfileMessage, + UpdateProfileSharingMessage, SetTranscriptBackgroundMessage, + TokenProvider, + ScheduleMode, + cloudkit::{ZoneDeleteOperation, CloudKitSession}, + ResourceState, +}; +use rustpush::cloudkit_proto::request_operation::header::IsolationLevel; +use rustpush::facetime::{FACETIME_SERVICE, VIDEO_SERVICE}; +use rustpush::findmy::MULTIPLEX_SERVICE; + +use std::sync::RwLock; + +// ============================================================================ +// Anisette provider selection +// ============================================================================ +// +// `BridgeDefaultAnisetteProvider` is the concrete `AnisetteProvider` type used +// by every rustpush client that's parameterized over one (AppleAccount, +// KeychainClient, CloudKitClient, TokenProvider, etc.). On macOS this is +// upstream's `AOSKitAnisetteProvider` (native, untouched). On Linux this is +// our `anisette::BridgeAnisetteProvider`, which wraps upstream's +// `RemoteAnisetteProviderV3` with retry logic, a timeout (upstream's +// provision() loop spins forever on WS close), and error handling for +// upstream's missing `EndProvisioningError` serde variant. +#[cfg(target_os = "macos")] +pub type BridgeDefaultAnisetteProvider = omnisette::DefaultAnisetteProvider; +#[cfg(not(target_os = "macos"))] +pub type BridgeDefaultAnisetteProvider = anisette::BridgeAnisetteProvider; + +#[cfg(target_os = "macos")] +fn bridge_default_provider( + info: omnisette::LoginClientInfo, + path: PathBuf, +) -> omnisette::ArcAnisetteClient<BridgeDefaultAnisetteProvider> { + omnisette::default_provider(info, path) +} +#[cfg(not(target_os = "macos"))] +fn bridge_default_provider( + info: omnisette::LoginClientInfo, + path: PathBuf, +) -> omnisette::ArcAnisetteClient<BridgeDefaultAnisetteProvider> { + std::sync::Arc::new(tokio::sync::Mutex::new(omnisette::AnisetteClient::new( + anisette::BridgeAnisetteProvider::new(info, path), + ))) +} +use tokio::sync::broadcast; +use util::{plist_from_string, plist_to_string}; + +// Local helpers to replace rustpush's private `util::{base64_decode, encode_hex, ...}`. +// Part of the zero-patch refactor: `rustpush::util` is private in upstream, so we +// reimplement the same semantics (infallible on invalid base64/hex input) using the +// `base64` and `hex` crates directly. +#[inline] +fn base64_decode(s: &str) -> Vec<u8> { + BASE64_STANDARD.decode(s).unwrap_or_default() +} +#[inline] +fn base64_encode(data: &[u8]) -> String { + BASE64_STANDARD.encode(data) +} +#[inline] +fn encode_hex(bytes: &[u8]) -> String { + hex::encode(bytes) +} +#[inline] +fn decode_hex(s: &str) -> Result<Vec<u8>, hex::FromHexError> { + hex::decode(s) +} + +// ============================================================================ +// Ford key cache (wrapper-level reimplementation of the 94f7b8e fix) +// ============================================================================ +// +// CloudKit videos are Ford-encrypted: each record carries a 32-byte Ford key +// in `lqa.protection_info.protection_info`, and MMCS deduplicates identical +// content at the storage layer. When the same video is uploaded twice, MMCS +// returns ONE encrypted blob encrypted with the original uploader's key — so +// the second record's own Ford key cannot SIV-decrypt it, and upstream +// rustpush's `get_mmcs` panics on the `.unwrap()` of the SIV result. +// +// This cache holds every Ford key the bridge has ever seen (populated during +// CloudKit attachment sync). On a SIV panic during download, the wrapper +// catches the panic and retries `container.get_assets(...)` with each cached +// key in turn by mutating `Asset.protection_info.protection_info` before the +// call. This matches the semantics of the original in-rustpush fix without +// touching upstream source. + +fn ford_key_cache() -> &'static std::sync::Mutex<HashMap<Vec<u8>, Vec<u8>>> { + static CACHE: std::sync::OnceLock<std::sync::Mutex<HashMap<Vec<u8>, Vec<u8>>>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +/// Register a Ford key in the process-wide cache so that a later deduplicated +/// download can recover from an SIV decrypt failure. Key is indexed by +/// `fordChecksum = 0x01 || SHA1(key)`. Idempotent; no-op for empty input. +#[uniffi::export] +pub fn register_ford_key(key: Vec<u8>) { + if key.is_empty() { + return; + } + use sha1::{Digest, Sha1}; + let mut hasher = Sha1::new(); + hasher.update(&key); + let sha = hasher.finalize(); + let mut checksum = Vec::with_capacity(1 + sha.len()); + checksum.push(0x01); + checksum.extend_from_slice(&sha); + let mut cache = match ford_key_cache().lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if cache.insert(checksum, key).is_some() { + return; + } + let size = cache.len(); + drop(cache); + debug!("register_ford_key: cached Ford key for dedup fallback (size={})", size); +} + +/// Number of Ford keys currently cached. Diagnostic only. +#[uniffi::export] +pub fn ford_key_cache_size() -> u64 { + let cache = match ford_key_cache().lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + cache.len() as u64 +} + +/// Snapshot of all cached Ford keys, used by the download recovery path. +fn ford_key_cache_values() -> Vec<Vec<u8>> { + let cache = match ford_key_cache().lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + cache.values().cloned().collect() +} + +/// Check whether a given asset is Ford-encrypted by parsing the CloudKit +/// `AssetGetResponse` body (which is a `mmcsp::AuthorizeGetResponse` proto) +/// and looking up the `wanted_chunks` entry for the asset's file_checksum. +/// If that entry has a `ford_reference`, the asset uses Ford encryption +/// (V2 / videos). If not, it's V1 per-chunk encryption (images, etc.) and +/// the Ford dedup recovery path shouldn't run for it. +/// +/// Returns `true` ONLY when we've proven the asset is Ford-encrypted. +/// Returns `false` if the body can't be parsed, the checksum isn't found, +/// or `ford_reference` is None — anything ambiguous is treated as non-Ford +/// to avoid burning retries on records recovery can't help. +fn is_ford_encrypted_asset( + asset_responses: &[rustpush::cloudkit_proto::AssetGetResponse], + asset: &rustpush::cloudkit_proto::Asset, +) -> bool { + use prost::Message; + + let Some(bundled_id) = asset.bundled_request_id.as_ref() else { + return false; + }; + let Some(signature) = asset.signature.as_ref() else { + return false; + }; + let Some(response) = asset_responses + .iter() + .find(|r| r.asset_id.as_ref() == Some(bundled_id)) + else { + return false; + }; + let Some(body) = response.body.as_ref() else { + return false; + }; + + let auth_response = match rustpush::mmcsp::AuthorizeGetResponse::decode(&body[..]) { + Ok(r) => r, + Err(_) => return false, + }; + let Some(f1) = auth_response.f1.as_ref() else { + return false; + }; + // Find our file's `wanted_chunks` entry by matching file_checksum + // (= the asset signature). If it has a ford_reference, the server is + // telling us this asset's chunks are Ford-wrapped and the Ford key + // lives in the asset's protection_info. + f1.references + .iter() + .find(|w| &w.file_checksum == signature) + .and_then(|w| w.ford_reference.as_ref()) + .is_some() +} + +// ============================================================================ +// Manual Ford download (V1 + V2 support — zero-patch workaround for upstream) +// ============================================================================ +// +// Upstream rustpush's `get_mmcs` supports V1 Ford (`FordItem` with a flat +// `chunks: Vec<FordChunkItem>`) only. When Apple's CloudKit serves a +// V2 Ford blob (`FordItemV2` with grouped chunks) — which master's fork +// supports via an extended proto — upstream panics unconditionally at +// `chunks.item.expect("Ford chunks missing?")` (mmcs.rs:1117). The panic +// is post-SIV, so no amount of key brute-forcing in the wrapper can +// recover: upstream crashes before it ever tries to extract per-chunk +// keys. +// +// This module reimplements the Ford download path entirely at the +// wrapper layer using upstream's public primitives: +// - `rustpush::mmcsp::AuthorizeGetResponse` (the proto bytes we get +// back from the CloudKit AssetGetResponse body) +// - `rustpush::mmcs::transfer_mmcs_container` (the public HTTP fetch) +// - local prost types for `LocalFordChunk` that understand BOTH V1 +// and V2 layouts (fields 1 and 2 of the wire format) +// - manual SIV decrypt loop (HKDF + CmacSiv) over every cached Ford +// key, because the dedup case means the record's OWN key isn't +// necessarily the right one +// - manual V2 chunk decrypt (AES-256-CTR + HKDF + HMAC verify) +// - manual V1 chunk decrypt (AES-128-CFB) +// +// The control flow is: upstream's `container.get_assets` is still the +// happy path (fast, no panic on V1 correct-key records). Only when that +// panics or errors do we fall through to `manual_ford_download_asset`. +// That function handles dedup, V2 Ford, V1 Ford, AND plain V1 chunks +// all via the same code path, so recovery is a superset of upstream's +// capabilities. + +mod manual_ford { + use super::*; + use aes_siv::{siv::CmacSiv, KeyInit}; + use aes::Aes256; + use hkdf::Hkdf; + use once_cell::sync::Lazy; + use openssl::{ + hash::MessageDigest, + pkey::PKey, + sign::Signer, + symm::{decrypt as openssl_decrypt, Cipher}, + }; + use prost::Message; + use sha2::{Digest as Sha2Digest, Sha256}; + + /// Shared reqwest client for HTTP container fetches. We can't use + /// upstream's private `REQWEST` static, so we maintain our own. + pub(super) static HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| { + reqwest::Client::builder() + .gzip(true) + .timeout(Duration::from_secs(120)) + .build() + .expect("reqwest client build") + }); + + /// Local FordChunk message. Upstream's `mmcsp::FordChunk` only has + /// field 1 (`item: FordItem`) because upstream's proto doesn't know + /// about V2. We decode the same bytes through this local type to get + /// access to the V2 `item_v2` field (tag 2), which carries chunks + /// grouped by size with multiple keys per group. + /// + /// Since prost decoders skip unknown fields by default, decoding the + /// same wire bytes through either type works regardless of which + /// version the server actually sent. Presence of `item`/`item_v2` + /// tells us which layout we got. + #[derive(Clone, PartialEq, Message)] + pub(super) struct LocalFordChunk { + #[prost(message, optional, tag = "1")] + pub item: Option<LocalFordItem>, + #[prost(message, optional, tag = "2")] + pub item_v2: Option<LocalFordItemV2>, + } + + #[derive(Clone, PartialEq, Message)] + pub(super) struct LocalFordItem { + #[prost(message, repeated, tag = "1")] + pub chunks: Vec<LocalFordChunkItem>, + #[prost(bytes = "vec", tag = "2")] + pub checksum: Vec<u8>, + } + + #[derive(Clone, PartialEq, Message)] + pub(super) struct LocalFordChunkItem { + #[prost(bytes = "vec", tag = "1")] + pub key: Vec<u8>, + #[prost(bytes = "vec", tag = "2")] + pub chunk_len: Vec<u8>, + } + + #[derive(Clone, PartialEq, Message)] + pub(super) struct LocalFordItemV2 { + #[prost(bytes = "vec", tag = "1")] + pub checksum: Vec<u8>, + #[prost(message, repeated, tag = "2")] + pub chunks: Vec<LocalFordChunkGroup>, + } + + #[derive(Clone, PartialEq, Message)] + pub(super) struct LocalFordChunkGroup { + #[prost(bytes = "vec", tag = "1")] + pub chunk_len: Vec<u8>, + #[prost(message, repeated, tag = "2")] + pub keys: Vec<LocalFordKeyWrapper>, + } + + #[derive(Clone, PartialEq, Message)] + pub(super) struct LocalFordKeyWrapper { + #[prost(bytes = "vec", tag = "1")] + pub key: Vec<u8>, + } + + /// Flatten a LocalFordChunk (V1 or V2) into a sequence of + /// `(ford_key, chunk_len_bytes)` pairs, one per data chunk, in the + /// same order as the file's `wanted_chunks.chunk_references`. + pub(super) fn flatten_ford_entries(chunk: LocalFordChunk) -> Option<Vec<(Vec<u8>, Vec<u8>)>> { + if let Some(item) = chunk.item { + Some(item.chunks.into_iter().map(|c| (c.key, c.chunk_len)).collect()) + } else if let Some(item_v2) = chunk.item_v2 { + let mut out = Vec::new(); + for group in item_v2.chunks { + let chunk_len = group.chunk_len.clone(); + for kw in group.keys { + out.push((kw.key, chunk_len.clone())); + } + } + Some(out) + } else { + None + } + } + + /// Attempt a single SIV decrypt with the given key over the given + /// Ford blob. Returns None on failure (wrong key). Matches master's + /// `try_ford_siv` exactly: HKDF("PCSMMCS2", key) → expand 64 bytes → + /// CmacSiv::decrypt with headers [iv, version_byte]. + pub(super) fn try_ford_siv(key: &[u8], ford_blob: &[u8]) -> Option<Vec<u8>> { + if ford_blob.len() < 17 { + return None; + } + let hk = Hkdf::<Sha256>::new(Some(b"PCSMMCS2"), key); + let mut result = [0u8; 64]; + if hk.expand(&[], &mut result).is_err() { + return None; + } + let cipher = match CmacSiv::<Aes256>::new_from_slice(&result) { + Ok(c) => c, + Err(_) => return None, + }; + // Wrap in catch_unwind — CmacSiv's decrypt has been observed to + // panic on malformed blobs in some aes-siv versions. Defensive. + let blob = ford_blob.to_vec(); + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let mut cipher = cipher; + cipher + .decrypt::<&[&[u8]], &&[u8]>(&[&blob[1..17], &blob[..1]], &blob[17..]) + .ok() + })); + match res { + Ok(Some(plaintext)) => Some(plaintext), + _ => None, + } + } + + /// Try every cached Ford key against the blob. The correct key for a + /// dedup'd upload is often from a DIFFERENT record than the one we're + /// currently downloading, so "trying the record's own key" is never + /// enough. Recency-first ordering makes the happy case (dedup with + /// recently-seen source) fast. + pub(super) fn ford_siv_retry( + record_own_key: &[u8], + ford_checksum: &[u8], + ford_blob: &[u8], + record_name: &str, + ) -> Option<Vec<u8>> { + // 1. The record's own declared key — usually the right one for + // non-dedup'd uploads. + if let Some(d) = try_ford_siv(record_own_key, ford_blob) { + return Some(d); + } + + // 2. fordChecksum direct lookup — the server gave us a chunk + // reference checksum that identifies the original uploader's + // key. If we've seen that key in a prior batch, we have an + // exact-match hit. + if !ford_checksum.is_empty() { + let hit = { + let cache = ford_key_cache().lock().unwrap_or_else(|p| p.into_inner()); + cache.get(ford_checksum).cloned() + }; + if let Some(alt) = hit { + if let Some(d) = try_ford_siv(&alt, ford_blob) { + info!( + "manual_ford_download {}: SIV succeeded via fordChecksum cache hit", + record_name + ); + return Some(d); + } + } + } + + // 3. Brute-force: try every cached key. The cross-batch dedup + // case requires this — the right key can be from any record + // the account has ever seen. + let all_keys = ford_key_cache_values(); + let total = all_keys.len(); + for (idx, alt) in all_keys.iter().enumerate() { + if let Some(d) = try_ford_siv(alt, ford_blob) { + info!( + "manual_ford_download {}: SIV succeeded via brute-force (attempt {}/{})", + record_name, + idx + 1, + total + ); + return Some(d); + } + } + + None + } + + /// V2 chunk decrypt — ported from upstream's private `ChunkDesc::decrypt` + /// at mmcs.rs:696. HKDF(key[1..]) → expand 0x60 bytes → split into + /// (sig_hmac [0..32], auth_hmac [32..64], aes_key [64..96]). IV is + /// HMAC(auth_hmac, chunk_id_padded_40)[..16]. Decrypts AES-256-CTR, + /// truncates to declared length, verifies sig HMAC. + /// + /// Returns `Err` on HMAC mismatch (wrong key or corrupt data) — the + /// upstream version has `assert_eq!` there, which panics; we return + /// a typed error instead so the caller can fall through. + pub(super) fn decrypt_v2_chunk( + key33: &[u8], + chunk_len_bytes: &[u8], + chunk_id21: &[u8; 21], + data: &[u8], + ) -> Result<Vec<u8>, String> { + if key33.len() != 33 { + return Err(format!("V2 key wrong length: {}", key33.len())); + } + if chunk_len_bytes.len() != 4 { + return Err(format!("V2 chunk_len wrong length: {}", chunk_len_bytes.len())); + } + + let hk = Hkdf::<Sha256>::new(None, &key33[1..]); + let mut expanded = [0u8; 0x60]; + hk.expand(b"signature-key", &mut expanded) + .map_err(|e| format!("V2 HKDF expand: {e}"))?; + + let auth_hmac_key = &expanded[0x20..0x40]; + let aes_key = &expanded[0x40..0x60]; + let sig_hmac_key = &expanded[0x00..0x20]; + + // IV construction: chunk_id[1..] (20 bytes) padded to 40 bytes, + // with data length (little-endian u32) at offset [32..36]. + let mut id_padded = [0u8; 40]; + id_padded[..20].copy_from_slice(&chunk_id21[1..]); + id_padded[32..36].copy_from_slice(&(data.len() as u32).to_le_bytes()); + + let auth_pkey = PKey::hmac(auth_hmac_key) + .map_err(|e| format!("V2 auth PKey: {e}"))?; + let mut signer = Signer::new(MessageDigest::sha256(), &auth_pkey) + .map_err(|e| format!("V2 auth Signer: {e}"))?; + let iv_material = signer + .sign_oneshot_to_vec(&id_padded) + .map_err(|e| format!("V2 IV sign: {e}"))?; + let iv = &iv_material[..16]; + + let mut result = openssl_decrypt(Cipher::aes_256_ctr(), aes_key, Some(iv), data) + .map_err(|e| format!("V2 AES-CTR decrypt: {e}"))?; + + let length = u32::from_le_bytes( + chunk_len_bytes + .try_into() + .map_err(|_| "chunk_len bytes->[u8;4]")?, + ) as usize; + result.resize(length, 0); + + // HMAC verify — if the decrypted chunk doesn't authenticate, the + // wrong key was used. This is the "is this chunk really mine" + // check that lets us fall through to try another key. + let plaintext_hash = { + let mut h = Sha256::new(); + h.update(&result); + h.finalize() + }; + let sig_pkey = PKey::hmac(sig_hmac_key) + .map_err(|e| format!("V2 sig PKey: {e}"))?; + let mut verifier = Signer::new(MessageDigest::sha256(), &sig_pkey) + .map_err(|e| format!("V2 sig Signer: {e}"))?; + let computed_id = verifier + .sign_oneshot_to_vec(&plaintext_hash) + .map_err(|e| format!("V2 sig sign: {e}"))?; + + if &computed_id[..chunk_id21.len() - 1] != &chunk_id21[1..] { + return Err("V2 chunk HMAC mismatch".to_string()); + } + + Ok(result) + } + + /// V1 chunk decrypt — AES-128-CFB with `key[1..]` as the 16-byte + /// key, no IV. Ported from upstream's mmcs.rs:695. + pub(super) fn decrypt_v1_chunk(key17: &[u8], data: &[u8]) -> Result<Vec<u8>, String> { + if key17.len() != 17 { + return Err(format!("V1 key wrong length: {}", key17.len())); + } + openssl_decrypt(Cipher::aes_128_cfb128(), &key17[1..], None, data) + .map_err(|e| format!("V1 AES-CFB decrypt: {e}")) + } + + /// Re-authorize a fresh AuthorizeGetResponse body directly from + /// MMCS. The cached body that came in the original AssetGetResponse + /// may be tied to a specific auth session that's since expired; on + /// the recovery path we want to re-issue the authorization to make + /// sure the container HTTP URLs are still valid. + /// + /// We don't currently re-authorize — the cached body from CloudKit + /// is usually still fresh (CloudKit authorization is bundled with + /// the AssetGetResponse). This helper is reserved for future use if + /// we start seeing auth expiry. + #[allow(dead_code)] + pub(super) fn noop_reauth() {} + + /// Fetch the entire HTTP body of one container. Returns bytes ready + /// to slice by offset. + pub(super) async fn fetch_container_body( + container: &rustpush::mmcsp::Container, + user_agent: &str, + ) -> Result<Vec<u8>, String> { + let req = container + .request + .as_ref() + .ok_or_else(|| "container has no HttpRequest".to_string())?; + let url = format!("{}://{}:{}{}", req.scheme, req.domain, req.port, req.path); + + let mut builder = match req.method.as_str() { + "GET" => HTTP_CLIENT.get(&url), + other => return Err(format!("unsupported MMCS method: {}", other)), + } + .header("x-apple-request-uuid", uuid::Uuid::new_v4().to_string().to_uppercase()) + .header("user-agent", user_agent); + + let complete_at_edge = req.headers.iter().any(|h| { + h.name == "x-apple-put-complete-at-edge-version" && h.value == "2" + }); + + for header in &req.headers { + if (header.name == "Content-Length" && complete_at_edge) || header.name == "Host" { + continue; + } + builder = builder.header(header.name.clone(), header.value.clone()); + } + + let resp = builder + .send() + .await + .map_err(|e| format!("MMCS fetch send: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("MMCS fetch HTTP {}", resp.status())); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| format!("MMCS fetch body: {e}"))?; + Ok(bytes.to_vec()) + } + + /// Manual Ford download of one asset. This is the V1+V2-capable + /// replacement for upstream's `get_assets` → `get_mmcs` chain. It + /// bypasses all of upstream's panicking code paths by reimplementing + /// the download primitives at this layer. + /// + /// Inputs: + /// - `asset_response`: the AuthorizeGetResponse body bytes from the + /// original CloudKit AssetGetResponse for this bundled_request_id + /// - `asset_signature`: `record.lqa.signature` — identifies our file + /// within the response's `references` list + /// - `asset_ford_key`: `record.lqa.protection_info.protection_info` + /// — the record's own declared Ford key (usually right, but not + /// always in the dedup case) + /// - `user_agent`: the CloudKit user agent string (for HTTP) + /// - `record_name`: diagnostic only + /// + /// Returns the decrypted file bytes. + pub(super) async fn manual_ford_download_asset( + asset_response_body: &[u8], + asset_signature: &[u8], + asset_ford_key: &[u8], + user_agent: &str, + record_name: &str, + ) -> Result<Vec<u8>, String> { + let response = rustpush::mmcsp::AuthorizeGetResponse::decode(asset_response_body) + .map_err(|e| format!("decode AuthorizeGetResponse: {e}"))?; + + let f1 = response.f1.ok_or_else(|| { + let reason = response + .error + .and_then(|e| e.f2) + .map(|f2| f2.reason) + .unwrap_or_default(); + format!("MMCS server returned error: {}", reason) + })?; + + // Find OUR file's chunk references + ford_reference. + let wanted = f1 + .references + .iter() + .find(|r| &r.file_checksum[..] == asset_signature) + .ok_or_else(|| { + format!( + "no references entry matches signature {}", + encode_hex(asset_signature) + ) + })?; + + // Fetch every container body once up front. Master's approach + // streams chunks through a matcher; we just pull each container + // in full (simpler, avoids needing the private MMCSMatcher). + // For typical attachments this is ONE container per file. + let mut container_bodies: Vec<Vec<u8>> = Vec::with_capacity(f1.containers.len()); + for container in &f1.containers { + let body = fetch_container_body(container, user_agent).await?; + container_bodies.push(body); + } + + // ---- Build ford_keymap: data_chunk_checksum -> (ford_key, chunk_len) ---- + // + // If this file has a ford_reference, decrypt the Ford blob and + // flatten into per-chunk keys. Otherwise, we expect V1 per-chunk + // keys to come from `chunk.meta.encryption_key`. + let mut ford_keymap: HashMap<Vec<u8>, (Vec<u8>, Vec<u8>)> = HashMap::new(); + + if let Some(ford_ref) = &wanted.ford_reference { + let container_idx = ford_ref.container_index as usize; + let chunk_idx = ford_ref.chunk_index as usize; + let container = f1 + .containers + .get(container_idx) + .ok_or_else(|| format!("ford_reference container_idx {} OOB", container_idx))?; + let body = container_bodies + .get(container_idx) + .ok_or_else(|| "ford container body missing".to_string())?; + let chunk = container + .chunks + .get(chunk_idx) + .ok_or_else(|| format!("ford_reference chunk_idx {} OOB", chunk_idx))?; + let enc_meta = chunk + .encryption + .as_ref() + .ok_or_else(|| "ford chunk has no encryption meta".to_string())?; + + let blob_offset = enc_meta.offset as usize; + let blob_size = enc_meta.size as usize; + if blob_offset + blob_size > body.len() { + return Err(format!( + "ford blob OOB: offset={} size={} body_len={}", + blob_offset, + blob_size, + body.len() + )); + } + let ford_blob = &body[blob_offset..blob_offset + blob_size]; + + // SIV retry over record_own_key + cache. + let plaintext = ford_siv_retry( + asset_ford_key, + &wanted.ford_checksum, + ford_blob, + record_name, + ) + .ok_or_else(|| { + format!( + "Ford SIV failed: tried record key + {} cached keys, no match", + ford_key_cache_size() + ) + })?; + + // Decode plaintext as our local V1/V2-capable FordChunk. + let ford_chunk = LocalFordChunk::decode(&plaintext[..]) + .map_err(|e| format!("decode FordChunk: {e}"))?; + + let ford_entries = flatten_ford_entries(ford_chunk) + .ok_or_else(|| "FordChunk has neither item nor item_v2".to_string())?; + + if ford_entries.len() != wanted.chunk_references.len() { + warn!( + "manual_ford_download {}: ford_entries={} != chunk_references={} (will map what's present)", + record_name, + ford_entries.len(), + wanted.chunk_references.len() + ); + } + + for (entry, chunk_ref) in ford_entries.iter().zip(wanted.chunk_references.iter()) { + let cidx = chunk_ref.container_index as usize; + let kidx = chunk_ref.chunk_index as usize; + let chunk = f1 + .containers + .get(cidx) + .and_then(|c| c.chunks.get(kidx)) + .ok_or_else(|| { + format!("chunk_ref ({},{}) OOB", cidx, kidx) + })?; + let meta = chunk + .meta + .as_ref() + .ok_or_else(|| "data chunk has no meta".to_string())?; + ford_keymap.insert(meta.checksum.clone(), (entry.0.clone(), entry.1.clone())); + } + } + + // ---- Assemble the file by iterating data chunk refs in order ---- + let mut out = Vec::new(); + for chunk_ref in &wanted.chunk_references { + let cidx = chunk_ref.container_index as usize; + let kidx = chunk_ref.chunk_index as usize; + let container = f1 + .containers + .get(cidx) + .ok_or_else(|| format!("chunk_ref container OOB {}", cidx))?; + let body = container_bodies + .get(cidx) + .ok_or_else(|| "container body missing".to_string())?; + let chunk = container + .chunks + .get(kidx) + .ok_or_else(|| format!("chunk_ref chunk OOB {}", kidx))?; + let meta = chunk + .meta + .as_ref() + .ok_or_else(|| "chunk has no meta".to_string())?; + + let offset = meta.offset as usize; + let size = meta.size as usize; + if offset + size > body.len() { + return Err(format!( + "chunk OOB: offset={} size={} body_len={}", + offset, + size, + body.len() + )); + } + let encrypted = &body[offset..offset + size]; + + let plaintext = if let Some((fkey, clen)) = ford_keymap.get(&meta.checksum) { + // V2: Ford-derived key + chunk_len + let chunk_id: [u8; 21] = meta + .checksum + .clone() + .try_into() + .map_err(|_| "chunk checksum not 21 bytes".to_string())?; + decrypt_v2_chunk(fkey, clen, &chunk_id, encrypted)? + } else if let Some(enc_key) = &meta.encryption_key { + // V1: per-chunk AES-128-CFB key from the authorize response + decrypt_v1_chunk(enc_key, encrypted)? + } else { + // Unencrypted chunk + encrypted.to_vec() + }; + + out.extend_from_slice(&plaintext); + } + + Ok(out) + } +} + +// ============================================================================ +// NAC relay config (Apple Silicon hardware keys that can't run in the +// x86-64 unicorn emulator on Linux) +// ============================================================================ +// +// Some hardware keys (especially those extracted from Apple Silicon Macs) +// cannot be driven by the local unicorn x86-64 NAC emulator. For those +// users, `extract-key` embeds a `nac_relay_url` + bearer token + +// (optional) TLS cert fingerprint into the hardware-key JSON blob. +// +// At runtime, `_create_config_from_hardware_key_inner` calls +// `register_nac_relay` to stash those values here, then forwards them +// into `open_absinthe::nac::set_relay_config` so open-absinthe's +// `ValidationCtx::new()` can use the relay's 3-step NAC protocol +// instead of running the emulator. This mirrors the macOS Local NAC +// wiring (where open-absinthe's Native variant delegates to +// `nac-validation`) — same integration pattern, just over HTTPS to a +// Mac running `tools/nac-relay`. + +// --------------------------------------------------------------------------- +// rustls 0.23 CryptoProvider initialization. +// +// Upstream rustpush pulled in rustls 0.23 transitively. Unlike 0.21/0.22, +// rustls 0.23 does not auto-install a process-wide CryptoProvider when +// multiple provider crates are present in the dep graph; the first TLS +// connection panics with "Could not automatically determine the +// process-level CryptoProvider from Rustls crate features." We install +// aws-lc-rs explicitly the first time any FFI entry point that may open +// a TLS connection is called. install_default() returns Err if a provider +// is already installed; that's fine — we ignore it. +// --------------------------------------------------------------------------- +fn ensure_crypto_provider() { + use std::sync::Once; + static INIT: Once = Once::new(); + INIT.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); +} + +// --------------------------------------------------------------------------- +// RelayOSConfig — wraps MacOSConfig for Apple Silicon hardware keys. +// +// Copies master's relay logic: generate_validation_data() calls the relay +// directly and returns the data, bypassing the Apple handshake entirely. +// All other OSConfig methods delegate to the inner MacOSConfig. +// --------------------------------------------------------------------------- + +struct RelayOSConfig { + inner: Arc<rustpush::macos::MacOSConfig>, + relay_url: String, + relay_token: Option<String>, +} + +#[async_trait::async_trait] +impl OSConfig for RelayOSConfig { + fn build_activation_info(&self, csr: Vec<u8>) -> rustpush::activation::ActivationInfo { + self.inner.build_activation_info(csr) + } + fn get_activation_device(&self) -> String { self.inner.get_activation_device() } + async fn generate_validation_data(&self) -> Result<Vec<u8>, rustpush::PushError> { + // Same logic as master's MacOSConfig relay path: call relay, return directly. + use base64::{Engine, engine::general_purpose::STANDARD}; + + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .map_err(|e| rustpush::PushError::RelayError(0, format!("Failed to build relay client: {e}")))?; + + let mut req = client.post(&self.relay_url); + if let Some(ref token) = self.relay_token { + req = req.header("Authorization", format!("Bearer {token}")); + } + + let resp = req.send().await + .map_err(|e| rustpush::PushError::RelayError(0, format!("NAC relay request failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(rustpush::PushError::RelayError(status, format!("NAC relay error: {body}"))); + } + let b64 = resp.text().await + .map_err(|e| rustpush::PushError::RelayError(0, format!("NAC relay read error: {e}")))?; + let data = STANDARD.decode(b64.trim()) + .map_err(|e| rustpush::PushError::RelayError(0, format!("NAC relay base64 decode: {e}")))?; + info!("NAC relay: got {} bytes of validation data from {}", data.len(), self.relay_url); + Ok(data) + } + fn get_protocol_version(&self) -> u32 { self.inner.get_protocol_version() } + fn get_register_meta(&self) -> rustpush::RegisterMeta { self.inner.get_register_meta() } + fn get_normal_ua(&self, item: &str) -> String { self.inner.get_normal_ua(item) } + fn get_mme_clientinfo(&self, for_item: &str) -> String { self.inner.get_mme_clientinfo(for_item) } + fn get_version_ua(&self) -> String { self.inner.get_version_ua() } + fn get_device_name(&self) -> String { self.inner.get_device_name() } + fn get_device_uuid(&self) -> String { self.inner.get_device_uuid() } + fn get_private_data(&self) -> plist::Dictionary { self.inner.get_private_data() } + fn get_debug_meta(&self) -> rustpush::DebugMeta { self.inner.get_debug_meta() } + fn get_login_url(&self) -> &'static str { self.inner.get_login_url() } + fn get_serial_number(&self) -> String { self.inner.get_serial_number() } + fn get_gsa_hardware_headers(&self) -> HashMap<String, String> { self.inner.get_gsa_hardware_headers() } + fn get_aoskit_version(&self) -> String { self.inner.get_aoskit_version() } + fn get_udid(&self) -> String { self.inner.get_udid() } +} + +// ============================================================================ +// Local CloudKit record type that understands the `avid` field. +// ============================================================================ +// +// Upstream `rustpush::cloud_messages::CloudAttachment` only declares `cm` and +// `lqa`, so parsed records drop the `avid` Asset (Live Photo MOV companion). +// Our vendored fork added `pub avid: Asset` to support Live Photos. +// +// Instead of patching upstream, we define our own CloudKit record type with +// the same `attachment` record ID and the same field names — CloudKit's +// on-the-wire record format is schema-driven by field name, so a local +// struct that derives `CloudKitRecord` with an extra `avid: Asset` field +// parses the exact same server-side records and populates all three fields. +// Upstream's original `CloudAttachment` still works wherever we don't need +// the avid — this type is used anywhere we DO need it (attachment sync for +// `has_avid` detection, Live Photo MOV download). + +// Imports the derive macro sees at its expansion site. The `CloudKitRecord` +// derive emits references to `CloudKitEncryptor` unqualified, and to +// `cloudkit_proto::*` by crate name — both need to resolve in our scope. +use rustpush::cloudkit_derive::CloudKitRecord; +use cloudkit_proto::{Asset, CloudKitEncryptor}; + +#[derive(CloudKitRecord, Debug, Default, Clone)] +#[cloudkit_record(type = "attachment", encrypted)] +pub struct CloudAttachmentWithAvid { + pub cm: rustpush::cloud_messages::GZipWrapper<rustpush::cloud_messages::AttachmentMeta>, + pub lqa: Asset, + pub avid: Asset, +} + +// ============================================================================ +// Wrapper types +// ============================================================================ + +#[derive(uniffi::Object)] +pub struct WrappedAPSState { + pub inner: Option<APSState>, +} + +#[uniffi::export] +impl WrappedAPSState { + #[uniffi::constructor] + pub fn new(string: Option<String>) -> Arc<Self> { + Arc::new(Self { + inner: string + .and_then(|s| if s.is_empty() { None } else { Some(s) }) + .and_then(|s| plist_from_string::<APSState>(&s).ok()), + }) + } + + pub fn to_string(&self) -> String { + plist_to_string(&self.inner).unwrap_or_default() + } +} + +#[derive(uniffi::Object)] +pub struct WrappedAPSConnection { + pub inner: rustpush::APSConnection, +} + +#[uniffi::export(async_runtime = "tokio")] +impl WrappedAPSConnection { + pub async fn state(&self) -> Arc<WrappedAPSState> { + Arc::new(WrappedAPSState { + inner: Some(self.inner.state.read().await.clone()), + }) + } +} + +#[derive(uniffi::Record)] +pub struct IDSUsersWithIdentityRecord { + pub users: Arc<WrappedIDSUsers>, + pub identity: Arc<WrappedIDSNGMIdentity>, + /// TokenProvider for iCloud services (CardDAV, CloudKit, etc.) + pub token_provider: Option<Arc<WrappedTokenProvider>>, + /// Persist data for restoring the TokenProvider after restart. + pub account_persist: Option<AccountPersistData>, +} + +/// Data needed to restore a TokenProvider from persisted state. +/// Stored in session.json so it survives database resets. +#[derive(uniffi::Record)] +pub struct AccountPersistData { + pub username: String, + pub hashed_password_hex: String, + pub pet: String, + pub adsid: String, + pub dsid: String, + pub spd_base64: String, +} + +#[derive(uniffi::Object)] +pub struct WrappedIDSUsers { + pub inner: Vec<IDSUser>, +} + +#[uniffi::export] +impl WrappedIDSUsers { + #[uniffi::constructor] + pub fn new(string: Option<String>) -> Arc<Self> { + Arc::new(Self { + inner: string + .and_then(|s| if s.is_empty() { None } else { Some(s) }) + .and_then(|s| plist_from_string(&s).ok()) + .unwrap_or_default(), + }) + } + + pub fn to_string(&self) -> String { + plist_to_string(&self.inner).unwrap_or_default() + } + + pub fn login_id(&self, i: u64) -> String { + self.inner[i as usize].user_id.clone() + } + + pub fn get_handles(&self) -> Vec<String> { + self.inner.iter() + .flat_map(|user| { + user.registration.get("com.apple.madrid") + .map(|reg| reg.handles.clone()) + .unwrap_or_default() + }) + .collect() + } + + /// Check that all keystore keys referenced by the user state actually exist. + /// Returns false if any auth/id keypair alias is missing from the keystore, + /// which means the keystore was wiped or never migrated and re-login is needed. + pub fn validate_keystore(&self) -> bool { + if self.inner.is_empty() { + return true; + } + for user in &self.inner { + let alias = &user.auth_keypair.private.0; + if keystore().get_key_type(alias).ok().flatten().is_none() { + warn!("Keystore key '{}' not found for user '{}' — keystore/state mismatch", alias, user.user_id); + return false; + } + } + true + } +} + +#[derive(uniffi::Object)] +pub struct WrappedIDSNGMIdentity { + pub inner: IDSNGMIdentity, +} + +#[uniffi::export] +impl WrappedIDSNGMIdentity { + #[uniffi::constructor] + pub fn new(string: Option<String>) -> Arc<Self> { + Arc::new(Self { + inner: string + .and_then(|s| if s.is_empty() { None } else { Some(s) }) + .and_then(|s| plist_from_string(&s).ok()) + .unwrap_or_else(|| IDSNGMIdentity::new().expect("Failed to create new identity")), + }) + } + + pub fn to_string(&self) -> String { + plist_to_string(&self.inner).unwrap_or_default() + } +} + +#[derive(uniffi::Object)] +pub struct WrappedOSConfig { + pub config: Arc<dyn OSConfig>, + /// True when this config was built from an Apple Silicon hardware key + /// that requires the NAC relay server to be running during registration. + pub has_nac_relay: bool, + /// NAC relay URL for pre-fetching validation data (Apple Silicon keys). + pub(crate) relay_url: Option<String>, + /// NAC relay bearer token. + pub(crate) relay_token: Option<String>, +} + +#[uniffi::export] +impl WrappedOSConfig { + /// Get the device UUID from the underlying OSConfig. + pub fn get_device_id(&self) -> String { + self.config.get_device_uuid() + } + + /// Returns true if this config requires the NAC relay server to be + /// running during initial registration (Apple Silicon hardware keys). + pub fn requires_nac_relay(&self) -> bool { + self.has_nac_relay + } +} + +#[derive(thiserror::Error, uniffi::Error, Debug)] +pub enum WrappedError { + #[error("{msg}")] + GenericError { msg: String }, +} + +impl From<rustpush::PushError> for WrappedError { + fn from(e: rustpush::PushError) -> Self { + WrappedError::GenericError { msg: format!("{}", e) } + } +} + +fn is_pcs_recoverable_error(err: &rustpush::PushError) -> bool { + matches!( + err, + rustpush::PushError::ShareKeyNotFound(_) + | rustpush::PushError::DecryptionKeyNotFound(_) + | rustpush::PushError::PCSRecordKeyMissing + | rustpush::PushError::MasterKeyNotFound + ) +} + +fn keychain_retry_delay(attempt: usize) -> Duration { + match attempt { + 0..=4 => Duration::from_secs(2), + 5..=11 => Duration::from_secs(4), + _ => Duration::from_secs(6), + } +} + +/// Try to recover usable CloudKit keys after a NotInClique failure. +/// +/// Upstream's `sync_keychain` gates everything behind `is_in_clique()`, which +/// calls `sync_trust()` → Cuttlefish `fetchChanges`. If another device (e.g. +/// an iPhone running Messages) posts a trust-update that excludes us, every +/// `is_in_clique()` call returns false and `sync_keychain` is dead. +/// +/// Recovery strategy — all public upstream APIs, no internal field access: +/// 1. Try to refresh TLK shares via `fetch_shares_for` + `store_keys`. +/// These hit a different Cuttlefish endpoint that doesn't check clique +/// membership, so they work even when we appear excluded. +/// 2. Check `state.items` (public field): if the prior join/sync already +/// populated the CloudKit key cache, those keys are sufficient for +/// decryption. Return Ok — callers proceed with cached keys. +/// 3. If cache is empty (fresh install, no prior sync), return the real +/// error so the caller knows it cannot proceed. +/// +/// This mirrors what master's rustpush fork achieves by ignoring self-exclusion +/// inside `fast_forward_trust`, but implemented entirely at the wrapper layer. +async fn recover_keychain_after_exclusion( + keychain: &rustpush::keychain::KeychainClient<BridgeDefaultAnisetteProvider>, + context: &str, +) -> Result<(), WrappedError> { + // Step 1: attempt TLK share refresh (best-effort; doesn't need clique membership). + let identity_opt = { + let state = keychain.state.read().await; + state.user_identity.clone() + }; + if let Some(identity) = identity_opt { + match keychain.fetch_shares_for(&identity).await { + Ok(shares) if !shares.is_empty() => { + match keychain.store_keys(&shares).await { + Ok(()) => info!("{}: refreshed {} TLK share(s) despite exclusion", context, shares.len()), + Err(e) => warn!("{}: store_keys failed (non-fatal): {}", context, e), + } + } + Ok(_) => info!("{}: no TLK shares returned; using persisted keystore", context), + Err(e) => warn!("{}: fetch_shares_for failed (non-fatal): {}", context, e), + } + } + + // Step 2: check whether the persisted CloudKit key cache is usable. + let has_cached_keys = { + let state = keychain.state.read().await; + state.items.values().any(|zone| !zone.keys.is_empty()) + }; + + if has_cached_keys { + warn!( + "{}: excluded from Cuttlefish trust circle by another device, \ + but CloudKit key cache is populated — using cached keys. \ + Messages in iCloud sync will work; key rotation may require re-login.", + context + ); + return Ok(()); + } + + // Step 3: nothing to fall back on — propagate the error. + Err(WrappedError::GenericError { + msg: format!("{} keychain sync failed: not in clique and no cached keys available", context), + }) +} + +async fn sync_keychain_with_retries( + keychain: &rustpush::keychain::KeychainClient<BridgeDefaultAnisetteProvider>, + max_attempts: usize, + context: &str, +) -> Result<(), WrappedError> { + let attempts = max_attempts.max(1); + let mut last_err: Option<rustpush::PushError> = None; + for attempt in 0..attempts { + match keychain.sync_keychain(&rustpush::keychain::KEYCHAIN_ZONES).await { + Ok(()) => { + if attempt > 0 { + info!("{} keychain sync recovered after {} attempt(s)", context, attempt + 1); + } + return Ok(()); + } + Err(err) => { + if matches!(err, rustpush::PushError::NotInClique) { + // Don't retry — NotInClique won't resolve with more attempts. + // Fall back to cached CloudKit keys if available. + return recover_keychain_after_exclusion(keychain, context).await; + } + + let retrying = attempt + 1 < attempts; + warn!( + "{} keychain sync attempt {}/{} failed: {}{}", + context, + attempt + 1, + attempts, + err, + if retrying { " (retrying)" } else { "" } + ); + last_err = Some(err); + if retrying { + tokio::time::sleep(keychain_retry_delay(attempt)).await; + } + } + } + } + + let msg = match last_err { + Some(err) => format!("{} keychain sync failed after retries: {}", context, err), + None => format!("{} keychain sync failed", context), + }; + Err(WrappedError::GenericError { msg }) +} + +async fn refresh_recoverable_tlk_shares( + keychain: &Arc<rustpush::keychain::KeychainClient<BridgeDefaultAnisetteProvider>>, + context: &str, +) -> Result<(), WrappedError> { + let identity_opt = { + let state = keychain.state.read().await; + state.user_identity.clone() + }; + + let Some(identity) = identity_opt else { + warn!("{}: no keychain user identity available for TLK share refresh", context); + return Ok(()); + }; + + match keychain.fetch_shares_for(&identity).await { + Ok(shares) => { + info!("{}: fetched {} recoverable TLK share(s)", context, shares.len()); + if !shares.is_empty() { + keychain.store_keys(&shares).await?; + } + } + Err(err) => { + // Best-effort: we still attempt regular keychain sync / CloudKit probes. + warn!("{}: failed to fetch recoverable TLK shares: {}", context, err); + } + } + + Ok(()) +} + +async fn finalize_keychain_setup_with_probe( + keychain: Arc<rustpush::keychain::KeychainClient<BridgeDefaultAnisetteProvider>>, + cloudkit: Arc<rustpush::cloudkit::CloudKitClient<BridgeDefaultAnisetteProvider>>, + max_attempts: usize, +) -> Result<(), WrappedError> { + let cloud_messages = rustpush::cloud_messages::CloudMessagesClient::new(cloudkit, keychain.clone()); + let attempts = max_attempts.max(1); + + for attempt in 0..attempts { + let attempt_no = attempt + 1; + if attempt == 0 { + // After join, explicitly refresh recoverable TLK shares for this new peer. + // Some accounts/devices need an extra fetch before all view keys materialize. + refresh_recoverable_tlk_shares(&keychain, "Login finalize").await?; + } + sync_keychain_with_retries(&keychain, 1, "Login finalize").await?; + + match cloud_messages.sync_chats(None).await { + Ok((_token, chats, status)) => { + info!( + "CloudKit decrypt probe (chats) succeeded on attempt {} (status={}, records={})", + attempt_no, + status, + chats.len() + ); + } + Err(err) => { + if matches!(err, rustpush::PushError::NotInClique) { + return Err(WrappedError::GenericError { + msg: format!("CloudKit probe failed: {}", err), + }); + } + + let retrying = attempt_no < attempts; + if is_pcs_recoverable_error(&err) { + warn!( + "CloudKit decrypt probe (chats) missing PCS keys on attempt {}/{}: {}{}", + attempt_no, + attempts, + err, + if retrying { " (retrying)" } else { "" } + ); + } else { + warn!( + "CloudKit decrypt probe (chats) failed on attempt {}/{}: {}{}", + attempt_no, + attempts, + err, + if retrying { " (retrying)" } else { "" } + ); + } + if retrying { + if is_pcs_recoverable_error(&err) && attempt % 4 == 0 { + refresh_recoverable_tlk_shares(&keychain, "Login finalize").await?; + } + tokio::time::sleep(keychain_retry_delay(attempt)).await; + continue; + } + return Err(WrappedError::GenericError { + msg: format!("CloudKit decrypt probe failed after retries (chats): {}", err), + }); + } + } + + match cloud_messages.sync_messages(None).await { + Ok((_token, messages, status)) => { + info!( + "CloudKit decrypt probe (messages) succeeded on attempt {} (status={}, records={})", + attempt_no, + status, + messages.len() + ); + return Ok(()); + } + Err(err) => { + if matches!(err, rustpush::PushError::NotInClique) { + return Err(WrappedError::GenericError { + msg: format!("CloudKit probe failed: {}", err), + }); + } + + let retrying = attempt_no < attempts; + if is_pcs_recoverable_error(&err) { + warn!( + "CloudKit decrypt probe (messages) missing PCS keys on attempt {}/{}: {}{}", + attempt_no, + attempts, + err, + if retrying { " (retrying)" } else { "" } + ); + } else { + warn!( + "CloudKit decrypt probe (messages) failed on attempt {}/{}: {}{}", + attempt_no, + attempts, + err, + if retrying { " (retrying)" } else { "" } + ); + } + if retrying { + if is_pcs_recoverable_error(&err) && attempt % 4 == 0 { + refresh_recoverable_tlk_shares(&keychain, "Login finalize").await?; + } + tokio::time::sleep(keychain_retry_delay(attempt)).await; + continue; + } + return Err(WrappedError::GenericError { + msg: format!("CloudKit decrypt probe failed after retries (messages): {}", err), + }); + } + } + } + + Err(WrappedError::GenericError { + msg: "CloudKit decrypt probe failed after retries".into(), + }) +} + +// ============================================================================ +// Token Provider (iCloud auth for CardDAV, CloudKit, etc.) +// ============================================================================ + +/// Information about a device that has an escrow bottle in the iCloud Keychain +/// trust circle. Used to let the user choose which device's passcode to enter. +#[derive(uniffi::Record)] +pub struct EscrowDeviceInfo { + /// Index into the bottles list (used when calling join_keychain_clique_for_device). + pub index: u32, + /// Human-readable device name (e.g. "Ludvig's iPhone"). + pub device_name: String, + /// Device model identifier (e.g. "iPhone15,2"). + pub device_model: String, + /// Device serial number. + pub serial: String, + /// When the escrow bottle was created. + pub timestamp: String, +} + +/// Wraps a TokenProvider that manages MobileMe auth tokens with auto-refresh. +/// Used for iCloud services like CardDAV contacts and CloudKit messages. +/// +/// This wrapper stores `account`, `os_config`, and `mme_delegate` alongside the +/// inner TokenProvider because OpenBubbles upstream's TokenProvider keeps these +/// as private fields with no public getters. As part of the zero-patch refactor, +/// we pass them in at construction time and read from our own state. +#[derive(uniffi::Object)] +pub struct WrappedTokenProvider { + inner: Arc<TokenProvider<BridgeDefaultAnisetteProvider>>, + account: Arc<rustpush::DebugMutex<AppleAccount<BridgeDefaultAnisetteProvider>>>, + os_config: Arc<dyn OSConfig>, + // MobileMe delegate stored as opaque plist XML bytes because + // `rustpush::auth::MobileMeDelegateResponse` is not nameable from outside the + // crate in OpenBubbles upstream (`mod auth` is private). Call sites that + // need a typed `&MobileMeDelegateResponse` reconstruct it via + // `plist::from_bytes(&bytes)?` and let the compiler infer T from the + // receiving function's signature (e.g. `KeychainClientState::new(_, _, &T)`). + mme_delegate_bytes: tokio::sync::Mutex<Option<Vec<u8>>>, + // Cached (KeychainClient, CloudKitClient) pair. The keychain/cloudkit + // pair is built by `create_keychain_clients` and used by + // `get_escrow_devices` and `join_keychain_clique_for_device`. + // + // We cache it because each fresh pair forces a fresh CloudKit container + // init (`ckAppInit` POST to `gateway.icloud.com/setup/setup/ck/v1/ckAppInit` + // inside upstream `CloudKitOpenContainer::init` at + // `third_party/rustpush-upstream/src/icloud/cloudkit.rs:1309-1340`). + // Apple has been observed returning empty bodies (401-with-no-body) for + // a *second* `ckAppInit` call from the same process minutes after the + // first one succeeded — typically the get_escrow_devices → user enters + // passcode → join_keychain_clique_for_device sequence in the install + // flow. Upstream's init handles 401 by calling `refresh_mme` but then + // still tries to JSON-decode the (empty) 401 response body + // (cloudkit.rs:1326-1330), surfacing as + // "HTTP error: error decoding response body: EOF while parsing a value + // at line 1 column 0". + // + // Caching the pair means the second call reuses the working container + // from the first call and skips the second ckAppInit entirely, which + // sidesteps the upstream bug and any Apple-side rate limiting on + // ckAppInit for the same DSID. + keychain_clients_cache: tokio::sync::Mutex<Option<( + Arc<rustpush::keychain::KeychainClient<BridgeDefaultAnisetteProvider>>, + Arc<rustpush::cloudkit::CloudKitClient<BridgeDefaultAnisetteProvider>>, + )>>, +} + +/// Helper: create CloudKit + Keychain clients from a WrappedTokenProvider. +/// Shared by get_escrow_devices, join_keychain_clique, and join_keychain_clique_for_device. +/// +/// Reads dsid/adsid/anisette directly from the locally-stored AppleAccount and +/// the cached MobileMe delegate, avoiding the need for getter methods on +/// TokenProvider itself (which upstream does not expose). +async fn create_keychain_clients( + wp: &WrappedTokenProvider, +) -> Result<( + Arc<rustpush::keychain::KeychainClient<BridgeDefaultAnisetteProvider>>, + Arc<rustpush::cloudkit::CloudKitClient<BridgeDefaultAnisetteProvider>>, +), WrappedError> { + // Fast path: return the cached pair if we've already built one in this + // process. See the keychain_clients_cache field docstring on + // WrappedTokenProvider for why caching is required (Apple returns empty + // body on a second ckAppInit, and upstream's CloudKitOpenContainer::init + // can't recover from it). Holding the lock across the slow construction + // path below also serializes concurrent callers, so two parallel + // `get_escrow_devices` calls don't race into two separate ckAppInit POSTs. + let mut cache = wp.keychain_clients_cache.lock().await; + if let Some(pair) = &*cache { + debug!("create_keychain_clients: reusing cached keychain/cloudkit pair"); + return Ok(pair.clone()); + } + + let (dsid, adsid, anisette) = { + let account = wp.account.lock().await; + let spd = account.spd.as_ref().ok_or(WrappedError::GenericError { + msg: "AppleAccount has no SPD — not fully logged in".into(), + })?; + let dsid = spd.get("DsPrsId") + .and_then(|v| v.as_unsigned_integer()) + .ok_or(WrappedError::GenericError { msg: "SPD missing DsPrsId".into() })? + .to_string(); + let adsid = spd.get("adsid") + .and_then(|v| v.as_string()) + .ok_or(WrappedError::GenericError { msg: "SPD missing adsid".into() })? + .to_string(); + let anisette = account.anisette.clone(); + (dsid, adsid, anisette) + }; + // Load MobileMe delegate as typed `MobileMeDelegateResponse` via type + // inference from its downstream usage (the `&mme_delegate` reference passed + // to `KeychainClientState::new(..., &MobileMeDelegateResponse)` below — Rust + // propagates that constraint back to `parse_mme_delegate`'s `T`). + let mme_delegate = wp.parse_mme_delegate().await?; + let os_config = wp.os_config.clone(); + let token_provider = wp.inner.clone(); + + let cloudkit_state = rustpush::cloudkit::CloudKitState::new(dsid.clone()) + .ok_or(WrappedError::GenericError { msg: "Failed to create CloudKitState".into() })?; + let cloudkit = Arc::new(rustpush::cloudkit::CloudKitClient { + state: rustpush::DebugRwLock::new(cloudkit_state), + anisette: anisette.clone(), + config: os_config.clone(), + token_provider: token_provider.clone(), + }); + let keychain_state_path = format!("{}/trustedpeers.plist", resolve_xdg_data_dir()); + let mut keychain_state: Option<rustpush::keychain::KeychainClientState> = match std::fs::read(&keychain_state_path) { + Ok(data) => match plist::from_bytes(&data) { + Ok(state) => Some(state), + Err(e) => { + warn!("Failed to parse keychain state at {}: {}", keychain_state_path, e); + None + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + warn!("Failed to read keychain state at {}: {}", keychain_state_path, e); + None + } + }; + if keychain_state.is_none() { + keychain_state = Some( + rustpush::keychain::KeychainClientState::new(dsid.clone(), adsid.clone(), &mme_delegate) + .ok_or(WrappedError::GenericError { msg: "Missing KeychainSync config in MobileMe delegate".into() })? + ); + } + let path_for_closure = keychain_state_path.clone(); + let keychain = Arc::new(rustpush::keychain::KeychainClient { + anisette: anisette.clone(), + token_provider: token_provider.clone(), + state: rustpush::DebugRwLock::new(keychain_state.expect("keychain state missing")), + config: os_config.clone(), + update_state: Box::new(move |state| { + if let Err(e) = plist::to_file_xml(&path_for_closure, state) { + warn!("Failed to persist keychain state to {}: {}", path_for_closure, e); + } else { + info!("Persisted keychain state to {}", path_for_closure); + } + }), + container: tokio::sync::Mutex::new(None), + security_container: tokio::sync::Mutex::new(None), + client: cloudkit.clone(), + }); + + let pair = (keychain, cloudkit); + *cache = Some(pair.clone()); + Ok(pair) +} + +/// Extract device name and model from an EscrowMetadata's client_metadata dictionary. +fn extract_device_info(meta: &rustpush::keychain::EscrowMetadata) -> (String, String) { + let dict = meta.client_metadata.as_dictionary(); + let device_name = dict + .and_then(|d| d.get("device_name")) + .and_then(|v| v.as_string()) + .unwrap_or("Unknown Device") + .to_string(); + let device_model = dict + .and_then(|d| d.get("device_model")) + .and_then(|v| v.as_string()) + .unwrap_or("Unknown") + .to_string(); + (device_name, device_model) +} + +/// Core keychain joining logic used by both join_keychain_clique and join_keychain_clique_for_device. +/// If `preferred_index` is Some, the bottle at that index is tried first before falling back to others. +async fn join_keychain_with_bottles( + keychain: Arc<rustpush::keychain::KeychainClient<BridgeDefaultAnisetteProvider>>, + cloudkit: Arc<rustpush::cloudkit::CloudKitClient<BridgeDefaultAnisetteProvider>>, + bottles: &[(rustpush::cloudkit_proto::EscrowData, rustpush::keychain::EscrowMetadata)], + passcode: &str, + preferred_index: Option<u32>, +) -> Result<String, WrappedError> { + let passcode_bytes = passcode.as_bytes(); + let mut last_err = String::new(); + + // Build iteration order: preferred bottle first (if specified), then the rest. + let indices: Vec<usize> = if let Some(pref) = preferred_index { + let pref = pref as usize; + let mut order = vec![pref]; + order.extend((0..bottles.len()).filter(|&i| i != pref)); + order + } else { + (0..bottles.len()).collect() + }; + + // If there are many escrow bottles, do a quick probe per bottle first, + // then one extended probe at the end on the latest successful join. + let per_bottle_probe_attempts = if bottles.len() > 1 { 3 } else { 24 }; + + // Outer stability loop: after joining + probe, verify we stay in the clique. + // Other devices can exclude us within seconds of joining. + const MAX_REJOIN_ATTEMPTS: usize = 3; + let mut rejoin_attempt = 0; + + 'stability: loop { + let mut joined_any = false; + let mut probe_succeeded = false; + let mut last_joined_meta: Option<(String, String)> = None; + + for &i in &indices { + let (data, meta) = &bottles[i]; + info!("Trying bottle {} (serial={})...", i, meta.serial); + match keychain.join_clique_from_escrow(data, passcode_bytes, passcode_bytes).await { + Ok(()) => { + joined_any = true; + last_joined_meta = Some((meta.serial.clone(), meta.build.clone())); + info!("Successfully joined keychain trust circle via bottle {}", i); + info!( + "Finalizing keychain setup (sync + CloudKit decrypt probe), attempts={}", + per_bottle_probe_attempts + ); + match finalize_keychain_setup_with_probe(keychain.clone(), cloudkit.clone(), per_bottle_probe_attempts).await { + Ok(()) => { + probe_succeeded = true; + break; // probe passed, go to stability check + } + Err(e) => { + warn!( + "Bottle {} joined, but CloudKit decrypt probe failed: {}. Trying next bottle...", + i, + e + ); + last_err = format!("{}", e); + } + } + } + Err(e) => { + warn!("Bottle {} failed: {}", i, e); + last_err = format!("{}", e); + } + } + } + + if !joined_any { + return Err(WrappedError::GenericError { + msg: format!("All {} bottles failed. Last error: {}", bottles.len(), last_err) + }); + } + + // The CloudKit decrypt probe already verified clique membership + // (sync_keychain internally checks is_in_clique). Calling is_in_clique() + // again here triggers another sync_trust() → Cuttlefish fetchChanges, + // which can transiently fail to include us and reset our local state. + // Skip this check when the probe already confirmed membership; the + // stability loop below catches any subsequent exclusion. + if !probe_succeeded && !keychain.is_in_clique().await { + if rejoin_attempt >= MAX_REJOIN_ATTEMPTS { + return Err(WrappedError::GenericError { + msg: format!("Excluded from clique after {} rejoin attempts. Last error: {}", rejoin_attempt, last_err) + }); + } + rejoin_attempt += 1; + warn!("Not in clique after bottle probes, rejoin attempt {}/{}", rejoin_attempt, MAX_REJOIN_ATTEMPTS); + continue 'stability; + } + + // Stability check: wait, re-sync trust, verify we're still included. + // Other devices can exclude us within seconds of joining. + info!("Verifying clique membership stability..."); + for check in 0..3 { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + if !keychain.is_in_clique().await { + rejoin_attempt += 1; + warn!( + "Excluded from clique during stability check {} — re-joining (attempt {}/{})", + check + 1, rejoin_attempt, MAX_REJOIN_ATTEMPTS + ); + if rejoin_attempt > MAX_REJOIN_ATTEMPTS { + return Err(WrappedError::GenericError { + msg: "Repeatedly excluded from iCloud Keychain trust circle by another device. \ + Try disabling and re-enabling 'Messages in iCloud' on your iPhone, then retry." + .into() + }); + } + continue 'stability; + } + } + + // Still in clique after stability checks + let (serial, build) = last_joined_meta.unwrap_or(("unknown".into(), "unknown".into())); + info!("Clique membership stable after {} stability checks", 3); + return Ok(format!( + "Joined iCloud Keychain and verified CloudKit access (device: serial={}, build={})", + serial, build, + )); + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl WrappedTokenProvider { + /// Get HTTP headers needed for iCloud MobileMe API calls. + /// Includes Authorization (X-MobileMe-AuthToken) and anisette headers. + /// + /// OpenBubbles upstream exposes `TokenProvider::get_mme_token(&str)` which + /// returns the mmeAuthToken (and auto-refreshes internally). We build the + /// Apple-required HTTP headers (X-MobileMe-AuthToken, X-Client-UDID, + /// X-MMe-Client-Info, plus anisette) locally from our stored state. + pub async fn get_icloud_auth_headers(&self) -> Result<HashMap<String, String>, WrappedError> { + let token = self.inner.get_mme_token("mmeAuthToken").await + .map_err(|e| WrappedError::GenericError { + msg: format!("Failed to fetch mmeAuthToken: {}", e), + })?; + let dsid = self.get_dsid().await?; + + // Basic auth: "dsid:mmeAuthToken" base64-encoded in X-MMe-Client-Info? No. + // The header "Authorization: Basic base64(dsid:token)" is what Apple wants. + let auth_header_value = format!( + "Basic {}", + BASE64_STANDARD.encode(format!("{}:{}", dsid, token)), + ); + + let mut headers: HashMap<String, String> = HashMap::new(); + headers.insert("Authorization".to_string(), auth_header_value); + headers.insert("X-Client-UDID".to_string(), self.os_config.get_udid().to_lowercase()); + headers.insert( + "X-MMe-Client-Info".to_string(), + self.os_config.get_mme_clientinfo("com.apple.AppleAccount/1.0 (com.apple.Preferences/1112.96)"), + ); + headers.insert("X-MMe-Country".to_string(), "US".to_string()); + headers.insert("X-MMe-Language".to_string(), "en".to_string()); + + // Anisette headers from the AppleAccount. `get_headers()` returns a + // `&HashMap<String, String>` so we clone the entries we need. + let anisette = { + let account = self.account.lock().await; + account.anisette.clone() + }; + let mut anisette_guard = anisette.lock().await; + let anisette_headers = anisette_guard.get_headers().await + .map_err(|e| WrappedError::GenericError { + msg: format!("Failed to fetch anisette headers: {}", e), + })?; + for (k, v) in anisette_headers.iter() { + headers.insert(k.clone(), v.clone()); + } + drop(anisette_guard); + + Ok(headers) + } + + /// Get the contacts CardDAV URL from the MobileMe delegate config. + /// Reads from our locally-cached MobileMe delegate plist bytes via generic + /// `plist::Value` traversal (we can't name `MobileMeDelegateResponse` from + /// outside the rustpush crate, so we use untyped plist parsing here). + /// Runs the bytes through `normalize_mme_delegate_dict` first so we look + /// up the URL on a single canonical shape regardless of which of the + /// three persisted shapes is on disk (see normalize comment for details). + pub async fn get_contacts_url(&self) -> Result<Option<String>, WrappedError> { + let bytes_guard = self.mme_delegate_bytes.lock().await; + let Some(bytes) = bytes_guard.as_ref() else { + return Ok(None); + }; + let value: plist::Value = plist::from_bytes(bytes).map_err(|e| { + WrappedError::GenericError { msg: format!("Invalid MobileMe delegate plist: {}", e) } + })?; + let normalized = normalize_mme_delegate_dict(value); + // After normalize: `{tokens, config: {com.apple.Dataclass.Contacts: {url}, ...}}`. + let url = normalized + .as_dictionary() + .and_then(|d| d.get("config")) + .and_then(|v| v.as_dictionary()) + .and_then(|d| d.get("com.apple.Dataclass.Contacts")) + .and_then(|v| v.as_dictionary()) + .and_then(|d| d.get("url")) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + Ok(url) + } + + /// Get the DSID for this account (from AppleAccount's SPD dictionary). + pub async fn get_dsid(&self) -> Result<String, WrappedError> { + let account = self.account.lock().await; + let spd = account.spd.as_ref().ok_or(WrappedError::GenericError { + msg: "AppleAccount has no SPD — not fully logged in".into(), + })?; + let dsid = spd.get("DsPrsId") + .and_then(|v| v.as_unsigned_integer()) + .ok_or(WrappedError::GenericError { msg: "SPD missing DsPrsId".into() })? + .to_string(); + Ok(dsid) + } + + /// Get the serialized MobileMe delegate as a plist string (for persistence). + /// Returns None if no delegate is cached. The plist bytes are stored opaquely + /// and passed through as UTF-8 text. + pub async fn get_mme_delegate_json(&self) -> Result<Option<String>, WrappedError> { + let bytes_guard = self.mme_delegate_bytes.lock().await; + Ok(bytes_guard.as_ref().map(|b| String::from_utf8_lossy(b).to_string())) + } + + /// Seed the MobileMe delegate from persisted plist string. Validates as a + /// generic `plist::Value` first, then stores opaque bytes. Call sites that + /// need a typed `MobileMeDelegateResponse` reconstruct it via + /// `parse_mme_delegate()`. + pub async fn seed_mme_delegate_json(&self, json: String) -> Result<(), WrappedError> { + plist::from_bytes::<plist::Value>(json.as_bytes()) + .map_err(|e| WrappedError::GenericError { + msg: format!("Failed to deserialize MobileMe delegate: {}", e), + })?; + *self.mme_delegate_bytes.lock().await = Some(json.into_bytes()); + Ok(()) + } +} + +/// Reshape a stored MobileMe delegate plist value so that deserializing it as +/// upstream's `MobileMeDelegateResponse` puts the *inner* iCloud service dict +/// (the one keyed by `com.apple.Dataclass.KeychainSync`, +/// `com.apple.Dataclass.Files`, etc.) into the `config` field. +/// +/// We accept three stored shapes and normalize to a single output: +/// 1. **Current (post-a7fab47 double-wrapped)**: +/// `{tokens, "com.apple.mobileme": {tokens, "com.apple.mobileme": {KeychainSync: ...}}}` +/// — produced by our serialize path below after upstream's "fix" made +/// `mobileme.config` hold the entire delegate service_data. +/// 2. **Legacy (pre-a7fab47)**: +/// `{tokens, "com.apple.mobileme": {KeychainSync: ...}}` +/// — produced when `config` was deserialized via +/// `#[serde(rename = "com.apple.mobileme")]` and held the inner dict directly. +/// 3. **Already-normalized**: +/// `{tokens, config: {KeychainSync: ...}}` — ideal shape going forward. +/// +/// Output: `{tokens, config: {KeychainSync: ...}}` every time, which matches +/// the new `#[serde(default)] pub config: Dictionary` field on +/// `MobileMeDelegateResponse` and keeps `KeychainClientState::new` +/// (which does `delegate.config.get("com.apple.Dataclass.KeychainSync")`) +/// working. +fn normalize_mme_delegate_dict(value: plist::Value) -> plist::Value { + let Some(mut root) = value.into_dictionary() else { + return plist::Value::Dictionary(plist::Dictionary::new()); + }; + if root.contains_key("config") { + return plist::Value::Dictionary(root); + } + let Some(outer_mm) = root.remove("com.apple.mobileme") else { + return plist::Value::Dictionary(root); + }; + let Some(outer_dict) = outer_mm.as_dictionary() else { + return plist::Value::Dictionary(root); + }; + // If the outer has a nested `com.apple.mobileme`, that's shape 1 (double-wrap) + // and the nested dict is the real iCloud config. Otherwise it's shape 2 (legacy) + // and the outer IS the iCloud config. + let inner = outer_dict + .get("com.apple.mobileme") + .and_then(|v| v.as_dictionary()) + .cloned() + .unwrap_or_else(|| outer_dict.clone()); + root.insert("config".to_string(), plist::Value::Dictionary(inner)); + plist::Value::Dictionary(root) +} + +// Non-uniffi impl block: internal helpers used by other wrapper code that need +// to read state previously available via `TokenProvider::get_xxx()` methods that +// OpenBubbles upstream doesn't expose. These are not exported as FFI symbols. +impl WrappedTokenProvider { + /// Get the ADSID from the AppleAccount's SPD dictionary. + pub(crate) async fn get_adsid(&self) -> Result<String, WrappedError> { + let account = self.account.lock().await; + let spd = account.spd.as_ref().ok_or(WrappedError::GenericError { + msg: "AppleAccount has no SPD — not fully logged in".into(), + })?; + let adsid = spd.get("adsid") + .and_then(|v| v.as_string()) + .ok_or(WrappedError::GenericError { msg: "SPD missing adsid".into() })? + .to_string(); + Ok(adsid) + } + + /// Parse the stored MobileMe delegate plist bytes into whatever typed form + /// the caller context expects (usually `rustpush::auth::MobileMeDelegateResponse`). + /// `T` is inferred from the receiving function's signature at the call site — + /// we intentionally do NOT name the type here because it's not reachable from + /// outside the rustpush crate in OpenBubbles upstream (`mod auth` is private). + /// + /// Normalizes the dict shape before deserialization so `config` ends up as + /// the inner iCloud service dict (the one holding `com.apple.Dataclass.KeychainSync` + /// etc.) regardless of which shape was stored. See `normalize_mme_delegate_dict` + /// below for the three shapes we accept. + pub(crate) async fn parse_mme_delegate<T: serde::de::DeserializeOwned>(&self) -> Result<T, WrappedError> { + let bytes_guard = self.mme_delegate_bytes.lock().await; + let bytes = bytes_guard.as_ref().ok_or(WrappedError::GenericError { + msg: "MobileMe delegate not seeded — call seed_mme_delegate_json first".into(), + })?; + let value: plist::Value = plist::from_bytes(bytes).map_err(|e| WrappedError::GenericError { + msg: format!("Failed to parse MobileMe delegate bytes: {}", e), + })?; + let normalized = normalize_mme_delegate_dict(value); + plist::from_value::<T>(&normalized).map_err(|e| WrappedError::GenericError { + msg: format!("Failed to deserialize MobileMe delegate: {}", e), + }) + } + + /// Clone the shared AppleAccount handle. + pub(crate) fn get_account(&self) -> Arc<rustpush::DebugMutex<AppleAccount<BridgeDefaultAnisetteProvider>>> { + self.account.clone() + } + + /// Clone the shared OSConfig handle. + pub(crate) fn get_os_config(&self) -> Arc<dyn OSConfig> { + self.os_config.clone() + } +} + +// Second uniffi-exported impl block: the iCloud Keychain clique methods that +// were previously in the single uniffi block before I split out the internal +// helpers above. These MUST be exported to Go so the bridge's login flow can +// call them. +#[uniffi::export(async_runtime = "tokio")] +impl WrappedTokenProvider { + /// List devices that have escrow bottles in the iCloud Keychain trust circle. + /// Returns device info (name, model, serial, timestamp) for each bottle. + /// Call this before join_keychain_clique_for_device to let the user choose. + pub async fn get_escrow_devices(&self) -> Result<Vec<EscrowDeviceInfo>, WrappedError> { + info!("Fetching escrow devices..."); + let (keychain, _cloudkit) = create_keychain_clients(self).await?; + + let bottles = keychain.get_viable_bottles().await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to get escrow bottles: {}", e) })?; + + if bottles.is_empty() { + return Err(WrappedError::GenericError { + msg: "No escrow bottles found. Make sure Messages in iCloud is enabled on your iPhone/Mac.".into() + }); + } + + let devices: Vec<EscrowDeviceInfo> = bottles.iter().enumerate().map(|(i, (_data, meta))| { + let (device_name, device_model) = extract_device_info(meta); + info!(" [{}] name={:?} model={} serial={} timestamp={}", i, device_name, device_model, meta.serial, meta.timestamp); + EscrowDeviceInfo { + index: i as u32, + device_name, + device_model, + serial: meta.serial.clone(), + timestamp: meta.timestamp.clone(), + } + }).collect(); + + info!("Found {} escrow device(s)", devices.len()); + Ok(devices) + } + + /// Join the iCloud Keychain trust circle using a device passcode. + /// Tries all available escrow bottles in order. + /// Required before CloudKit Messages can decrypt PCS-encrypted records. + /// The passcode is the 6-digit PIN or password used to unlock an iPhone/Mac. + /// Returns a description of the escrow bottle used. + pub async fn join_keychain_clique(&self, passcode: String) -> Result<String, WrappedError> { + info!("=== Joining iCloud Keychain Trust Circle ==="); + let (keychain, cloudkit) = create_keychain_clients(self).await?; + + info!("Fetching escrow bottles..."); + let bottles = keychain.get_viable_bottles().await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to get escrow bottles: {}", e) })?; + + if bottles.is_empty() { + return Err(WrappedError::GenericError { + msg: "No escrow bottles found. Make sure Messages in iCloud is enabled on your iPhone/Mac.".into() + }); + } + + info!("Found {} escrow bottle(s)", bottles.len()); + for (i, (_data, meta)) in bottles.iter().enumerate() { + info!(" [{}] serial={} build={} timestamp={}", i, meta.serial, meta.build, meta.timestamp); + } + + join_keychain_with_bottles(keychain, cloudkit, &bottles, &passcode, None).await + } + + /// Join the iCloud Keychain trust circle, trying the specified device first. + /// `device_index` is the index from get_escrow_devices(). If that bottle fails, + /// falls back to trying other bottles. + pub async fn join_keychain_clique_for_device(&self, passcode: String, device_index: u32) -> Result<String, WrappedError> { + info!("=== Joining iCloud Keychain Trust Circle (preferred device {}) ===", device_index); + let (keychain, cloudkit) = create_keychain_clients(self).await?; + + info!("Fetching escrow bottles..."); + let bottles = keychain.get_viable_bottles().await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to get escrow bottles: {}", e) })?; + + if bottles.is_empty() { + return Err(WrappedError::GenericError { + msg: "No escrow bottles found. Make sure Messages in iCloud is enabled on your iPhone/Mac.".into() + }); + } + + info!("Found {} escrow bottle(s)", bottles.len()); + for (i, (_data, meta)) in bottles.iter().enumerate() { + let (name, model) = extract_device_info(meta); + info!(" [{}] name={:?} model={} serial={} build={} timestamp={}", i, name, model, meta.serial, meta.build, meta.timestamp); + } + + let preferred = if (device_index as usize) < bottles.len() { + Some(device_index) + } else { + warn!("Device index {} out of range (have {} bottles), trying all", device_index, bottles.len()); + None + }; + + join_keychain_with_bottles(keychain, cloudkit, &bottles, &passcode, preferred).await + } +} + +/// Restore a TokenProvider from persisted account credentials. +/// Used on session restore (when we don't go through the login flow). +#[uniffi::export(async_runtime = "tokio")] +pub async fn restore_token_provider( + config: &WrappedOSConfig, + connection: &WrappedAPSConnection, + username: String, + hashed_password_hex: String, + pet: String, + spd_base64: String, +) -> Result<Arc<WrappedTokenProvider>, WrappedError> { + let os_config = config.config.clone(); + let conn = connection.inner.clone(); + + // Create a fresh anisette provider. On Linux the + // `BridgeAnisetteProvider` owns provisioning and bypasses upstream's + // broken `ProvisionInput` enum entirely (see src/anisette.rs). On + // macOS this is upstream's native AOSKit path, unchanged. + let client_info = os_config.get_gsa_config(&*conn.state.read().await, false); + let anisette_state_path = PathBuf::from_str("state/anisette").unwrap(); + let anisette = bridge_default_provider(client_info.clone(), anisette_state_path); + + // Create a new AppleAccount and populate it with persisted state + let mut account = AppleAccount::new_with_anisette(client_info, anisette) + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to create account: {}", e) })?; + + account.username = Some(username.clone()); + + // Restore hashed password + let hashed_password = decode_hex(&hashed_password_hex) + .map_err(|e| WrappedError::GenericError { msg: format!("Invalid hashed_password hex: {}", e) })?; + account.hashed_password = Some(hashed_password.clone()); + + // Restore SPD from base64-encoded plist + let spd_bytes = base64_decode(&spd_base64); + let spd: plist::Dictionary = plist::from_bytes(&spd_bytes) + .map_err(|e| WrappedError::GenericError { msg: format!("Invalid SPD plist: {}", e) })?; + account.spd = Some(spd); + + // Best-effort PET refresh on restore using persisted credentials. + // This avoids private token constructor hacks while still warming auth state. + match account.login_email_pass(&username, &hashed_password).await { + Ok(icloud_auth::LoginState::LoggedIn) => { + info!("restore_token_provider: proactive PET refresh succeeded"); + } + Ok(state) => { + warn!( + "restore_token_provider: proactive PET refresh returned non-logged-in state: {:?}", + state + ); + } + Err(err) => { + warn!( + "restore_token_provider: proactive PET refresh failed (non-fatal): {}", + err + ); + } + } + + // PET remains part of persisted payload for compatibility/telemetry. + let _ = pet; + + let account = Arc::new(rustpush::DebugMutex::new(account)); + let token_provider = TokenProvider::new(account.clone(), os_config.clone()); + + info!("Restored TokenProvider from persisted credentials"); + + // Restore path does not have a MobileMe delegate — callers must + // seed_mme_delegate_json() from persisted state before using keychain/ + // contacts features. + Ok(Arc::new(WrappedTokenProvider { + inner: token_provider, + account, + os_config, + mme_delegate_bytes: tokio::sync::Mutex::new(None), + keychain_clients_cache: tokio::sync::Mutex::new(None), + })) +} + +// ============================================================================ +// Message wrapper types (flat structs for uniffi) +// ============================================================================ + +#[derive(uniffi::Record, Clone, Default)] +pub struct WrappedMessage { + pub uuid: String, + pub sender: Option<String>, + pub text: Option<String>, + pub subject: Option<String>, + pub participants: Vec<String>, + pub group_name: Option<String>, + pub timestamp_ms: u64, + pub is_sms: bool, + + // Tapback + pub is_tapback: bool, + pub tapback_type: Option<u32>, + pub tapback_target_uuid: Option<String>, + pub tapback_target_part: Option<u64>, + pub tapback_emoji: Option<String>, + pub tapback_remove: bool, + + // Edit + pub is_edit: bool, + pub edit_target_uuid: Option<String>, + pub edit_part: Option<u64>, + pub edit_new_text: Option<String>, + + // Unsend + pub is_unsend: bool, + pub unsend_target_uuid: Option<String>, + pub unsend_edit_part: Option<u64>, + + // Rename + pub is_rename: bool, + pub new_chat_name: Option<String>, + + // Participant change + pub is_participant_change: bool, + pub new_participants: Vec<String>, + + // Attachments + pub attachments: Vec<WrappedAttachment>, + + // Reply + pub reply_guid: Option<String>, + pub reply_part: Option<String>, + + // Typing + pub is_typing: bool, + pub typing_app_bundle_id: Option<String>, + pub typing_app_icon: Option<Vec<u8>>, + + // Read receipt + pub is_read_receipt: bool, + + // Delivered + pub is_delivered: bool, + + // Error + pub is_error: bool, + pub error_for_uuid: Option<String>, + pub error_status: Option<u64>, + pub error_status_str: Option<String>, + + // Peer cache invalidate + pub is_peer_cache_invalidate: bool, + + // Send delivered flag + pub send_delivered: bool, + + // Group chat UUID (persistent identifier for the group conversation) + pub sender_guid: Option<String>, + + // Delete (MoveToRecycleBin / PermanentDelete) and Recover + pub is_move_to_recycle_bin: bool, + pub is_permanent_delete: bool, + pub is_recover_chat: bool, + pub delete_chat_participants: Vec<String>, + pub delete_chat_group_id: Option<String>, + pub delete_chat_guid: Option<String>, + pub delete_message_uuids: Vec<String>, + + // Stored message detection: true if this message was queued by APNs + // while the client was offline and delivered on reconnect. Detected by + // checking if the message arrived (drain-time) within 10 seconds of an + // APNs reconnect (generated_signal). Drain-time is used instead of + // process-time so MMCS downloads and retries don't push messages past + // the window. + pub is_stored_message: bool, + + // Group photo (icon) change. True when someone sets or clears the + // group chat photo from their iMessage client. + // group_photo_cleared: true = photo was removed ("ngp"), false = new photo set. + // When is_icon_change=true and group_photo_cleared=false, icon_change_photo_data + // contains the MMCS-downloaded photo bytes (None if download failed). + pub is_icon_change: bool, + pub group_photo_cleared: bool, + // Pre-downloaded photo bytes for IconChange messages. + // Some(bytes) only when is_icon_change=true, group_photo_cleared=false, and download succeeded. + pub icon_change_photo_data: Option<Vec<u8>>, + + // HTML-formatted text body. Some(...) when the message contains any non-plain + // formatting (bold, italic, underline, strikethrough, text effects, mentions). + pub html: Option<String>, + + // Voice message flag. True for voice memo / audio message attachments. + pub is_voice: bool, + + // Screen/bubble effect identifier (e.g., "com.apple.MobileSMS.expressivesend.impact"). + pub effect: Option<String>, + + // Scheduled send timestamp in milliseconds. Some(ms) when message is scheduled. + pub scheduled_ms: Option<u64>, + + // SMS activation: true = activation request, false = deactivation. + pub is_sms_activation: Option<bool>, + + // SMS confirmed sent status. + pub is_sms_confirm_sent: Option<bool>, + + // Mark unread flag. + pub is_mark_unread: bool, + + // Message-read-on-device acknowledgment. + pub is_message_read_on_device: bool, + + // Unschedule marker for scheduled-message cancellation updates. + pub is_unschedule: bool, + + // Update extension (sticker/balloon metadata update) targeting a message UUID. + pub is_update_extension: bool, + pub update_extension_for_uuid: Option<String>, + + // Profile sharing state sync update. + pub is_update_profile_sharing: bool, + pub update_profile_sharing_dismissed: Vec<String>, + pub update_profile_sharing_all: Vec<String>, + pub update_profile_sharing_version: Option<u64>, + + // Profile update (share profile card and/or sharing preference). + pub is_update_profile: bool, + pub update_profile_share_contacts: Option<bool>, + + // "Notify anyway" control message. + pub is_notify_anyways: bool, + + // Transcript background (conversation wallpaper) update. + pub is_set_transcript_background: bool, + pub transcript_background_remove: Option<bool>, + pub transcript_background_chat_id: Option<String>, + pub transcript_background_object_id: Option<String>, + pub transcript_background_url: Option<String>, + pub transcript_background_file_size: Option<u64>, + + // Sticker data for sticker tapback reactions (tapback_type=7). + pub sticker_data: Option<Vec<u8>>, + pub sticker_mime: Option<String>, + + // Profile sharing: set when a contact shares their name/photo with us. + // record_key + decryption_key + has_poster identify the CloudKit record; + // display_name/first_name/last_name/avatar are populated inline by the + // Rust receive loop via ProfilesClient::get_record so the Go side just + // reads bytes (mirrors the IconChange MMCS download pattern). + pub is_share_profile: bool, + pub share_profile_record_key: Option<String>, + pub share_profile_decryption_key: Option<Vec<u8>>, + pub share_profile_has_poster: bool, + pub share_profile_display_name: Option<String>, + pub share_profile_first_name: Option<String>, + pub share_profile_last_name: Option<String>, + pub share_profile_avatar: Option<Vec<u8>>, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedAttachment { + pub mime_type: String, + pub filename: String, + pub uti_type: String, + pub size: u64, + pub is_inline: bool, + pub inline_data: Option<Vec<u8>>, + /// True for Live Photo attachments (Apple's "iris" flag). + pub iris: bool, +} + +/// Result of fetching a shared iMessage profile (Name & Photo Sharing). +#[derive(uniffi::Record, Clone)] +pub struct WrappedProfileRecord { + pub display_name: String, + pub first_name: String, + pub last_name: String, + pub avatar: Option<Vec<u8>>, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedStickerExtension { + pub msg_width: f64, + pub rotation: f64, + pub sai: u64, + pub scale: f64, + pub sli: u64, + pub normalized_x: f64, + pub normalized_y: f64, + pub version: u64, + pub hash: String, + pub safi: u64, + pub effect_type: i64, + pub sticker_id: String, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedShareProfileData { + pub cloud_kit_record_key: String, + pub cloud_kit_decryption_record_key: Vec<u8>, + pub low_res_wallpaper_tag: Option<Vec<u8>>, + pub wallpaper_tag: Option<Vec<u8>>, + pub message_tag: Option<Vec<u8>>, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedStatusKitInviteHandle { + pub handle: String, + pub allowed_modes: Vec<String>, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedPasswordEntryRef { + pub id: String, + pub group: Option<String>, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedPasswordSiteCounts { + pub website_meta_count: u64, + pub password_count: u64, + pub password_meta_count: u64, + pub passkey_count: u64, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedLetMeInRequest { + pub delegation_uuid: String, + pub pseud: String, + pub requestor: String, + pub nickname: Option<String>, + pub usage: Option<String>, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedConversation { + pub participants: Vec<String>, + pub group_name: Option<String>, + pub sender_guid: Option<String>, + pub is_sms: bool, +} + +impl From<&ConversationData> for WrappedConversation { + fn from(c: &ConversationData) -> Self { + Self { + participants: c.participants.clone(), + group_name: c.cv_name.clone(), + sender_guid: c.sender_guid.clone(), + is_sms: false, + } + } +} + +impl From<&WrappedConversation> for ConversationData { + fn from(c: &WrappedConversation) -> Self { + ConversationData { + participants: c.participants.clone(), + cv_name: c.group_name.clone(), + sender_guid: c.sender_guid.clone(), + after_guid: None, + } + } +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedCloudSyncChat { + pub record_name: String, + pub cloud_chat_id: String, + pub group_id: String, + /// CloudKit chat style: 43 = group, 45 = DM + pub style: i64, + pub service: String, + pub display_name: Option<String>, + pub participants: Vec<String>, + pub deleted: bool, + pub updated_timestamp_ms: u64, + /// CloudKit group photo GUID ("gpid" field). Non-null means a custom photo is set. + pub group_photo_guid: Option<String>, + /// CloudKit `filt` field: 0 = normal, 1 = filtered (spam/junk/unknown sender) + pub is_filtered: i64, +} + +fn recoverable_record_field_names(fields: &[rustpush::cloudkit_proto::record::Field]) -> Vec<String> { + fields + .iter() + .filter_map(|field| field.identifier.as_ref()?.name.clone()) + .collect() +} + +fn record_looks_chat_like(fields: &[rustpush::cloudkit_proto::record::Field]) -> bool { + let names = recoverable_record_field_names(fields); + names.iter().any(|name| matches!(name.as_str(), "stl" | "cid" | "gid" | "ptcpts" | "name" | "lah")) +} + +fn wrap_recoverable_chat(record_name: String, mut chat: rustpush::cloud_messages::CloudChat) -> Option<WrappedCloudSyncChat> { + if chat.participants.is_empty() && chat.style == 45 && !chat.last_addressed_handle.is_empty() { + chat.participants.push(rustpush::cloud_messages::CloudParticipant { + uri: chat.last_addressed_handle.clone(), + }); + } + + let has_identity = !chat.chat_identifier.is_empty() + || !chat.group_id.is_empty() + || !chat.participants.is_empty() + || chat.display_name.as_ref().is_some_and(|name| !name.is_empty()); + if !has_identity || !matches!(chat.style, 43 | 45) { + return None; + } + + let cloud_chat_id = if chat.chat_identifier.is_empty() { + record_name.clone() + } else { + chat.chat_identifier.clone() + }; + Some(WrappedCloudSyncChat { + record_name, + cloud_chat_id, + group_id: chat.group_id, + style: chat.style, + service: chat.service_name, + display_name: chat.display_name, + participants: chat.participants.into_iter().map(|p| p.uri).collect(), + deleted: false, + updated_timestamp_ms: 0, + group_photo_guid: chat.group_photo_guid, + is_filtered: chat.is_filtered, + }) +} + +#[derive(serde::Serialize)] +struct RecoverableMessageMetadata { + v: u8, + record_name: String, + cloud_chat_id: String, + sender: String, + is_from_me: bool, + service: String, + timestamp_ms: i64, +} + +fn encode_recoverable_message_entry( + guid: &str, + record_name: &str, + msg: &rustpush::cloud_messages::CloudMessage, +) -> String { + if guid.is_empty() { + return String::new(); + } + + let metadata = RecoverableMessageMetadata { + v: 1, + record_name: record_name.to_string(), + cloud_chat_id: msg.chat_id.clone(), + sender: msg.sender.clone(), + is_from_me: msg.flags.contains(rustpush::cloud_messages::MessageFlags::IS_FROM_ME), + service: msg.service.clone(), + timestamp_ms: apple_timestamp_ns_to_unix_ms(msg.time), + }; + match serde_json::to_vec(&metadata) { + Ok(payload) => format!("{}|{}", guid, BASE64_STANDARD.encode(payload)), + Err(err) => { + debug!("list_recoverable_message_guids: failed to encode metadata for {}: {}", guid, err); + guid.to_string() + } + } +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedCloudSyncMessage { + pub record_name: String, + pub guid: String, + pub cloud_chat_id: String, + pub sender: String, + pub is_from_me: bool, + pub text: Option<String>, + pub subject: Option<String>, + pub service: String, + pub timestamp_ms: i64, + pub deleted: bool, + + // Tapback/reaction fields (from msg_proto.associatedMessageType/Guid) + pub tapback_type: Option<u32>, + pub tapback_target_guid: Option<String>, + pub tapback_emoji: Option<String>, + + // Attachment GUIDs extracted from messageSummaryInfo / attributedBody. + // These are matched against the attachment zone to download files. + pub attachment_guids: Vec<String>, + + // When the recipient read this message (Apple epoch ns → Unix ms). + // Only meaningful for is_from_me messages. 0 means unread. + pub date_read_ms: i64, + + // CloudKit msgType field. 0 = regular user message, non-zero = system/service + // record (group naming, participant changes, etc.). Used by Go to filter + // non-user-content that slips past IS_SYSTEM/IS_SERVICE_MESSAGE flags. + pub msg_type: i64, + + // Whether the CloudKit record has an attributedBody (rich text payload). + // Regular user messages always have attributedBody; system/service messages + // (group renames, participant changes) never do. Used by Go to filter + // system messages that slip past flag-based filters. + pub has_body: bool, +} + +/// Metadata for an attachment referenced by a CloudKit message. +/// The actual file data must be downloaded separately via cloud_download_attachment. +#[derive(uniffi::Record, Clone)] +pub struct WrappedCloudAttachmentInfo { + /// Attachment GUID (from AttachmentMeta.guid / attributedBody __kIMFileTransferGUID) + pub guid: String, + /// MIME type (from AttachmentMeta.mime_type) + pub mime_type: Option<String>, + /// UTI type (from AttachmentMeta.uti) + pub uti_type: Option<String>, + /// Filename (from AttachmentMeta.transfer_name) + pub filename: Option<String>, + /// File size in bytes (from AttachmentMeta.total_bytes) + pub file_size: i64, + /// CloudKit record name in attachmentManateeZone (needed for download) + pub record_name: String, + /// Whether this attachment is hidden (companion transfer, e.g. Live Photo MOV). + /// When true, this is the video component of a Live Photo — not shown standalone. + pub hide_attachment: bool, + /// Whether this attachment record has a Live Photo video in its `avid` asset field. + /// When true, use cloud_download_attachment_avid to get the MOV instead of the HEIC. + pub has_avid: bool, + /// PCS-decrypted `lqa.protection_info` bytes — for Ford-encrypted videos, + /// this is the raw 32-byte Ford key used to decrypt per-chunk keys from + /// the MMCS Ford metadata blob. None for attachments without Ford + /// encryption (images use V1 per-chunk keys in the MMCS chunk metadata). + /// + /// Exposed so the Go-side Ford key cache can pre-populate during sync + /// and fall back to cached keys on MMCS cross-batch dedup. See + /// `pkg/connector/ford_cache.go`. + pub ford_key: Option<Vec<u8>>, + /// PCS-decrypted `avid.protection_info` bytes — the Live Photo MOV + /// companion's Ford key. Same semantics as `ford_key`. + pub avid_ford_key: Option<Vec<u8>>, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedCloudSyncChatsPage { + pub continuation_token: Option<String>, + pub status: i32, + pub done: bool, + pub chats: Vec<WrappedCloudSyncChat>, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedCloudSyncMessagesPage { + pub continuation_token: Option<String>, + pub status: i32, + pub done: bool, + pub messages: Vec<WrappedCloudSyncMessage>, +} + +#[derive(uniffi::Record, Clone)] +pub struct WrappedCloudSyncAttachmentsPage { + pub continuation_token: Option<String>, + pub status: i32, + pub done: bool, + pub attachments: Vec<WrappedCloudAttachmentInfo>, +} + +/// A clonable writer backed by a shared Vec<u8>. +/// Used to recover written bytes after passing ownership to a consuming API. +#[derive(Clone)] +struct SharedWriter { + inner: Arc<std::sync::Mutex<Vec<u8>>>, +} + +impl SharedWriter { + fn new() -> Self { + Self { inner: Arc::new(std::sync::Mutex::new(Vec::new())) } + } + + fn into_bytes(self) -> Vec<u8> { + match Arc::try_unwrap(self.inner) { + Ok(mutex) => mutex.into_inner().unwrap(), + Err(arc) => arc.lock().unwrap().clone(), + } + } +} + +impl std::io::Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { + self.inner.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Extract attachment GUIDs from a CloudKit message's attributedBody. +/// The attributedBody is an NSAttributedString containing ranges with +/// __kIMFileTransferGUIDAttributeName → attachment GUID. +fn extract_attachment_guids_from_attributed_body(data: &[u8]) -> Vec<String> { + use rustpush::{coder_decode_flattened, NSAttributedString, StCollapsedValue}; + + let decoded = match std::panic::catch_unwind(|| { + let flat = coder_decode_flattened(data); + if flat.is_empty() { + return None; + } + Some(NSAttributedString::decode(&flat[0])) + }) { + Ok(Some(attr_str)) => attr_str, + _ => return vec![], + }; + + let mut guids = Vec::new(); + for (_len, dict) in &decoded.ranges { + if let Some(StCollapsedValue::Object { fields, .. }) = dict.0.get("__kIMFileTransferGUIDAttributeName") { + if let Some(first) = fields.first().and_then(|f| f.first()) { + if let StCollapsedValue::String(s) = first { + guids.push(s.clone()); + } + } + } else if let Some(StCollapsedValue::String(s)) = dict.0.get("__kIMFileTransferGUIDAttributeName") { + guids.push(s.clone()); + } + } + guids +} + +/// Extract attachment GUIDs from a CloudKit message's messageSummaryInfo. +/// messageSummaryInfo is a binary plist containing an "ams" (attachment metadata +/// summary) array. Each entry is a dict with "g" = GUID. This captures GUIDs +/// for companion transfers (e.g. Live Photo MOV components) that are NOT +/// referenced in attributedBody's __kIMFileTransferGUIDAttributeName. +fn extract_attachment_guids_from_summary_info(data: &[u8]) -> Vec<String> { + let value: plist::Value = match plist::from_bytes(data) { + Ok(v) => v, + Err(e) => { + warn!("messageSummaryInfo: plist parse failed ({} bytes): {}", data.len(), e); + return vec![]; + } + }; + let dict = match value.as_dictionary() { + Some(d) => d, + None => return vec![], + }; + let mut guids = Vec::new(); + // "ams" = attachment metadata summary — array of per-attachment dicts + if let Some(plist::Value::Array(ams)) = dict.get("ams") { + for entry in ams { + if let Some(entry_dict) = entry.as_dictionary() { + // "g" = attachment GUID (file transfer GUID) + if let Some(plist::Value::String(guid)) = entry_dict.get("g") { + if !guid.is_empty() { + guids.push(guid.clone()); + } + } + } + } + } + guids +} + +fn apple_timestamp_ns_to_unix_ms(timestamp_ns: i64) -> i64 { + const APPLE_EPOCH_UNIX_MS: i64 = 978_307_200_000; + APPLE_EPOCH_UNIX_MS.saturating_add(timestamp_ns / 1_000_000) +} + +fn decode_continuation_token(token_b64: Option<String>) -> Result<Option<Vec<u8>>, WrappedError> { + match token_b64 { + Some(token) if !token.is_empty() => BASE64_STANDARD + .decode(token) + .map(Some) + .map_err(|e| WrappedError::GenericError { + msg: format!("Invalid continuation token: {}", e), + }), + _ => Ok(None), + } +} + +fn encode_continuation_token(token: Vec<u8>) -> Option<String> { + if token.is_empty() { + None + } else { + Some(BASE64_STANDARD.encode(token)) + } +} + +fn convert_reaction(reaction: &Reaction, enable: bool) -> (Option<u32>, Option<String>, bool) { + let tapback_type = match reaction { + Reaction::Heart => Some(0), + Reaction::Like => Some(1), + Reaction::Dislike => Some(2), + Reaction::Laugh => Some(3), + Reaction::Emphasize => Some(4), + Reaction::Question => Some(5), + Reaction::Emoji(_) => Some(6), + Reaction::Sticker { .. } => Some(7), + }; + let emoji = match reaction { + Reaction::Emoji(e) => Some(e.clone()), + _ => None, + }; + (tapback_type, emoji, !enable) +} + +fn populate_delete_target(w: &mut WrappedMessage, target: &DeleteTarget) { + match target { + DeleteTarget::Chat(chat) => { + w.delete_chat_participants = chat.participants.clone(); + w.delete_chat_group_id = if chat.group_id.is_empty() { + None + } else { + Some(chat.group_id.clone()) + }; + w.delete_chat_guid = if chat.guid.is_empty() { + None + } else { + Some(chat.guid.clone()) + }; + } + DeleteTarget::Messages(uuids) => { + w.delete_message_uuids = uuids.clone(); + } + } +} + +/// Convert message parts into HTML. Returns Some(html) only if there is any +/// non-plain formatting (bold/italic/underline/strikethrough, text effects, +/// or mentions). Returns None for plain-text-only messages so the Go side can +/// skip HTML encoding. +fn parts_to_html(parts: &MessageParts) -> Option<String> { + let has_formatting = parts.0.iter().any(|p| match &p.part { + MessagePart::Text(_, fmt) => !matches!(fmt, TextFormat::Flags(TextFlags { bold: false, italic: false, underline: false, strikethrough: false })), + MessagePart::Mention(_, _) => true, + _ => false, + }); + if !has_formatting { + return None; + } + + let mut html = String::new(); + for indexed_part in &parts.0 { + match &indexed_part.part { + MessagePart::Text(text, format) => { + let escaped = html_escape(text); + match format { + TextFormat::Flags(flags) => { + let mut open = String::new(); + let mut close = String::new(); + if flags.bold { open.push_str("<strong>"); close.insert_str(0, "</strong>"); } + if flags.italic { open.push_str("<em>"); close.insert_str(0, "</em>"); } + if flags.underline { open.push_str("<u>"); close.insert_str(0, "</u>"); } + if flags.strikethrough { open.push_str("<del>"); close.insert_str(0, "</del>"); } + html.push_str(&open); + html.push_str(&escaped); + html.push_str(&close); + } + TextFormat::Effect(effect) => { + let effect_name = match effect { + TextEffect::Big => "big", + TextEffect::Small => "small", + TextEffect::Shake => "shake", + TextEffect::Nod => "nod", + TextEffect::Explode => "explode", + TextEffect::Ripple => "ripple", + TextEffect::Bloom => "bloom", + TextEffect::Jitter => "jitter", + }; + html.push_str(&format!( + "<span data-mx-imessage-effect=\"{}\">", + effect_name + )); + html.push_str(&escaped); + html.push_str("</span>"); + } + } + } + MessagePart::Mention(uri, display) => { + let escaped_display = html_escape(display); + let escaped_uri = html_escape(uri); + html.push_str(&format!( + "<a href=\"{}\">@{}</a>", + escaped_uri, escaped_display + )); + } + _ => {} + } + } + + if html.is_empty() { + None + } else { + Some(html) + } +} + +/// Minimal HTML escaping for user-provided text. +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Parse Matrix HTML into iMessage MessageParts with formatting. +/// Returns None if no formatting tags are found (plain text fallback). +fn parse_html_to_parts(html: &str, plain_text: &str) -> Option<MessageParts> { + if !html.contains('<') { + return None; + } + + let mut parts: Vec<IndexedMessagePart> = Vec::new(); + let mut pos = 0; + let bytes = html.as_bytes(); + let len = bytes.len(); + let mut bold = false; + let mut italic = false; + let mut underline = false; + let mut strikethrough = false; + + while pos < len { + if bytes[pos] == b'<' { + let tag_end = match html[pos..].find('>') { + Some(e) => pos + e + 1, + None => break, + }; + let tag_content = &html[pos + 1..tag_end - 1].trim().to_lowercase(); + match tag_content.as_str() { + "strong" | "b" => bold = true, + "/strong" | "/b" => bold = false, + "em" | "i" => italic = true, + "/em" | "/i" => italic = false, + "u" => underline = true, + "/u" => underline = false, + "del" | "s" | "strike" => strikethrough = true, + "/del" | "/s" | "/strike" => strikethrough = false, + "br" | "br/" | "br /" => { + let flags = TextFlags { bold, italic, underline, strikethrough }; + parts.push(IndexedMessagePart { + part: MessagePart::Text("\n".to_string(), TextFormat::Flags(flags)), + idx: None, + ext: None, + }); + } + _ => { + // Skip unknown tags (p, div, span without effect, etc.) + } + } + pos = tag_end; + } else { + let next_tag = html[pos..].find('<').map(|i| pos + i).unwrap_or(len); + let chunk = &html[pos..next_tag]; + let decoded = chunk + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\""); + if !decoded.is_empty() { + let flags = TextFlags { bold, italic, underline, strikethrough }; + parts.push(IndexedMessagePart { + part: MessagePart::Text(decoded, TextFormat::Flags(flags)), + idx: None, + ext: None, + }); + } + pos = next_tag; + } + } + + if parts.is_empty() { + return None; + } + + let reconstructed: String = parts.iter().map(|p| match &p.part { + MessagePart::Text(t, _) => t.as_str(), + _ => "", + }).collect(); + if reconstructed == plain_text && !parts.iter().any(|p| matches!(&p.part, MessagePart::Text(_, TextFormat::Flags(f)) if f.bold || f.italic || f.underline || f.strikethrough)) { + return None; + } + + Some(MessageParts(parts)) +} + +/// Lift a ShareProfileMessage onto a WrappedMessage's share-profile keys. +/// Used by both the standalone Message::ShareProfile / UpdateProfile arms +/// and the embedded_profile field on NormalMessage / ReactMessage — +/// iOS partners send profile keys piggybacked on regular text messages +/// when Name & Photo Sharing is enabled, so the standalone control message +/// alone is not sufficient to discover all shared profiles. +/// +/// `kind` describes which message variant produced the profile, and is +/// logged so the embedded path is observable. The standalone-variant +/// "received Message::ShareProfile" log already covers those cases; this +/// log catches NormalMessage/ReactMessage embedded arrivals which would +/// otherwise be silent in the receive-loop log stream. +fn populate_share_profile_keys( + w: &mut WrappedMessage, + profile: &rustpush::ShareProfileMessage, + kind: &'static str, +) { + w.is_share_profile = true; + w.share_profile_record_key = Some(profile.cloud_kit_record_key.clone()); + w.share_profile_decryption_key = Some(profile.cloud_kit_decryption_record_key.clone()); + w.share_profile_has_poster = profile.poster.is_some(); + info!( + "populated share_profile_keys from {} (record_key_len={}, has_poster={})", + kind, + profile.cloud_kit_record_key.len(), + profile.poster.is_some() + ); +} + +const FACETIME_RING_MARKER: &str = "[[FACETIME_RING]]"; +const FACETIME_MISSED_MARKER: &str = "[[FACETIME_MISSED]]"; +const FACETIME_ANSWERED_ELSEWHERE_MARKER: &str = "[[FACETIME_ANSWERED_ELSEWHERE]]"; + +// Tracks FaceTime sessions that should ring a set of targets as soon as +// somebody *other than the caller* joins. Populated by `!im facetime` in a +// portal room: the command creates a session for the user + contact, returns +// a join link, and queues a pending ring here. When the caller later taps +// the link and a JoinEvent arrives on the APNs stream, the receive loop +// fires ft.ring() against the queued targets. We filter out the caller's +// own handle so the implicit self-join emitted during create_session does +// NOT trigger the ring prematurely — the contact's phone must only ring +// after the caller has actually joined. +struct PendingFTRing { + caller_handle: String, + targets: Vec<String>, + expires_at: std::time::Instant, +} + +static PENDING_FT_RINGS: std::sync::OnceLock<tokio::sync::Mutex<HashMap<String, PendingFTRing>>> = + std::sync::OnceLock::new(); + +fn pending_ft_rings() -> &'static tokio::sync::Mutex<HashMap<String, PendingFTRing>> { + PENDING_FT_RINGS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) +} + +async fn maybe_fire_pending_ring(ft: &rustpush::facetime::FTClient, guid: &str, joiner_handle: &str) { + let targets = { + let mut map = pending_ft_rings().lock().await; + let Some(entry) = map.get(guid) else { + return; + }; + if entry.expires_at <= std::time::Instant::now() { + map.remove(guid); + return; + } + if joiner_handle == entry.caller_handle { + // Creator's own implicit join from create_session — keep the + // entry pending and wait for a real remote/web joiner. + return; + } + let targets = entry.targets.clone(); + map.remove(guid); + targets + }; + let session = { + let state = ft.state.read().await; + match state.sessions.get(guid).cloned() { + Some(s) => s, + None => { + warn!("pending ring: session {} not found", guid); + return; + } + } + }; + match ft.ring(&session, &targets, false).await { + Ok(_) => { + info!( + "pending ring: rang {} target(s) in session {} (triggered by join from {})", + targets.len(), + guid, + joiner_handle + ); + // Flip is_ringing_inaccurate=true now that the Invitation is on + // the wire. create_session_no_ring starts it false to suppress + // prop_up_conv's RespondedElsewhere diversion; we need it true + // here so upstream's missed-call detection (facetime.rs:1411) + // trips if the callee declines / times out — otherwise a + // no-answer call silently drops instead of surfacing as Missed. + let mut state = ft.state.write().await; + if let Some(session) = state.sessions.get_mut(guid) { + session.is_ringing_inaccurate = true; + } + } + Err(e) => warn!("pending ring: ft.ring failed for session {}: {:?}", guid, e), + } +} + +// Overlay around FTClient::handle that tolerates Apple sending cmd 207/209 +// wire messages with `ConversationParticipantDidJoinContext.message = None`. +// Upstream's handler at facetime.rs:1272/1344 hard-requires that field and +// returns PushError::BadMsg when it's missing, which means the bridge never +// records the joiner in session.participants. When wife answers an +// outbound call (or when a link-tap joiner completes), her device's +// server-originated 207 trips this path and the session state diverges +// from Apple's — symptom is "this call is not available" on the callee. +// +// Strategy: always try upstream first so successful paths stay on the +// reference implementation. Only on BadMsg do we re-decode the wire +// message locally (using upstream's pub types: FTWireMessage + +// facetimep::ConversationParticipantDidJoinContext) and register the +// joiner ourselves. No upstream source changes — see feedback_no_patch_rustpush. +async fn ft_handle_with_join_recovery( + ft: &rustpush::facetime::FTClient, + msg: rustpush::APSMessage, +) -> Result<Option<rustpush::facetime::FTMessage>, rustpush::PushError> { + // Locally-mirrored wire-message shape. FTWireMessage's fields are + // private upstream, so we can't deserialize into it directly — but the + // plist schema is stable (same serde rename attrs as upstream), so a + // parallel struct here gives us the fields we need without touching + // upstream source. + #[derive(serde::Deserialize, Debug)] + #[serde(crate = "serde", rename_all = "kebab-case")] + struct LocalFTWire { + #[serde(rename = "s")] + session: String, + #[serde(default)] + client_context_data_key: Option<plist::Value>, + #[serde(default)] + participant_id_key: Option<LocalParticipantId>, + } + + #[derive(serde::Deserialize, Debug, Clone, Copy)] + #[serde(crate = "serde", untagged)] + enum LocalParticipantId { + Signed(i64), + Unsigned(u64), + } + impl LocalParticipantId { + fn as_u64(self) -> u64 { + match self { + Self::Signed(i) => i as u64, + Self::Unsigned(i) => i, + } + } + } + + // Try upstream first. + let upstream_result = ft.handle(msg.clone()).await; + if !matches!(&upstream_result, Err(rustpush::PushError::BadMsg)) { + return upstream_result; + } + + // Re-decrypt to inspect cmd and payload. identity.receive_message has + // no side effects beyond decryption, so a second call is safe. + let recv = match ft + .identity + .receive_message( + msg, + &[ + "com.apple.private.alloy.facetime.multi", + "com.apple.private.alloy.facetime.video", + ], + ) + .await + { + Ok(Some(r)) => r, + _ => return upstream_result, + }; + + // Only recover 207 (JoinEvent) and 209 (GroupUpdate). + if recv.command != 207 && recv.command != 209 { + return upstream_result; + } + + let Some(message_unenc) = recv.message_unenc else { return upstream_result }; + let Some(sender) = recv.sender.clone() else { return upstream_result }; + let Some(target) = recv.target.clone() else { return upstream_result }; + let Some(token_bytes) = recv.token.clone() else { return upstream_result }; + let Some(ns_since_epoch) = recv.ns_since_epoch else { return upstream_result }; + + let payload_value: plist::Value = match message_unenc.plist() { + Ok(v) => v, + Err(_) => return upstream_result, + }; + let wire: LocalFTWire = match plist::from_value(&payload_value) { + Ok(w) => w, + Err(_) => return upstream_result, + }; + + // Apply minimal state mutation: session lookup/creation + joiner + // registration. We intentionally skip session.unpack_members (the + // upstream helper is private) — member-list drift is cosmetic; the + // load-bearing piece is session.participants so Apple-side state + // lines up when Apple retries delivery or the callee's device + // queries participant tokens. + let mut state = ft.state.write().await; + let session = state.sessions.entry(wire.session.clone()).or_default(); + session.group_id = wire.session.clone(); + if !session.my_handles.contains(&target) { + session.my_handles.push(target.clone()); + } + let guid = session.group_id.clone(); + + let emitted = if recv.command == 207 { + let participant_id = match wire.participant_id_key { + Some(id) => id.as_u64(), + None => return upstream_result, + }; + session.participants.insert( + participant_id.to_string(), + rustpush::facetime::FTParticipant { + token: Some(base64_encode(&token_bytes)), + participant_id, + last_join_date: Some(ns_since_epoch / 1_000_000), + handle: sender.clone(), + active: None, + }, + ); + + if session.is_propped && sender.starts_with("temp:") { + // Matches upstream's behavior at facetime.rs:1315 — once someone + // has joined via link tap, the call is live and the propped-up + // invitation can retire. + let _ = ft.unprop_conv(session).await; + } + + Some(rustpush::facetime::FTMessage::JoinEvent { + guid: guid.clone(), + participant: participant_id, + handle: sender.clone(), + // ring=false: without the message field we can't tell whether + // Apple's carrying an Invitation type. Missing the ring flag + // is strictly better than failing the handshake entirely. + ring: false, + }) + } else { + // 209 (GroupUpdate) — nothing to emit; local state is best-effort. + None + }; + + info!( + "FaceTime cmd={} BadMsg recovery: session={} sender={} target={} participant={:?}", + recv.command, guid, sender, target, wire.participant_id_key, + ); + + Ok(emitted) +} + +async fn auto_approve_bridge_letmein( + facetime: &rustpush::facetime::FTClient, + request: &rustpush::facetime::LetMeInRequest, +) -> Result<(), rustpush::PushError> { + // Only auto-approve for links owned by this bridge. Persistent links + // (from get_link_for_usage) have usage=Some("bridge"); session-specific + // links (from get_session_link) have usage=None but session_link=Some. + // Both are bridge-created and safe to auto-approve. + let (link_handle, linked_group, member_group, ringing_group) = { + let state = facetime.state.read().await; + let Some(link) = state.links.get(&request.pseud) else { + return Ok(()); + }; + let is_bridge_usage = link.usage.as_deref() == Some("bridge"); + let is_session_link = link.session_link.is_some(); + if !is_bridge_usage && !is_session_link { + return Ok(()); + } + + let linked_group = link + .session_link + .clone() + .filter(|group| state.sessions.contains_key(group)); + + let member_group = state.sessions.iter().find_map(|(group, session)| { + if !session.my_handles.iter().any(|h| h == &link.handle) { + return None; + } + if session.members.iter().any(|member| member.handle == request.requestor) { + Some(group.clone()) + } else { + None + } + }); + + let ringing_group = state.sessions.iter().find_map(|(group, session)| { + if !session.my_handles.iter().any(|h| h == &link.handle) { + return None; + } + if session.is_ringing_inaccurate { + Some(group.clone()) + } else { + None + } + }); + + (link.handle.clone(), linked_group, member_group, ringing_group) + }; + + // Priority: ringing > linked > member. An actively-ringing session is + // always the user's immediate concern (inbound-call case); a stale + // session_link from a prior tap would otherwise win via `linked` and + // route the tap to the wrong session. `linked` is still preferred over + // `member` since it's a deliberate pin, and session-specific links + // (outbound !im facetime) always hit `linked` first because their + // session is fresh (is_ringing_inaccurate=false until the ring fires). + let match_kind = if ringing_group.is_some() { + "ringing" + } else if linked_group.is_some() { + "linked" + } else if member_group.is_some() { + "member" + } else { + "cold-start" + }; + info!( + "FaceTime letmein approve: match_kind={} pseud={} requestor={} link_handle={}", + match_kind, request.pseud, request.requestor, link_handle, + ); + + let approved_group = if let Some(group) = ringing_group.or(linked_group).or(member_group) { + group + } else { + let group = uuid::Uuid::new_v4().to_string().to_uppercase(); + // Cold-start fallback: the tap request doesn't map to any known + // session via linked/member/ringing. Call upstream directly — the + // strip-own-devices wrapper caused the callee not to ring (see + // WrappedFaceTimeClient::create_session for the full writeup). + facetime + .create_session(group.clone(), link_handle.clone(), &[request.requestor.clone()]) + .await?; + group + }; + + { + let mut state = facetime.state.write().await; + if let Some(link) = state.links.get_mut(&request.pseud) { + if link.session_link.as_deref() != Some(approved_group.as_str()) { + link.session_link = Some(approved_group.clone()); + } + } + } + + // respond_letmein: sends LetMeInResponse then add_members/ring over APNs. + // APNs can flap (early eof → SendTimedOut) especially right after boot. + // + // Subtlety: respond_letmein's first action for delegated requests is + // removing the delegation_uuid from delegated_requests. If the later + // send fails and we retry with the same request, it hits "Already + // responded" and no-ops silently — member never added. Strip + // delegation_uuid on retries so the check is skipped; duplicate + // LetMeInResponse sends are harmless (web client decrypts the first), + // and add_members is idempotent (already-present member triggers ring + // instead of re-add). + let mut last_err: Option<rustpush::PushError> = None; + for attempt in 0..3u32 { + let mut retry_request = request.clone(); + if attempt > 0 { + retry_request.delegation_uuid = None; + } + match facetime.respond_letmein(retry_request, Some(&approved_group)).await { + Ok(()) => { + info!( + "FaceTime auto-approved LetMeIn request for bridge link: requestor={} group={} usage=bridge", + request.requestor, + approved_group + ); + return Ok(()); + } + Err(e) => { + let is_timeout = matches!(&e, rustpush::PushError::SendTimedOut); + last_err = Some(e); + if !is_timeout || attempt >= 2 { + break; + } + warn!( + "FaceTime letmein respond_letmein timed out (attempt {}), retrying in {}s", + attempt + 1, + attempt + 1 + ); + tokio::time::sleep(std::time::Duration::from_secs((attempt + 1) as u64)).await; + } + } + } + Err(last_err.unwrap_or(rustpush::PushError::SendTimedOut)) +} + +async fn facetime_event_to_wrapped( + facetime: &rustpush::facetime::FTClient, + event: &rustpush::facetime::FTMessage, +) -> Option<WrappedMessage> { + let (guid, mut sender, marker) = match event { + rustpush::facetime::FTMessage::Ring { guid } => { + (guid.clone(), None, FACETIME_RING_MARKER) + } + rustpush::facetime::FTMessage::JoinEvent { guid, handle, ring, .. } if *ring => { + (guid.clone(), Some(handle.clone()), FACETIME_RING_MARKER) + } + rustpush::facetime::FTMessage::AddMembers { guid, members, ring } if *ring => { + let fallback = members.iter().next().map(|member| member.handle.clone()); + (guid.clone(), fallback, FACETIME_RING_MARKER) + } + rustpush::facetime::FTMessage::Decline { guid } => { + (guid.clone(), None, FACETIME_MISSED_MARKER) + } + rustpush::facetime::FTMessage::RespondedElsewhere { guid } => { + (guid.clone(), None, FACETIME_ANSWERED_ELSEWHERE_MARKER) + } + _ => return None, + }; + + let state = facetime.state.read().await; + let (participants, my_handles, link) = state + .sessions + .get(&guid) + .map(|session| { + let participants = session + .members + .iter() + .map(|member| member.handle.clone()) + .collect::<Vec<_>>(); + let my_handles: std::collections::HashSet<String> = + session.my_handles.iter().cloned().collect(); + let link = session.link.as_ref().map(|link| { + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&link.public_key); + let pseud = link + .pseudonym + .strip_prefix("temp:") + .unwrap_or(&link.pseudonym); + format!("https://facetime.apple.com/join#v=1&p={pseud}&k={encoded}") + }); + (participants, my_handles, link) + }) + .unwrap_or_else(|| (Vec::new(), std::collections::HashSet::new(), None)); + drop(state); + + if sender.is_none() { + sender = participants + .iter() + .find(|participant| { + !participant.starts_with("temp:") && !my_handles.contains(*participant) + }) + .cloned(); + } + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let event_kind = marker.trim_matches('[').trim_matches(']').to_lowercase(); + let mut wrapped = WrappedMessage::default(); + wrapped.uuid = format!("FACETIME_{}_{}_{}", event_kind.to_uppercase(), guid, now_ms); + wrapped.sender = sender; + wrapped.text = Some(match link { + Some(link) if marker == FACETIME_RING_MARKER => format!("{marker} guid={guid} {link}"), + _ => format!("{marker} guid={guid}"), + }); + wrapped.participants = participants; + wrapped.timestamp_ms = now_ms; + wrapped.is_notify_anyways = true; + Some(wrapped) +} + +fn message_inst_to_wrapped(msg: &MessageInst) -> WrappedMessage { + let conv = msg.conversation.as_ref(); + + let mut w = WrappedMessage { + uuid: msg.id.clone(), + sender: msg.sender.clone(), + text: None, + subject: None, + participants: conv.map(|c| c.participants.clone()).unwrap_or_default(), + group_name: conv.and_then(|c| c.cv_name.clone()), + timestamp_ms: msg.sent_timestamp, + is_sms: false, + is_tapback: false, + tapback_type: None, + tapback_target_uuid: None, + tapback_target_part: None, + tapback_emoji: None, + tapback_remove: false, + is_edit: false, + edit_target_uuid: None, + edit_part: None, + edit_new_text: None, + is_unsend: false, + unsend_target_uuid: None, + unsend_edit_part: None, + is_rename: false, + new_chat_name: None, + is_participant_change: false, + new_participants: vec![], + attachments: vec![], + reply_guid: None, + reply_part: None, + is_typing: false, + typing_app_bundle_id: None, + typing_app_icon: None, + is_read_receipt: false, + is_delivered: false, + is_error: false, + error_for_uuid: None, + error_status: None, + error_status_str: None, + is_peer_cache_invalidate: false, + send_delivered: msg.send_delivered, + sender_guid: conv.and_then(|c| c.sender_guid.clone()), + is_move_to_recycle_bin: false, + is_permanent_delete: false, + is_recover_chat: false, + delete_chat_participants: vec![], + delete_chat_group_id: None, + delete_chat_guid: None, + delete_message_uuids: vec![], + is_stored_message: false, // set by caller based on connection state + is_icon_change: false, + group_photo_cleared: false, + icon_change_photo_data: None, + html: None, + is_voice: false, + effect: None, + scheduled_ms: None, + is_sms_activation: None, + is_sms_confirm_sent: None, + is_mark_unread: false, + is_message_read_on_device: false, + is_unschedule: false, + is_update_extension: false, + update_extension_for_uuid: None, + is_update_profile_sharing: false, + update_profile_sharing_dismissed: vec![], + update_profile_sharing_all: vec![], + update_profile_sharing_version: None, + is_update_profile: false, + update_profile_share_contacts: None, + is_notify_anyways: false, + is_set_transcript_background: false, + transcript_background_remove: None, + transcript_background_chat_id: None, + transcript_background_object_id: None, + transcript_background_url: None, + transcript_background_file_size: None, + sticker_data: None, + sticker_mime: None, + is_share_profile: false, + share_profile_record_key: None, + share_profile_decryption_key: None, + share_profile_has_poster: false, + share_profile_display_name: None, + share_profile_first_name: None, + share_profile_last_name: None, + share_profile_avatar: None, + }; + + match &msg.message { + Message::Message(normal) => { + w.text = Some(normal.parts.raw_text()); + w.subject = normal.subject.clone(); + w.reply_guid = normal.reply_guid.clone(); + w.reply_part = normal.reply_part.clone(); + w.is_sms = matches!(normal.service, MessageType::SMS { .. }); + // iOS piggybacks Name & Photo Sharing keys on regular text + // messages from contacts who have sharing enabled; lift them so + // the receive loop's inline download still fires. + if let Some(profile) = &normal.embedded_profile { + populate_share_profile_keys(&mut w, profile, "NormalMessage.embedded_profile"); + } + + for indexed_part in &normal.parts.0 { + if let MessagePart::Attachment(att) = &indexed_part.part { + let (is_inline, inline_data, size) = match &att.a_type { + AttachmentType::Inline(data) => (true, Some(data.clone()), data.len() as u64), + AttachmentType::MMCS(mmcs) => (false, None, mmcs.size as u64), + }; + w.attachments.push(WrappedAttachment { + mime_type: att.mime.clone(), + filename: att.name.clone(), + uti_type: att.uti_type.clone(), + size, + is_inline, + inline_data, + iris: att.iris, + }); + } + } + + // Encode rich link as special attachments for the Go side + if let Some(ref lm) = normal.link_meta { + let original_url: String = lm + .data + .original_url + .clone() + .map(|u| -> String { u.into() }) + .unwrap_or_default(); + let url: String = lm.data.url.clone().map(|u| u.into()).unwrap_or_default(); + let title = lm.data.title.clone().unwrap_or_default(); + let summary = lm.data.summary.clone().unwrap_or_default(); + + info!("Inbound rich link: original_url={}, url={}, title={:?}, summary={:?}, has_image={}, has_icon={}", + original_url, url, title, summary, + lm.data.image.is_some(), lm.data.icon.is_some()); + + let image_mime = if let Some(ref img) = lm.data.image { + img.mime_type.clone() + } else if let Some(ref icon) = lm.data.icon { + icon.mime_type.clone() + } else { + String::new() + }; + + // Metadata: original_url\x01url\x01title\x01summary\x01image_mime + let meta = format!("{}\x01{}\x01{}\x01{}\x01{}", + original_url, url, title, summary, image_mime); + w.attachments.push(WrappedAttachment { + mime_type: "x-richlink/meta".to_string(), + filename: String::new(), + uti_type: String::new(), + size: 0, + is_inline: true, + inline_data: Some(meta.into_bytes()), + iris: false, + }); + + // Image data (from image or icon) + let image_data = if let Some(ref img) = lm.data.image { + let idx = img.rich_link_image_attachment_substitute_index as usize; + lm.attachments.get(idx).cloned() + } else if let Some(ref icon) = lm.data.icon { + let idx = icon.rich_link_image_attachment_substitute_index as usize; + lm.attachments.get(idx).cloned() + } else { + None + }; + + if let Some(img_data) = image_data { + w.attachments.push(WrappedAttachment { + mime_type: "x-richlink/image".to_string(), + filename: String::new(), + uti_type: String::new(), + size: img_data.len() as u64, + is_inline: true, + inline_data: Some(img_data), + iris: false, + }); + } + } + + // HTML formatting + w.html = parts_to_html(&normal.parts); + + // Voice message flag + w.is_voice = normal.voice; + + // Screen/bubble effects + w.effect = normal.effect.as_ref().map(|e| e.to_string()); + + // Scheduled send + if let Some(ref sched) = normal.scheduled { + w.scheduled_ms = Some(sched.ms); + } + + // Sticker data from extension balloons (icon field) + if let Some(ref app) = normal.app { + if let Some(ref balloon) = app.balloon { + if let Some(ref icon_data) = balloon.icon { + if !icon_data.is_empty() { + w.sticker_data = Some(icon_data.clone()); + w.sticker_mime = Some("image/png".to_string()); + } + } + } + } + } + Message::React(react) => { + w.is_tapback = true; + w.tapback_target_uuid = Some(react.to_uuid.clone()); + w.tapback_target_part = react.to_part; + match &react.reaction { + ReactMessageType::React { reaction, enable } => { + let (tt, emoji, remove) = convert_reaction(reaction, *enable); + w.tapback_type = tt; + w.tapback_emoji = emoji; + w.tapback_remove = remove; + } + ReactMessageType::Extension { .. } => { + // Extension reactions (stickers etc.) — mark as tapback + w.tapback_type = Some(7); + } + } + // Reactions can also carry a piggybacked profile (same pattern + // as text messages). Surface it for inline download. + if let Some(profile) = &react.embedded_profile { + populate_share_profile_keys(&mut w, profile, "ReactMessage.embedded_profile"); + } + } + Message::Edit(edit) => { + w.is_edit = true; + w.edit_target_uuid = Some(edit.tuuid.clone()); + w.edit_part = Some(edit.edit_part); + w.edit_new_text = Some(edit.new_parts.raw_text()); + } + Message::Unsend(unsend) => { + w.is_unsend = true; + w.unsend_target_uuid = Some(unsend.tuuid.clone()); + w.unsend_edit_part = Some(unsend.edit_part); + } + Message::RenameMessage(rename) => { + w.is_rename = true; + w.new_chat_name = Some(rename.new_name.clone()); + } + Message::ChangeParticipants(change) => { + w.is_participant_change = true; + w.new_participants = change.new_participants.clone(); + } + Message::Typing(typing, app) => { + w.is_typing = *typing; + if let Some(app) = app { + w.typing_app_bundle_id = Some(app.bundle_id.clone()); + w.typing_app_icon = Some(app.icon.clone()); + } + } + Message::Read => { + w.is_read_receipt = true; + } + Message::Delivered => { + w.is_delivered = true; + } + Message::Error(err) => { + w.is_error = true; + w.error_for_uuid = Some(err.for_uuid.clone()); + w.error_status = Some(err.status); + w.error_status_str = Some(err.status_str.clone()); + } + Message::PeerCacheInvalidate => { + w.is_peer_cache_invalidate = true; + } + Message::MoveToRecycleBin(del) => { + w.is_move_to_recycle_bin = true; + populate_delete_target(&mut w, &del.target); + } + Message::PermanentDelete(del) => { + w.is_permanent_delete = true; + populate_delete_target(&mut w, &del.target); + } + Message::RecoverChat(chat) => { + w.is_recover_chat = true; + w.delete_chat_participants = chat.participants.clone(); + w.delete_chat_group_id = Some(chat.group_id.clone()).filter(|s| !s.is_empty()); + w.delete_chat_guid = Some(chat.guid.clone()).filter(|s| !s.is_empty()); + } + Message::IconChange(change) => { + w.is_icon_change = true; + w.group_photo_cleared = change.file.is_none(); + } + Message::EnableSmsActivation(enable) => { + w.is_sms_activation = Some(*enable); + } + Message::SmsConfirmSent(status) => { + w.is_sms_confirm_sent = Some(*status); + } + Message::MarkUnread => { + w.is_mark_unread = true; + } + Message::MessageReadOnDevice => { + w.is_message_read_on_device = true; + } + Message::Unschedule => { + w.is_unschedule = true; + } + Message::UpdateExtension(update) => { + w.is_update_extension = true; + w.update_extension_for_uuid = Some(update.for_uuid.clone()); + } + Message::NotifyAnyways => { + w.is_notify_anyways = true; + } + Message::ShareProfile(profile) => { + populate_share_profile_keys(&mut w, profile, "Message::ShareProfile"); + } + Message::UpdateProfile(update) => { + w.is_update_profile = true; + w.update_profile_share_contacts = Some(update.share_contacts); + if let Some(profile) = &update.profile { + populate_share_profile_keys(&mut w, profile, "Message::UpdateProfile.profile"); + } + } + Message::UpdateProfileSharing(update) => { + w.is_update_profile_sharing = true; + w.update_profile_sharing_dismissed = update.shared_dismissed.clone(); + w.update_profile_sharing_all = update.shared_all.clone(); + w.update_profile_sharing_version = Some(update.version); + } + Message::SetTranscriptBackground(update) => { + w.is_set_transcript_background = true; + match update { + SetTranscriptBackgroundMessage::Remove { chat_id, remove, .. } => { + w.transcript_background_remove = Some(*remove); + w.transcript_background_chat_id = chat_id.clone(); + } + SetTranscriptBackgroundMessage::Set { chat_id, object_id, url, file_size, .. } => { + w.transcript_background_remove = Some(false); + w.transcript_background_chat_id = chat_id.clone(); + w.transcript_background_object_id = Some(object_id.clone()); + w.transcript_background_url = Some(url.clone()); + w.transcript_background_file_size = Some(*file_size as u64); + } + } + } + _ => {} + } + + w +} + +// ============================================================================ +// Callback interfaces +// ============================================================================ + +#[uniffi::export(callback_interface)] +pub trait MessageCallback: Send + Sync { + fn on_message(&self, msg: WrappedMessage); +} + +#[uniffi::export(callback_interface)] +pub trait UpdateUsersCallback: Send + Sync { + fn update_users(&self, users: Arc<WrappedIDSUsers>); +} + +/// Callback invoked when an iMessage contact's presence state changes. +/// `user` is an iMessage handle (e.g. "tel:+1..." or "mailto:..."). When +/// `available` is false, `mode` may carry a Focus/DND mode identifier such +/// as "com.apple.donotdisturb.mode.default". +#[uniffi::export(callback_interface)] +pub trait StatusCallback: Send + Sync { + fn on_status_update(&self, user: String, mode: Option<String>, available: bool); + /// Called when StatusKit receives a key-sharing message, which adds new + /// encryption keys to the internal state. The Go side should re-subscribe + /// to presence so that APNs channels are created for the newly-available keys. + fn on_keys_received(&self); +} + +// ============================================================================ +// Top-level functions +// ============================================================================ + +#[uniffi::export] +pub fn init_logger() { + if std::env::var("RUST_LOG").is_err() { + // Default log filter: silence the entire `rustpush` crate at WARN + // (upstream OpenBubbles has ~80+ info! sites across mmcs, cloudkit, + // keychain, cloud_messages, pcs, aps, util, statuskit, findmy, and + // more — vastly more chatty than master's vendored tree). Keep our + // own `rustpushgo` wrapper at INFO so Ford recovery messages, + // relay wiring, and other wrapper-level diagnostics surface. + // + // The Go-side bridge (`component=imessage`, `component=cloud_sync`, + // etc.) writes through zerolog and is unaffected — those logs come + // through at whatever level the Go bridge is configured for. + // + // If you need to debug rustpush internals, override at invocation + // time with e.g.: + // RUST_LOG=info,rustpush=info # all rustpush info logs + // RUST_LOG=info,rustpush::icloud=debug # deep cloudkit/mmcs/pcs + // RUST_LOG=debug # everything + std::env::set_var( + "RUST_LOG", + "warn,rustpush=info,rustpushgo=info", + ); + } + let _ = pretty_env_logger::try_init(); + + // Install a panic hook that silences upstream's `.unwrap()` / + // `.expect()` panics inside the CloudKit download path. These panics + // are intentional — the wrapper's Ford dedup recovery loops wrap + // each get_assets / download_attachment call in catch_unwind and + // brute-force cached Ford keys until one decrypts successfully. The + // default Rust panic hook runs BEFORE catch_unwind catches, so each + // wrong-key attempt floods stderr with lines like + // thread '<unnamed>' panicked at mmcs.rs:1113:101 + // called `Result::unwrap()` on an `Err` value + // thread '<unnamed>' panicked at cloudkit.rs:2075 + // No bundled asset! + // This hook silently drops panics from upstream's MMCS + CloudKit + // download code paths (all caught by the wrapper) and falls through + // to the default hook for everything else — real panics from other + // modules still surface normally. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Some(loc) = info.location() { + let file = loc.file(); + // Match both Unix and Windows path separators. Files listed + // here are ones whose panics are intercepted by + // catch_unwind in pkg/rustpushgo/src/lib.rs download recovery. + let noisy = [ + "icloud/mmcs.rs", + "icloud\\mmcs.rs", + "icloud/cloudkit.rs", + "icloud\\cloudkit.rs", + ]; + if noisy.iter().any(|p| file.ends_with(p)) { + return; + } + } + default_hook(info); + })); + + // Initialize the keystore with a file-backed software keystore. + // This must be called before any rustpush operations (APNs connect, login, etc.). + // + // The keystore lives alongside session.json in the XDG data directory + // (~/.local/share/mautrix-imessage/) so that all session state is in one + // place and easy to migrate between machines. + let xdg_dir = resolve_xdg_data_dir(); + let state_path = format!("{}/keystore.plist", xdg_dir); + let _ = std::fs::create_dir_all(&xdg_dir); + + // Migrate from the old location (state/keystore.plist relative to working + // directory) if the new file doesn't exist yet. + let legacy_path = "state/keystore.plist"; + if !std::path::Path::new(&state_path).exists() { + if std::path::Path::new(legacy_path).exists() { + match std::fs::copy(legacy_path, &state_path) { + Ok(_) => info!( + "Migrated keystore from {} to {}", + legacy_path, state_path + ), + Err(e) => warn!( + "Failed to migrate keystore from {} to {}: {}", + legacy_path, state_path, e + ), + } + } + } + + let state: SoftwareKeystoreState = match std::fs::read(&state_path) { + Ok(data) => plist::from_bytes(&data).unwrap_or_else(|e| { + warn!("Failed to parse keystore at {}: {} — starting with empty keystore", state_path, e); + SoftwareKeystoreState::default() + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + info!("No keystore file at {} — starting fresh", state_path); + SoftwareKeystoreState::default() + } + Err(e) => { + warn!("Failed to read keystore at {}: {} — starting with empty keystore", state_path, e); + SoftwareKeystoreState::default() + } + }; + let path_for_closure = state_path.clone(); + init_keystore(SoftwareKeystore { + state: RwLock::new(state), + update_state: Box::new(move |s| { + let _ = plist::to_file_xml(&path_for_closure, s); + }), + encryptor: NoEncryptor, + }); +} + +/// Resolve the XDG data directory for mautrix-imessage session state. +/// Uses $XDG_DATA_HOME if set, otherwise ~/.local/share. +/// Returns the full path: <base>/mautrix-imessage +fn resolve_xdg_data_dir() -> String { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + return format!("{}/mautrix-imessage", xdg); + } + } + if let Some(home) = std::env::var("HOME").ok().filter(|h| !h.is_empty()) { + return format!("{}/.local/share/mautrix-imessage", home); + } + // Last resort — fall back to old relative path + warn!("Could not determine HOME or XDG_DATA_HOME, using local state directory"); + "state".to_string() +} + +fn subsystem_state_path(file_name: &str) -> String { + let xdg_dir = resolve_xdg_data_dir(); + let _ = std::fs::create_dir_all(&xdg_dir); + format!("{}/{}", xdg_dir, file_name) +} + +fn read_plist_state<T: serde::de::DeserializeOwned>(path: &str) -> Option<T> { + match std::fs::read(path) { + Ok(data) => match plist::from_bytes(&data) { + Ok(state) => Some(state), + Err(err) => { + warn!("Failed to parse state at {}: {}", path, err); + None + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => { + warn!("Failed to read state at {}: {}", path, err); + None + } + } +} + +fn persist_plist_state<T: serde::Serialize>(path: &str, state: &T) { + if let Err(err) = plist::to_file_xml(path, state) { + warn!("Failed to persist state to {}: {}", path, err); + } +} + +fn serialize_state_json<T: serde::Serialize>(state: &T) -> Result<String, WrappedError> { + serde_json::to_string(state).map_err(|err| WrappedError::GenericError { + msg: format!("Failed to serialize state: {}", err), + }) +} + +/// Create a local macOS config that reads hardware info from IOKit +/// and uses AAAbsintheContext for NAC validation (no SIP disable, no relay needed). +/// Only works on macOS — returns an error on other platforms. +#[uniffi::export] +pub fn create_local_macos_config() -> Result<Arc<WrappedOSConfig>, WrappedError> { + #[cfg(target_os = "macos")] + { + let config = local_config::LocalMacOSConfig::new() + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to read hardware info: {}", e) })? + .into_macos_config(); + Ok(Arc::new(WrappedOSConfig { + config: Arc::new(config), + has_nac_relay: false, + relay_url: None, + relay_token: None, + })) + } + #[cfg(not(target_os = "macos"))] + { + Err(WrappedError::GenericError { + msg: "Local macOS config is only available on macOS. Use create_config_from_hardware_key instead.".into(), + }) + } +} + +/// Create a local macOS config with a persisted device ID. +/// Only works on macOS — returns an error on other platforms. +#[uniffi::export] +pub fn create_local_macos_config_with_device_id(device_id: String) -> Result<Arc<WrappedOSConfig>, WrappedError> { + #[cfg(target_os = "macos")] + { + let config = local_config::LocalMacOSConfig::new() + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to read hardware info: {}", e) })? + .with_device_id(device_id) + .into_macos_config(); + Ok(Arc::new(WrappedOSConfig { + config: Arc::new(config), + has_nac_relay: false, + relay_url: None, + relay_token: None, + })) + } + #[cfg(not(target_os = "macos"))] + { + Err(WrappedError::GenericError { + msg: "Local macOS config is only available on macOS. Use create_config_from_hardware_key_with_device_id instead.".into(), + }) + } +} + +/// Create a cross-platform config from a base64-encoded JSON hardware key. +/// +/// The hardware key is a JSON-serialized `HardwareConfig` extracted once from +/// a real Mac (e.g., via copper's QR code tool). This config uses the +/// open-absinthe NAC emulator to generate fresh validation data on any platform. +/// +/// On macOS this is not needed (use `create_local_macos_config` instead). +/// Building with the `hardware-key` feature links open-absinthe + unicorn. +#[uniffi::export] +pub fn create_config_from_hardware_key(base64_key: String) -> Result<Arc<WrappedOSConfig>, WrappedError> { + _create_config_from_hardware_key_inner(base64_key, None) +} + +/// Create a cross-platform config from a base64-encoded JSON hardware key +/// with a persisted device ID. +#[uniffi::export] +pub fn create_config_from_hardware_key_with_device_id(base64_key: String, device_id: String) -> Result<Arc<WrappedOSConfig>, WrappedError> { + _create_config_from_hardware_key_inner(base64_key, Some(device_id)) +} + +#[cfg(feature = "hardware-key")] +fn _create_config_from_hardware_key_inner(base64_key: String, device_id: Option<String>) -> Result<Arc<WrappedOSConfig>, WrappedError> { + use base64::{Engine, engine::general_purpose::STANDARD}; + use rustpush::macos::{MacOSConfig, HardwareConfig}; + use serde::Deserialize; + + // Local wire-compatible struct: upstream MacOSConfig only has + // inner/version/protocol_version/device_id/icloud_ua/aoskit_version/udid. + // Our hardware-key JSON contract (shipped to existing users) ALSO carries + // nac_relay_url/relay_token/relay_cert_fp for the Apple Silicon relay + // path. Parsing into upstream MacOSConfig would drop the relay fields, + // so we parse into a local struct that holds everything and then split. + #[derive(Deserialize)] + struct FullHardwareKey { + #[serde(default)] + inner: Option<HardwareConfig>, + #[serde(default)] + version: Option<String>, + #[serde(default)] + protocol_version: Option<u32>, + #[serde(default)] + device_id: Option<String>, + #[serde(default)] + icloud_ua: Option<String>, + #[serde(default)] + aoskit_version: Option<String>, + #[serde(default)] + udid: Option<String>, + // NAC relay fields (Apple Silicon hw keys that can't be driven by + // the unicorn x86-64 emulator). When set, these are stashed into + // wrapper-level static state via register_nac_relay() for the + // open-absinthe ValidationCtx Relay variant to consume. + #[serde(default)] + nac_relay_url: Option<String>, + #[serde(default)] + relay_token: Option<String>, + #[serde(default)] + relay_cert_fp: Option<String>, + } + + // Strip whitespace/newlines that chat clients may insert when pasting + let clean_key: String = base64_key.chars().filter(|c| !c.is_whitespace()).collect(); + let json_bytes = STANDARD.decode(&clean_key) + .map_err(|e| WrappedError::GenericError { msg: format!("Invalid base64: {}", e) })?; + + // Try parsing as FullHardwareKey first (extract-key tool output, may be + // the full MacOSConfig-shaped blob with relay fields). Fall back to + // bare HardwareConfig for legacy keys that are just the hw blob. + let (hw, version, protocol_version, icloud_ua, aoskit_version, nac_relay_url, relay_token, relay_cert_fp) = + if let Ok(full) = serde_json::from_slice::<FullHardwareKey>(&json_bytes) { + if let Some(hw) = full.inner { + let version = full.version + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "13.6.4".to_string()); + let protocol_version = full.protocol_version + .filter(|v| *v != 0) + .unwrap_or(1660); + // get_normal_ua() expects icloud_ua to contain whitespace so + // it can split out the "com.apple.iCloudHelper/..." prefix. + let icloud_ua = full.icloud_ua + .filter(|v| v.split_once(char::is_whitespace).is_some()) + .unwrap_or_else(|| "com.apple.iCloudHelper/282 CFNetwork/1568.100.1 Darwin/22.5.0".to_string()); + let aoskit_version = full.aoskit_version + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "com.apple.AOSKit/282 (com.apple.accountsd/113)".to_string()); + ( + hw, + version, + protocol_version, + icloud_ua, + aoskit_version, + full.nac_relay_url, + full.relay_token, + full.relay_cert_fp, + ) + } else { + // JSON parsed as FullHardwareKey but has no `inner` field — + // retry as bare HardwareConfig. Preserve any relay fields that + // were already parsed into `full` — dropping them here would + // silently skip register_nac_relay() for keys that carry relay + // info at the top level without a nested `inner` object. + let hw: HardwareConfig = serde_json::from_slice(&json_bytes) + .map_err(|e| WrappedError::GenericError { msg: format!("Invalid hardware key JSON: {}", e) })?; + let version = full.version + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "13.6.4".to_string()); + let protocol_version = full.protocol_version + .filter(|v| *v != 0) + .unwrap_or(1660); + let icloud_ua = full.icloud_ua + .filter(|v| v.split_once(char::is_whitespace).is_some()) + .unwrap_or_else(|| "com.apple.iCloudHelper/282 CFNetwork/1568.100.1 Darwin/22.5.0".to_string()); + let aoskit_version = full.aoskit_version + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "com.apple.AOSKit/282 (com.apple.accountsd/113)".to_string()); + ( + hw, + version, + protocol_version, + icloud_ua, + aoskit_version, + full.nac_relay_url, + full.relay_token, + full.relay_cert_fp, + ) + } + } else { + let hw: HardwareConfig = serde_json::from_slice(&json_bytes) + .map_err(|e| WrappedError::GenericError { msg: format!("Invalid hardware key JSON: {}", e) })?; + ( + hw, + "13.6.4".to_string(), + 1660, + "com.apple.iCloudHelper/282 CFNetwork/1568.100.1 Darwin/22.5.0".to_string(), + "com.apple.AOSKit/282 (com.apple.accountsd/113)".to_string(), + None, + None, + None, + ) + }; + + // Always use the real hardware UUID from the extracted key so the bridge + // shows up as the original Mac rather than a new phantom device. + // Ignore any persisted device ID — it may be a stale random UUID. + let hw_uuid = hw.platform_uuid.to_uppercase(); + if let Some(ref old) = device_id { + if old != &hw_uuid { + log::warn!( + "Ignoring persisted device ID {} — using hardware UUID {} from extracted key", + old, hw_uuid + ); + } + } + let device_id = hw_uuid; + + let _ = relay_cert_fp; + + // Build upstream's MacOSConfig with only the fields it has — relay + // fields live in wrapper state, not on the OSConfig. + let config = Arc::new(MacOSConfig { + inner: hw, + version, + protocol_version, + device_id: device_id.clone(), + icloud_ua, + aoskit_version, + // Avoid panics in codepaths that expect a UDID (Find My, CloudKit, etc). + // Using the device UUID is sufficient. + udid: Some(device_id), + }); + + // For Apple Silicon keys with a relay, wrap in RelayOSConfig so + // generate_validation_data() calls the relay directly — same as master. + let os_config: Arc<dyn OSConfig> = if let Some(url) = nac_relay_url { + let trimmed = url.trim_end_matches('/'); + let relay_url = if trimmed.ends_with("/validation-data") { + trimmed.to_string() + } else { + format!("{}/validation-data", trimmed) + }; + Arc::new(RelayOSConfig { + inner: config, + relay_url, + relay_token: relay_token.clone(), + }) + } else { + config + }; + + Ok(Arc::new(WrappedOSConfig { + config: os_config, + has_nac_relay: relay_token.is_some(), + relay_url: None, + relay_token: None, + })) +} + +#[cfg(not(feature = "hardware-key"))] +fn _create_config_from_hardware_key_inner(base64_key: String, _device_id: Option<String>) -> Result<Arc<WrappedOSConfig>, WrappedError> { + let _ = base64_key; + Err(WrappedError::GenericError { + msg: "Hardware key support not available in this build. \ + On macOS, use the Apple ID login flow instead (which uses native validation). \ + To enable hardware key support, rebuild with: cargo build --features hardware-key".into(), + }) +} + +#[uniffi::export(async_runtime = "tokio")] +pub async fn connect( + config: &WrappedOSConfig, + state: &WrappedAPSState, +) -> Arc<WrappedAPSConnection> { + ensure_crypto_provider(); + let config = config.config.clone(); + let state = state.inner.clone(); + let (connection, error) = APSConnectionResource::new(config, state).await; + if let Some(error) = error { + error!("APS connection error (non-fatal, will retry): {}", error); + } + Arc::new(WrappedAPSConnection { inner: connection }) +} + +/// Login session object that holds state between login steps. +#[derive(uniffi::Object)] +pub struct LoginSession { + account: tokio::sync::Mutex<Option<AppleAccount<BridgeDefaultAnisetteProvider>>>, + username: String, + password_hash: Vec<u8>, + needs_2fa: bool, +} + +#[uniffi::export(async_runtime = "tokio")] +pub async fn login_start( + apple_id: String, + password: String, + config: &WrappedOSConfig, + connection: &WrappedAPSConnection, +) -> Result<Arc<LoginSession>, WrappedError> { + ensure_crypto_provider(); + let os_config = config.config.clone(); + let conn = connection.inner.clone(); + + let user_trimmed = apple_id.trim().to_string(); + // Apple's GSA SRP expects the password to be pre-hashed with SHA-256. + // See upstream test.rs: sha256(password.as_bytes()) + let pw_bytes = { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(password.trim().as_bytes()); + hasher.finalize().to_vec() + }; + + let client_info = os_config.get_gsa_config(&*conn.state.read().await, false); + info!("login_start: mme_client_info={}", client_info.mme_client_info); + info!("login_start: mme_client_info_akd={}", client_info.mme_client_info_akd); + info!("login_start: akd_user_agent={}", client_info.akd_user_agent); + info!("login_start: hardware_headers={:?}", client_info.hardware_headers); + info!("login_start: push_token={:?}", client_info.push_token); + let anisette_state_path = PathBuf::from_str("state/anisette").unwrap(); + let state_plist = anisette_state_path.join("state.plist"); + info!("login_start: anisette state path={:?} exists={}", state_plist, state_plist.exists()); + + let anisette = bridge_default_provider(client_info.clone(), anisette_state_path); + + let mut account = AppleAccount::new_with_anisette(client_info, anisette) + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to create account: {}", e) })?; + + info!("login_start: calling login_email_pass for {}", user_trimmed); + let result = account.login_email_pass(&user_trimmed, &pw_bytes).await + .map_err(|e| WrappedError::GenericError { msg: format!("Login failed: {}", e) })?; + info!("login_start: login_email_pass returned ok"); + + info!("login_email_pass returned: {:?}", result); + let needs_2fa = match result { + icloud_auth::LoginState::LoggedIn => { + info!("Login completed without 2FA"); + false + } + icloud_auth::LoginState::Needs2FAVerification => { + info!("2FA required (Needs2FAVerification — push already sent by Apple)"); + true + } + icloud_auth::LoginState::NeedsDevice2FA | icloud_auth::LoginState::NeedsSMS2FA => { + info!("2FA required — sending trusted device push"); + match account.send_2fa_to_devices().await { + Ok(_) => info!("send_2fa_to_devices succeeded"), + Err(e) => error!("send_2fa_to_devices failed: {}", e), + } + true + } + icloud_auth::LoginState::NeedsSMS2FAVerification(_) => { + info!("2FA required (NeedsSMS2FAVerification — SMS already sent)"); + true + } + icloud_auth::LoginState::NeedsExtraStep(ref step) => { + if account.get_pet().is_some() { + info!("Login completed (extra step ignored, PET available)"); + false + } else { + return Err(WrappedError::GenericError { msg: format!("Login requires extra step: {}", step) }); + } + } + icloud_auth::LoginState::NeedsLogin => { + return Err(WrappedError::GenericError { msg: "Login failed - bad credentials".to_string() }); + } + }; + + Ok(Arc::new(LoginSession { + account: tokio::sync::Mutex::new(Some(account)), + username: user_trimmed, + password_hash: pw_bytes, + needs_2fa, + })) +} + +#[uniffi::export(async_runtime = "tokio")] +impl LoginSession { + pub fn needs_2fa(&self) -> bool { + self.needs_2fa + } + + pub async fn submit_2fa(&self, code: String) -> Result<bool, WrappedError> { + let mut guard = self.account.lock().await; + let account = guard.as_mut().ok_or(WrappedError::GenericError { msg: "No active session".to_string() })?; + + info!("Verifying 2FA code via trusted device endpoint (verify_2fa)"); + let result = account.verify_2fa(code).await + .map_err(|e| WrappedError::GenericError { msg: format!("2FA verification failed: {}", e) })?; + + info!("2FA verification returned: {:?}", result); + info!("PET token available: {}", account.get_pet().is_some()); + + match result { + icloud_auth::LoginState::LoggedIn => Ok(true), + icloud_auth::LoginState::NeedsExtraStep(_) => { + Ok(account.get_pet().is_some()) + } + _ => Ok(false), + } + } + + pub async fn finish( + &self, + config: &WrappedOSConfig, + connection: &WrappedAPSConnection, + existing_identity: Option<Arc<WrappedIDSNGMIdentity>>, + existing_users: Option<Arc<WrappedIDSUsers>>, + ) -> Result<IDSUsersWithIdentityRecord, WrappedError> { + let os_config = config.config.clone(); + let conn = connection.inner.clone(); + + let mut guard = self.account.lock().await; + let account = guard.as_mut().ok_or(WrappedError::GenericError { msg: "No active session".to_string() })?; + + let pet = account.get_pet() + .ok_or(WrappedError::GenericError { msg: "No PET token available after login".to_string() })?; + + let spd = account.spd.as_ref().expect("No SPD after login"); + let adsid = spd.get("adsid").expect("No adsid").as_string().unwrap().to_string(); + let dsid = spd.get("DsPrsId").or_else(|| spd.get("dsid")) + .and_then(|v| { + if let Some(s) = v.as_string() { + Some(s.to_string()) + } else if let Some(i) = v.as_signed_integer() { + Some(i.to_string()) + } else if let Some(i) = v.as_unsigned_integer() { + Some(i.to_string()) + } else { + None + } + }) + .unwrap_or_default(); + + // Build persist data before delegates call (while we have SPD access) + let hashed_password_hex = account.hashed_password.as_ref() + .map(|p| encode_hex(p)) + .unwrap_or_default(); + let mut spd_bytes = Vec::new(); + plist::to_writer_binary(&mut spd_bytes, spd) + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to serialize SPD: {}", e) })?; + let spd_base64 = base64_encode(&spd_bytes); + + let account_persist = AccountPersistData { + username: self.username.clone(), + hashed_password_hex, + pet: pet.clone(), + adsid: adsid.clone(), + dsid: dsid.clone(), + spd_base64, + }; + + // Request both IDS (for messaging) and MobileMe (for contacts CardDAV URL) + let delegates = login_apple_delegates( + &*account, + None, + &*os_config, + &[LoginDelegate::IDS, LoginDelegate::MobileMe], + ).await.map_err(|e| WrappedError::GenericError { msg: format!("Failed to get delegates: {}", e) })?; + + let ids_delegate = delegates.ids.ok_or(WrappedError::GenericError { msg: "No IDS delegate in response".to_string() })?; + let fresh_user = authenticate_apple(ids_delegate, &*os_config).await + .map_err(|e| WrappedError::GenericError { msg: format!("IDS authentication failed: {}", e) })?; + + // Resolve identity: reuse existing or generate new + let identity = match existing_identity { + Some(wrapped) => { + info!("Reusing existing identity (avoiding new device notification)"); + wrapped.inner.clone() + } + None => { + info!("Generating new identity (first login)"); + IDSNGMIdentity::new() + .map_err(|e| WrappedError::GenericError { + msg: format!("Failed to create identity: {}", e) + })? + } + }; + + // Decide whether to reuse existing registration or register fresh. + let users = match existing_users { + Some(ref wrapped) if !wrapped.inner.is_empty() => { + let has_valid_registration = wrapped.inner[0] + .registration.get(MADRID_SERVICE.name) + .map(|r| r.calculate_rereg_time_s().map(|t| t > 0).unwrap_or(false)) + .unwrap_or(false); + let has_required_services = wrapped.inner[0].registration.contains_key(MADRID_SERVICE.name) + && wrapped.inner[0].registration.contains_key(MULTIPLEX_SERVICE.name); + + if has_valid_registration && has_required_services { + info!("Reusing existing registration (still valid for iMessage services, skipping register endpoint)"); + let mut existing = wrapped.inner.clone(); + existing[0].auth_keypair = fresh_user.auth_keypair.clone(); + existing + } else { + info!( + "Existing registration missing required services or expired, must re-register" + ); + let mut users = vec![fresh_user]; + register( + &*os_config, + &*conn.state.read().await, + &[&MADRID_SERVICE, &MULTIPLEX_SERVICE], + &mut users, + &identity, + ).await.map_err(|e| WrappedError::GenericError { msg: format!("Registration failed: {}", e) })?; + users + } + } + _ => { + let mut users = vec![fresh_user]; + if users[0].registration.is_empty() { + info!("Registering identity (first login)..."); + register( + &*os_config, + &*conn.state.read().await, + &[&MADRID_SERVICE, &MULTIPLEX_SERVICE], + &mut users, + &identity, + ).await.map_err(|e| WrappedError::GenericError { msg: format!("Registration failed: {}", e) })?; + } + users + } + }; + + // Take ownership of the account to create a TokenProvider. + // The MobileMe delegate from `delegates` is seeded into the WrappedTokenProvider + // so the first get_contacts_url() / create_keychain_clients() call doesn't + // need to re-fetch. + let owned_account = guard.take() + .ok_or(WrappedError::GenericError { msg: "Account already consumed".to_string() })?; + let account_arc = Arc::new(rustpush::DebugMutex::new(owned_account)); + let token_provider = TokenProvider::new(account_arc.clone(), os_config.clone()); + + // Store the MobileMe delegate in the WrappedTokenProvider as opaque plist + // bytes. Upstream `MobileMeDelegateResponse` is not `Serialize`, so we + // manually build a `plist::Value` that matches its serde representation + // (`{"tokens": {...}, "com.apple.mobileme": {...}}`). We can access the + // public fields of `delegates.mobileme` via field syntax without naming + // the type — Rust field access on expressions of unnameable types is + // permitted. The parse_mme_delegate() helper deserializes back into + // MobileMeDelegateResponse at call sites where a typed value is needed. + let mme_delegate_bytes = if let Some(mobileme) = &delegates.mobileme { + let mut tokens_dict = plist::Dictionary::new(); + for (k, v) in &mobileme.tokens { + tokens_dict.insert(k.clone(), plist::Value::String(v.clone())); + } + let mut root = plist::Dictionary::new(); + root.insert("tokens".to_string(), plist::Value::Dictionary(tokens_dict)); + root.insert( + "com.apple.mobileme".to_string(), + plist::Value::Dictionary(mobileme.config.clone()), + ); + let mut buf = Vec::new(); + plist::Value::Dictionary(root).to_writer_xml(&mut buf) + .map_err(|e| WrappedError::GenericError { + msg: format!("Failed to serialize MobileMe delegate: {}", e), + })?; + Some(buf) + } else { + None + }; + + Ok(IDSUsersWithIdentityRecord { + users: Arc::new(WrappedIDSUsers { inner: users }), + identity: Arc::new(WrappedIDSNGMIdentity { inner: identity }), + token_provider: Some(Arc::new(WrappedTokenProvider { + inner: token_provider, + account: account_arc, + os_config: os_config.clone(), + mme_delegate_bytes: tokio::sync::Mutex::new(mme_delegate_bytes), + keychain_clients_cache: tokio::sync::Mutex::new(None), + })), + account_persist: Some(account_persist), + }) + } +} + +// ============================================================================ +// Attachment download helper +// ============================================================================ + +/// Fetch an iMessage MMCS `AuthorizeGetResponse` body via the APNs auth +/// dance, without invoking upstream's `MMCSFile::get_attachment` (which +/// calls the panic-prone `get_mmcs`). +/// +/// This is the same protocol upstream rustpush uses at +/// `third_party/rustpush-upstream/src/imessage/messages.rs` in +/// `MMCSFile::get_attachment` up through line 1381, inlined into the +/// wrapper so we can stop at the point where upstream would hand off to +/// `get_mmcs` and instead route the bytes through +/// `manual_ford::manual_ford_download_asset`. +/// +/// Returns the raw response bytes — the caller decodes them as an +/// `mmcsp::AuthorizeGetResponse` and runs them through the manual +/// download. +async fn fetch_imessage_mmcs_authorize_body( + mmcs: &rustpush::MMCSFile, + conn: &rustpush::APSConnectionResource, +) -> Result<Vec<u8>, String> { + use futures::FutureExt; + use plist::Value; + use rand::Rng; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize)] + struct RequestMMCSDownload { + #[serde(rename = "mO")] + object: String, + #[serde(rename = "mS")] + signature: plist::Data, + v: u64, + ua: String, + c: u64, + i: u32, + #[serde(rename = "cH")] + headers: String, + #[serde(rename = "mR")] + domain: String, + #[serde(rename = "cV")] + cv: u32, + } + + #[derive(Serialize, Deserialize)] + struct MMCSDownloadResponse { + #[serde(rename = "cB")] + response: plist::Data, + #[serde(rename = "mU")] + #[allow(dead_code)] + object: String, + } + + // User agents / client info strings — must exactly match upstream's + // `MMCSFile::get_attachment` construction so the MMCS server accepts + // our auth request. + let mme_client_info = conn + .os_config + .get_mme_clientinfo("com.apple.icloud.content/1950.19 (com.apple.Messenger/1.0)"); + let mini_ua = conn.os_config.get_version_ua(); + + // Strip the last `/{object}` segment from the URL to produce the + // MMCS domain — upstream does this to build the `mR` field. + let domain = mmcs.url.replace(&format!("/{}", &mmcs.object), ""); + + let msg_id: u32 = rand::thread_rng().gen(); + let header = format!("x-mme-client-info:{}", mme_client_info); + let request_download = RequestMMCSDownload { + object: mmcs.object.to_string(), + c: 151, + ua: mini_ua, + headers: [ + "x-apple-mmcs-proto-version:5.0", + "x-apple-mmcs-plist-sha256:fvj0Y/Ybu1pq0r4NxXw3eP51exujUkEAd7LllbkTdK8=", + "x-apple-mmcs-plist-version:v1.0", + &header, + "", + ] + .join("\n"), + v: 8, + domain, + cv: 2, + i: msg_id, + signature: mmcs.signature.to_vec().into(), + }; + + // Subscribe BEFORE send to avoid the race where the response arrives + // before the receiver is registered. + let recv = conn.subscribe().await; + + // Upstream's send_message now takes `impl Serialize` and handles plist + // encoding internally (was: pre-serialized Vec<u8> + plist_to_bin at the + // call site). The id parameter is also i32 now, not u32. + conn.send_message("com.apple.madrid", request_download, Some(msg_id as i32)) + .await + .map_err(|e| format!("APNs send_message: {e}"))?; + + // Wait for a Notification on com.apple.madrid where `c == 151` and + // `i == msg_id`. Topic filtering is done by sha1-hash compare, which + // we can't easily replicate without `rustpush::util::sha1`, so we + // check the parsed payload fields directly and trust APNs routing + // to only deliver com.apple.madrid messages on this subscription. + let predicate = move |msg: rustpush::APSMessage| -> Option<Value> { + // Upstream APSPackedValue::Into<Value> maps each packed-attribute + // kind to its corresponding plist::Value variant (aps.rs:31-42), + // so MMCS auth responses with a Dict-encoded payload arrive as + // Value::Dictionary, while Data-encoded payloads arrive as + // Value::Data carrying raw plist bytes. Accept both — matches + // upstream's get_message helper, which doesn't constrain the + // variant. + let rustpush::APSMessage::Notification { payload, .. } = msg else { return None }; + let parsed = match payload { + plist::Value::Data(bytes) => plist::from_bytes::<Value>(&bytes).ok()?, + v => v, + }; + let dict = parsed.as_dictionary()?; + let c = dict.get("c")?.as_unsigned_integer()?; + let i_val = dict.get("i")?; + let i = i_val + .as_unsigned_integer() + .map(|v| v as u32) + .or_else(|| i_val.as_signed_integer().map(|v| v as u32))?; + if c == 151 && i == msg_id { + Some(parsed) + } else { + None + } + }; + + // The `wait_for_timeout` future can in principle panic on malformed + // APNs messages (rustpush's aps internals have `unwrap`s). Wrap in + // catch_unwind so a panic here returns an Err instead of crashing + // the bridge. + let wait_fut = conn.wait_for_timeout(recv, predicate); + let reader = match std::panic::AssertUnwindSafe(wait_fut).catch_unwind().await { + Ok(Ok(v)) => v, + Ok(Err(e)) => return Err(format!("APNs wait_for_timeout: {e}")), + Err(_) => return Err("APNs wait_for_timeout panicked".to_string()), + }; + + let apns_response: MMCSDownloadResponse = + plist::from_value(&reader).map_err(|e| format!("plist decode response: {e}"))?; + let body: Vec<u8> = apns_response.response.into(); + Ok(body) +} + +/// Download any MMCS (non-inline) attachments from the message and convert them +/// to inline data in the wrapped message, so the Go side can upload them to Matrix. +/// +/// This function reimplements upstream's `MMCSFile::get_attachment` at +/// the wrapper layer so we can use our V1+V2-capable +/// `manual_ford_download_asset` instead of upstream's panicking +/// `get_mmcs`. Master worked because Cameron's rustpush fork had the +/// 94f7b8e Ford fix baked into `get_mmcs`; after the refactor's source +/// swap to OpenBubbles upstream (which doesn't have the fix), calling +/// `att.get_attachment` on V2-Ford or dedup'd records panics. Routing +/// the bytes through the manual path gets us back to master's behavior +/// without patching rustpush. +async fn download_mmcs_attachments( + wrapped: &mut WrappedMessage, + msg_inst: &MessageInst, + conn: &rustpush::APSConnectionResource, +) { + if let Message::Message(normal) = &msg_inst.message { + let mut att_idx = 0; + for indexed_part in &normal.parts.0 { + if let MessagePart::Attachment(att) = &indexed_part.part { + if let AttachmentType::MMCS(mmcs) = &att.a_type { + if att_idx < wrapped.attachments.len() { + match download_one_mmcs_attachment(mmcs, conn, &att.name).await { + Ok(buf) => { + info!( + "Downloaded MMCS attachment: {} ({} bytes)", + att.name, + buf.len() + ); + wrapped.attachments[att_idx].is_inline = true; + wrapped.attachments[att_idx].inline_data = Some(buf); + } + Err(e) => { + error!( + "Failed to download MMCS attachment {}: {}", + att.name, e + ); + } + } + } + } + att_idx += 1; + } + } + } +} + +/// Download one MMCS attachment via the wrapper-level path: +/// APNs auth handshake → manual V1+V2 Ford-capable chunk decode → +/// iMessage outer AES-256-CTR unwrap. +/// +/// Equivalent to upstream's `MMCSFile::get_attachment` but bypasses the +/// panicking `get_mmcs` call at its tail. +/// +/// iMessage MMCS differs from CloudKit MMCS in one important way: the +/// file bytes are additionally wrapped in an `IMessageContainer` layer +/// (AES-256-CTR with `MMCSFile.key` and a zero nonce). Upstream handles +/// this by passing a `WriteContainer` impl through to `get_mmcs` that +/// decrypts during chunk writes. Since our manual path returns the +/// assembled chunk plaintext without that hook, we apply the outer +/// unwrap here as a post-processing step. +async fn download_one_mmcs_attachment( + mmcs: &rustpush::MMCSFile, + conn: &rustpush::APSConnectionResource, + name: &str, +) -> Result<Vec<u8>, String> { + // Step 1: APNs auth handshake → AuthorizeGetResponse body bytes. + let body = fetch_imessage_mmcs_authorize_body(mmcs, conn).await?; + + // Step 2: decrypt via the same V1+V2-capable manual path used by + // CloudKit downloads. Pass an empty Ford key because iMessage MMCS + // chunk encryption uses the per-chunk `meta.encryption_key` field + // (V1 AES-128-CFB) — there's no outer Ford SIV layer on the iMessage + // side. + let user_agent = conn.os_config.get_normal_ua("IMTransferAgent/1000"); + let chunked_plaintext = manual_ford::manual_ford_download_asset( + &body, + &mmcs.signature, + &[], + &user_agent, + name, + ) + .await?; + + // Step 3: iMessage outer unwrap — AES-256-CTR(mmcs.key, zero_iv) + // over the full assembled plaintext. This mirrors what upstream's + // `IMessageContainer` does on the write path during `get_mmcs`. + // Matches `third_party/rustpush-upstream/src/imessage/messages.rs` + // `IMessageContainer::new(&self.key, writer, true)` at line 1341. + if mmcs.key.len() != 32 { + return Err(format!( + "iMessage MMCS key has unexpected length {}: expected 32 for AES-256-CTR", + mmcs.key.len() + )); + } + let iv = [0u8; 16]; + let unwrapped = openssl::symm::decrypt( + openssl::symm::Cipher::aes_256_ctr(), + &mmcs.key, + Some(&iv), + &chunked_plaintext, + ) + .map_err(|e| format!("iMessage outer AES-256-CTR unwrap: {e}"))?; + + Ok(unwrapped) +} + +/// Download the group photo for an IconChange message via MMCS. +/// Apple delivers group photo changes as MMCS file transfers inside IconChange +/// APNs messages — NOT via CloudKit. This downloads the photo inline so the +/// Go side can use msg.icon_change_photo_data directly without any CloudKit +/// round-trip (which would fail for Apple-set photos anyway). +async fn download_icon_change_photo( + wrapped: &mut WrappedMessage, + msg_inst: &MessageInst, + conn: &rustpush::APSConnectionResource, +) { + if let Message::IconChange(change) = &msg_inst.message { + if let Some(mmcs_file) = &change.file { + let att = Attachment { + a_type: AttachmentType::MMCS(mmcs_file.clone()), + part: 0, + uti_type: String::new(), + mime: String::from("image/jpeg"), + name: String::from("group_photo"), + iris: false, + }; + let mut buf: Vec<u8> = Vec::new(); + match att.get_attachment(conn, &mut buf, |_, _| {}).await { + Ok(()) => { + info!("Downloaded group photo via MMCS ({} bytes)", buf.len()); + wrapped.icon_change_photo_data = Some(buf); + } + Err(e) => { + error!("Failed to download group photo via MMCS: {}", e); + } + } + } + } +} + +// ============================================================================ +// Client +// ============================================================================ + +#[derive(uniffi::Object)] +pub struct WrappedFindMyClient { + inner: Arc<rustpush::findmy::FindMyClient<BridgeDefaultAnisetteProvider>>, +} + +#[uniffi::export(async_runtime = "tokio")] +impl WrappedFindMyClient { + pub async fn export_state_json(&self) -> Result<String, WrappedError> { + let state = self.inner.state.state.lock().await; + serialize_state_json(&*state) + } + + pub async fn sync_item_positions(&self) -> Result<(), WrappedError> { + self.inner.sync_item_positions().await?; + Ok(()) + } + + pub async fn accept_item_share(&self, circle_id: String) -> Result<(), WrappedError> { + self.inner.accept_item_share(&circle_id).await?; + Ok(()) + } + + pub async fn update_beacon_name( + &self, + associated_beacon: String, + role_id: i64, + name: String, + emoji: String, + ) -> Result<(), WrappedError> { + let record = rustpush::findmy::BeaconNamingRecord { + associated_beacon, + role_id, + name, + emoji, + }; + self.inner.update_beacon_name(&record).await?; + Ok(()) + } + + pub async fn delete_shared_item(&self, id: String, remove_beacon: bool) -> Result<(), WrappedError> { + self.inner.delete_shared_item(&id, remove_beacon).await?; + Ok(()) + } +} + +#[derive(uniffi::Object)] +pub struct WrappedFaceTimeClient { + inner: Arc<rustpush::facetime::FTClient>, +} + +#[uniffi::export(async_runtime = "tokio")] +impl WrappedFaceTimeClient { + pub async fn export_state_json(&self) -> Result<String, WrappedError> { + let state = self.inner.state.read().await; + serialize_state_json(&*state) + } + + pub async fn use_link_for(&self, old_usage: String, usage: String) -> Result<(), WrappedError> { + self.inner.use_link_for(&old_usage, &usage).await?; + Ok(()) + } + + pub async fn get_link_for_usage(&self, handle: String, usage: String) -> Result<String, WrappedError> { + Ok(self.inner.get_link_for_usage(&handle, &usage).await?) + } + + pub async fn clear_links(&self) -> Result<(), WrappedError> { + self.inner.clear_links().await?; + Ok(()) + } + + pub async fn delete_link(&self, pseud: String) -> Result<(), WrappedError> { + self.inner.delete_link(&pseud).await?; + Ok(()) + } + + pub async fn get_session_link(&self, guid: String) -> Result<String, WrappedError> { + Ok(self.inner.get_session_link(&guid).await?) + } + + // Deterministically binds the FTLink (matched by handle + usage) to a + // session group_id by setting link.session_link. Without this, the + // persistent "bridge" link has session_link=None until the first + // letmein-tap, and auto_approve_bridge_letmein falls through to + // member/ringing heuristics. Under cold-start or stale-state conditions + // those heuristics miss and the approver creates a fresh empty session, + // producing the "0 people" symptom. + pub async fn bind_bridge_link_to_session( + &self, + handle: String, + usage: String, + group_id: String, + ) -> Result<(), WrappedError> { + let mut state = self.inner.state.write().await; + let pseud = state + .links + .iter() + .find(|(_, link)| link.handle == handle && link.usage.as_deref() == Some(&usage)) + .map(|(pseud, _)| pseud.clone()) + .ok_or_else(|| WrappedError::GenericError { + msg: format!("No FaceTime link found for handle={} usage={}", handle, usage), + })?; + if let Some(link) = state.links.get_mut(&pseud) { + link.session_link = Some(group_id); + } + Ok(()) + } + + pub async fn create_session(&self, group_id: String, handle: String, participants: Vec<String>) -> Result<(), WrappedError> { + // Call upstream directly. We previously wrapped this with a + // strip-own-from-session.members pattern to stop the wire ring from + // fanning out to the owner's other Apple devices (Mac, iPad). The + // motivation was tap-routing fragility: when the Mac auto-answered + // via Continuity, it sent RespondedElsewhere back to the bridge, + // which cleared is_ringing_inaccurate, which broke the + // auto_approve_bridge_letmein ringing-group fallback for link taps. + // + // bind_bridge_link_to_session (added alongside this change) pins the + // bridge FaceTime link's session_link to the outgoing session the + // moment it's created, so the letmein approver's linked_group branch + // matches deterministically regardless of is_ringing_inaccurate. The + // strip's original justification is moot. + // + // Empirically the strip also correlated with the callee not ringing + // (own was absent from update_context.members and fanout_groupmembers + // in the Invitation wire, which we suspect Apple's FT routing + // rejected). Straight upstream sends a well-formed Invitation. Side + // effect: owner's devices ring too; caller dismisses on the device + // they don't want. Future: suppress own-device ring via a follow-up + // RespondedElsewhere once we've confirmed the callee ring is stable. + self.inner + .create_session(group_id, handle, &participants) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("FTClient::create_session failed: {:?}", e), + })?; + Ok(()) + } + + // Create a FaceTime session without ringing any participant. Used by the + // portal-room !im facetime flow: session is allocated + registered with + // Apple's relay (so the join link is valid), but no Invitation wire is + // fanned out. The contact's phone rings only when the caller taps the + // link — that JoinEvent triggers maybe_fire_pending_ring which calls + // ft.ring() with the queued targets. + // + // Difference from create_session: + // - is_ringing_inaccurate starts false, so prop_up_conv's !ring branch + // doesn't divert into RespondedElsewhere (facetime.rs:708). + // - prop_up_conv(session, false) so the wire message carries no + // ConversationMessageType::Invitation on any target (facetime.rs:759). + // + // Net effect on Apple's side: session allocated + propped (state=live) + // but nobody's device rings. + pub async fn create_session_no_ring( + &self, + group_id: String, + handle: String, + participants: Vec<String>, + ) -> Result<(), WrappedError> { + use rustpush::facetime::{FTMember, FTMode, FTSession}; + use std::collections::HashMap; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let members = participants + .iter() + .chain(std::iter::once(&handle)) + .map(|p| FTMember { + nickname: None, + handle: p.clone(), + }) + .collect(); + + let session = FTSession { + group_id: group_id.clone(), + my_handles: vec![handle.clone()], + participants: HashMap::new(), + link: None, + members, + report_id: uuid::Uuid::new_v4().to_string().to_uppercase(), + start_time: Some(now_ms), + last_rekey: None, + is_propped: false, + is_ringing_inaccurate: false, + mode: Some(FTMode::Outgoing), + recent_member_adds: HashMap::new(), + }; + + let mut state = self.inner.state.write().await; + state.sessions.insert(group_id.clone(), session); + let session = state.sessions.get_mut(&group_id).expect("just inserted"); + + self.inner + .ensure_allocations(session, &[]) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("ensure_allocations failed: {:?}", e), + })?; + self.inner + .prop_up_conv(session, false) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("prop_up_conv(ring=false) failed: {:?}", e), + })?; + Ok(()) + } + + pub async fn add_members( + &self, + session_id: String, + handles: Vec<String>, + letmein: bool, + to_members: Option<Vec<String>>, + ) -> Result<(), WrappedError> { + let mut session = { + let state = self.inner.state.read().await; + state.sessions.get(&session_id).cloned().ok_or(WrappedError::GenericError { + msg: format!("FaceTime session not found: {}", session_id), + })? + }; + + let members = handles + .into_iter() + .map(|handle| rustpush::facetime::FTMember { + nickname: None, + handle, + }) + .collect::<Vec<_>>(); + + self.inner.add_members(&mut session, members, letmein, to_members).await?; + + let mut state = self.inner.state.write().await; + state.sessions.insert(session_id, session); + Ok(()) + } + + pub async fn remove_members(&self, session_id: String, handles: Vec<String>) -> Result<(), WrappedError> { + let mut session = { + let state = self.inner.state.read().await; + state.sessions.get(&session_id).cloned().ok_or(WrappedError::GenericError { + msg: format!("FaceTime session not found: {}", session_id), + })? + }; + + let members = handles + .into_iter() + .map(|handle| rustpush::facetime::FTMember { + nickname: None, + handle, + }) + .collect::<Vec<_>>(); + + self.inner.remove_members(&mut session, members).await?; + + let mut state = self.inner.state.write().await; + state.sessions.insert(session_id, session); + Ok(()) + } + + pub async fn ring(&self, session_id: String, targets: Vec<String>, letmein: bool) -> Result<(), WrappedError> { + let session = { + let state = self.inner.state.read().await; + state.sessions.get(&session_id).cloned().ok_or(WrappedError::GenericError { + msg: format!("FaceTime session not found: {}", session_id), + })? + }; + self.inner.ring(&session, &targets, letmein).await?; + Ok(()) + } + + // Queue a ring that fires as soon as a participant *other than the + // caller* joins this session. The portal !facetime command uses this so + // the contact's phone doesn't ring until the caller has actually tapped + // the join link. caller_handle is the session creator's own handle; + // join events from that handle are ignored so the implicit self-join + // from create_session does not fire the ring immediately. Entries + // self-expire after ttl_secs to avoid orphan rings. + pub async fn register_pending_ring( + &self, + session_id: String, + caller_handle: String, + targets: Vec<String>, + ttl_secs: u64, + ) -> Result<(), WrappedError> { + let mut map = pending_ft_rings().lock().await; + map.insert(session_id, PendingFTRing { + caller_handle, + targets, + expires_at: std::time::Instant::now() + std::time::Duration::from_secs(ttl_secs), + }); + Ok(()) + } + + pub async fn list_delegated_letmein_requests(&self) -> Vec<WrappedLetMeInRequest> { + let delegated = self.inner.delegated_requests.lock().await; + delegated + .iter() + .map(|(uuid, request)| WrappedLetMeInRequest { + delegation_uuid: uuid.clone(), + pseud: request.pseud.clone(), + requestor: request.requestor.clone(), + nickname: request.nickname.clone(), + usage: request.usage.clone(), + }) + .collect() + } + + pub async fn respond_delegated_letmein( + &self, + delegation_uuid: String, + approved_group: Option<String>, + ) -> Result<(), WrappedError> { + let request = { + let delegated = self.inner.delegated_requests.lock().await; + delegated.get(&delegation_uuid).cloned().ok_or(WrappedError::GenericError { + msg: format!("Delegated LetMeIn request not found: {}", delegation_uuid), + })? + }; + self.inner.respond_letmein(request, approved_group.as_deref()).await?; + Ok(()) + } +} + +#[derive(uniffi::Object)] +pub struct WrappedPasswordsClient { + inner: Arc<rustpush::passwords::PasswordManager<BridgeDefaultAnisetteProvider>>, +} + +#[uniffi::export(async_runtime = "tokio")] +impl WrappedPasswordsClient { + pub async fn export_state_json(&self) -> Result<String, WrappedError> { + let state = self.inner.state.read().await; + serialize_state_json(&*state) + } + + pub async fn sync_passwords(&self) -> Result<(), WrappedError> { + self.inner.sync_passwords(&self.inner.conn).await?; + Ok(()) + } + + pub async fn accept_invite(&self, invite_id: String) -> Result<(), WrappedError> { + self.inner.accept_invite(&invite_id).await?; + Ok(()) + } + + pub async fn decline_invite(&self, invite_id: String) -> Result<(), WrappedError> { + self.inner.decline_invite(&invite_id).await?; + Ok(()) + } + + pub async fn query_handle(&self, handle: String) -> Result<bool, WrappedError> { + Ok(self.inner.query_handle(&handle).await?) + } + + pub async fn create_group(&self, name: String) -> Result<String, WrappedError> { + Ok(self.inner.create_group(&name).await?) + } + + pub async fn rename_group(&self, id: String, new_name: String) -> Result<(), WrappedError> { + self.inner.rename_group(&id, &new_name).await?; + Ok(()) + } + + pub async fn remove_group(&self, id: String) -> Result<(), WrappedError> { + self.inner.remove_group(&id).await?; + Ok(()) + } + + pub async fn invite_user(&self, group_id: String, handle: String) -> Result<(), WrappedError> { + self.inner.invite_user(&group_id, &handle).await?; + Ok(()) + } + + pub async fn remove_user(&self, group_id: String, handle: String) -> Result<(), WrappedError> { + self.inner.remove_user(&group_id, &handle).await?; + Ok(()) + } + + pub async fn list_password_raw_entry_refs(&self) -> Vec<WrappedPasswordEntryRef> { + self.inner + .get_password_entries::<rustpush::passwords::PasswordRawEntry>() + .await + .into_iter() + .map(|(id, (group, _))| WrappedPasswordEntryRef { id, group }) + .collect() + } + + pub async fn get_password_site_counts(&self, site: String) -> WrappedPasswordSiteCounts { + let cfg = self.inner.get_password_for_site(site).await; + WrappedPasswordSiteCounts { + website_meta_count: if cfg.website_meta.is_some() { 1 } else { 0 }, + password_count: cfg.passwords.len() as u64, + password_meta_count: cfg.passwords_meta.len() as u64, + passkey_count: cfg.passkeys.len() as u64, + } + } + + pub async fn upsert_password_raw_entry( + &self, + id: String, + site: String, + account: String, + secret_data: Vec<u8>, + group: Option<String>, + ) -> Result<(), WrappedError> { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let entry = rustpush::passwords::PasswordRawEntry { + cdat: now_ms, + mdat: now_ms, + srvr: site, + acct: account, + agrp: "com.apple.cfnetwork".to_string(), + data: secret_data, + }; + self.inner + .insert_password_entry::<rustpush::passwords::PasswordRawEntry>(&id, &entry, group) + .await?; + Ok(()) + } + + pub async fn delete_password_raw_entry(&self, id: String, group: Option<String>) -> Result<(), WrappedError> { + self.inner + .delete_password_entry::<rustpush::passwords::PasswordRawEntry>(&id, group) + .await?; + Ok(()) + } +} + +#[derive(uniffi::Object)] +pub struct WrappedStatusKitClient { + inner: Arc<rustpush::statuskit::StatusKitClient<BridgeDefaultAnisetteProvider>>, + interests: tokio::sync::Mutex<Vec<rustpush::statuskit::ChannelInterestToken>>, +} + +#[uniffi::export(async_runtime = "tokio")] +impl WrappedStatusKitClient { + pub async fn export_state_json(&self) -> Result<String, WrappedError> { + let state = self.inner.state.read().await; + serialize_state_json(&*state) + } + + pub async fn roll_keys(&self) { + self.inner.roll_keys().await; + } + + pub async fn reset_keys(&self) { + self.inner.reset_keys().await; + } + + pub async fn share_status(&self, active: bool, mode: Option<String>) -> Result<(), WrappedError> { + let status = if active { + rustpush::statuskit::StatusKitStatus::new_active() + } else { + rustpush::statuskit::StatusKitStatus::new_away(mode.ok_or(WrappedError::GenericError { + msg: "Mode is required when sharing away status".into(), + })?) + }; + self.inner.share_status(&status).await?; + Ok(()) + } + + pub async fn invite_to_channel( + &self, + sender_handle: String, + handles: Vec<WrappedStatusKitInviteHandle>, + ) -> Result<(), WrappedError> { + let handle_names: Vec<&str> = handles.iter().map(|h| h.handle.as_str()).collect(); + info!( + "StatusKit manual invite: sender={} handles={:?} (with modes)", + sender_handle, handle_names + ); + let mapped = handles + .into_iter() + .map(|h| { + let modes_debug = h.allowed_modes.clone(); + info!("StatusKit manual invite target: {} modes={:?}", h.handle, modes_debug); + ( + h.handle, + rustpush::statuskit::StatusKitPersonalConfig { + allowed_modes: h.allowed_modes, + }, + ) + }) + .collect::<HashMap<_, _>>(); + info!("StatusKit manual invite: calling invite_to_channel..."); + self.inner.invite_to_channel(&sender_handle, mapped).await?; + info!("StatusKit manual invite: invite_to_channel completed OK"); + Ok(()) + } + + pub async fn request_handles(&self, handles: Vec<String>) { + let token = self.inner.request_handles(&handles).await; + self.interests.lock().await.push(token); + } + + pub async fn clear_interest_tokens(&self) { + self.interests.lock().await.clear(); + } + + /// Returns contact handles with a confirmed-live StatusKit channel — i.e. + /// at least one channel for that peer has delivered a real status message + /// (recent_channels.last_msg_ns > 1). A peer with only placeholder + /// channels (last_msg_ns == 1) is NOT returned here, because that state + /// typically indicates the ratchet has drifted: the peer rotated to a + /// channel id the bridge hasn't discovered, and the stored channels + /// will never carry a status. Excluding them lets the Go-side re-invite + /// loop re-key that peer on its next tick (bounded by the per-peer 4h + /// KV backoff). Peers who have genuinely never toggled Focus since + /// keying will incur at most one extra invite every 4h, which upstream + /// processes as a c=227 no-op. + /// + /// Each returned entry is a "from" handle (e.g. "mailto:user@icloud.com" + /// or "tel:+1..."). Used by the Go layer both to suppress re-invites for + /// already-keyed peers and to resolve mailto:/tel: aliases via the IDS + /// correlation cache. + /// + /// Since upstream's StatusKitSharedDevice::from is private, this reads + /// the plist file directly. + pub async fn get_known_handles(&self) -> Vec<String> { + let state_path = subsystem_state_path("statuskit-state.plist"); + let data = match std::fs::read(&state_path) { + Ok(d) => d, + Err(_) => return Vec::new(), + }; + let value = match plist::from_bytes::<plist::Value>(&data) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let Some(dict) = value.as_dictionary() else { return Vec::new() }; + let Some(keys_dict) = dict.get("keys").and_then(|v| v.as_dictionary()) else { + return Vec::new(); + }; + + // Collect channel ids (base64-encoded, matching the keys-dict key + // format) that have received a real status message. recent_channels + // stores the id as plist::Data; the keys dict uses its base64 form. + use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; + use std::collections::HashSet; + let mut confirmed_ids: HashSet<String> = HashSet::new(); + if let Some(rc) = dict.get("recent_channels").and_then(|v| v.as_array()) { + for entry in rc { + let Some(d) = entry.as_dictionary() else { continue }; + let last_ns = d.get("last_msg_ns").and_then(|v| v.as_unsigned_integer()).unwrap_or(0); + if last_ns <= 1 { + continue; + } + let Some(ident) = d.get("identifier").and_then(|v| v.as_dictionary()) else { continue }; + let Some(id_data) = ident.get("id").and_then(|v| v.as_data()) else { continue }; + confirmed_ids.insert(B64.encode(id_data)); + } + } + + // A handle is reported as known if ANY of its channels is confirmed. + // Dedup by handle so a single confirmed channel still covers peers + // with duplicate placeholder entries. + let mut seen: HashSet<String> = HashSet::new(); + let mut handles = Vec::new(); + for (channel_id, entry) in keys_dict { + if !confirmed_ids.contains(channel_id) { + continue; + } + let Some(entry_dict) = entry.as_dictionary() else { continue }; + let Some(from_str) = entry_dict.get("from").and_then(|v| v.as_string()) else { continue }; + if seen.insert(from_str.to_string()) { + handles.push(from_str.to_string()); + } + } + handles + } + +} + +#[derive(uniffi::Record)] +pub struct SharedAlbumInfo { + pub albumguid: String, + pub name: Option<String>, + pub fullname: Option<String>, + pub email: Option<String>, +} + +#[derive(uniffi::Record)] +pub struct SharedAssetInfo { + pub assetguid: String, + pub filename: String, + pub date_created: String, + pub media_type: String, + pub width: String, + pub height: String, + pub size: String, +} + +#[derive(uniffi::Object)] +pub struct WrappedSharedStreamsClient { + inner: Arc<rustpush::sharedstreams::SharedStreamClient<BridgeDefaultAnisetteProvider>>, +} + +#[uniffi::export(async_runtime = "tokio")] +impl WrappedSharedStreamsClient { + pub async fn export_state_json(&self) -> Result<String, WrappedError> { + let state = self.inner.state.read().await; + serialize_state_json(&*state) + } + + pub async fn list_album_ids(&self) -> Vec<String> { + let state = self.inner.state.read().await; + state.albums.iter().map(|album| album.albumguid.clone()).collect() + } + + pub async fn get_changes(&self) -> Result<Vec<String>, WrappedError> { + Ok(self.inner.get_changes().await?) + } + + pub async fn subscribe(&self, album: String) -> Result<(), WrappedError> { + self.inner.subscribe(&album).await?; + Ok(()) + } + + pub async fn unsubscribe(&self, album: String) -> Result<(), WrappedError> { + self.inner.unsubscribe(&album).await?; + Ok(()) + } + + pub async fn subscribe_token(&self, token: String) -> Result<(), WrappedError> { + self.inner.subscribe_token(&token).await?; + Ok(()) + } + + pub async fn get_album_summary(&self, album: String) -> Result<Vec<String>, WrappedError> { + Ok(self.inner.get_album_summary(&album).await?) + } + + pub async fn get_assets_json(&self, album: String, assets: Vec<String>) -> Result<String, WrappedError> { + let details = self.inner.get_assets(&album, &assets).await?; + serde_json::to_string(&details).map_err(|e| WrappedError::GenericError { + msg: format!("Failed to encode asset details: {}", e), + }) + } + + pub async fn delete_assets(&self, album: String, assets: Vec<String>) -> Result<(), WrappedError> { + self.inner.delete_asset(&album, assets).await?; + Ok(()) + } + + pub async fn list_albums(&self) -> Vec<SharedAlbumInfo> { + let state = self.inner.state.read().await; + state.albums.iter().map(|a| SharedAlbumInfo { + albumguid: a.albumguid.clone(), + name: a.name.clone(), + fullname: a.fullname.clone(), + email: a.email.clone(), + }).collect() + } + + pub async fn get_album_assets(&self, album: String) -> Result<Vec<SharedAssetInfo>, WrappedError> { + let guids = self.inner.get_album_summary(&album).await?; + if guids.is_empty() { + return Ok(vec![]); + } + let details = self.inner.get_assets(&album, &guids).await?; + Ok(details.iter().map(|d| { + let primary = d.files.iter().max_by_key(|f| f.size.parse::<u64>().unwrap_or(0)); + let media_type = if d.collectionmetadata.video_duration.is_some() { + "video".to_string() + } else { + "image".to_string() + }; + SharedAssetInfo { + assetguid: d.assetguid.clone(), + filename: d.filename.clone(), + date_created: format!("{:?}", d.collectionmetadata.date_created), + media_type, + width: primary.map(|f| f.width.clone()).unwrap_or_default(), + height: primary.map(|f| f.height.clone()).unwrap_or_default(), + size: primary.map(|f| f.size.clone()).unwrap_or_default(), + } + }).collect()) + } + + pub async fn download_file(&self, album: String, asset_guid: String) -> Result<Vec<u8>, WrappedError> { + let details = self.inner.get_assets(&album, &[asset_guid.clone()]).await?; + let asset = details.into_iter().next().ok_or(WrappedError::GenericError { + msg: format!("Asset {} not found in album {}", asset_guid, album), + })?; + let primary = asset.files.into_iter() + .max_by_key(|f| f.size.parse::<u64>().unwrap_or(0)) + .ok_or(WrappedError::GenericError { + msg: "Asset has no files".to_string(), + })?; + let mut buf = Cursor::new(Vec::new()); + self.inner.get_file(&mut [(&primary, &mut buf)], |_, _| {}).await?; + Ok(buf.into_inner()) + } +} + +#[derive(uniffi::Object)] +pub struct Client { + client: Arc<IMClient>, + conn: rustpush::APSConnection, + os_config: Arc<dyn OSConfig>, + receive_handle: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>, + token_provider: Option<Arc<WrappedTokenProvider>>, + cloud_messages_client: tokio::sync::Mutex<Option<Arc<rustpush::cloud_messages::CloudMessagesClient<BridgeDefaultAnisetteProvider>>>>, + cloud_keychain_client: tokio::sync::Mutex<Option<Arc<rustpush::keychain::KeychainClient<BridgeDefaultAnisetteProvider>>>>, + findmy_client: tokio::sync::Mutex<Option<Arc<WrappedFindMyClient>>>, + facetime_client: tokio::sync::Mutex<Option<Arc<WrappedFaceTimeClient>>>, + passwords_client: tokio::sync::Mutex<Option<Arc<WrappedPasswordsClient>>>, + statuskit_client: tokio::sync::Mutex<Option<Arc<WrappedStatusKitClient>>>, + sharedstreams_client: tokio::sync::Mutex<Option<Arc<WrappedSharedStreamsClient>>>, + /// Cached Profiles client (Name & Photo Sharing). Initialized lazily the + /// first time a shared profile needs to be fetched from CloudKit. + profiles_client: tokio::sync::Mutex<Option<Arc<rustpush::name_photo_sharing::ProfilesClient<BridgeDefaultAnisetteProvider>>>>, + /// Shared handle to the raw StatusKit client, used by the APNs receive + /// loop to intercept presence messages before iMessage handling. Set by + /// `init_statuskit()`; the loop no-ops when unset. + shared_statuskit: Arc<tokio::sync::RwLock<Option<Arc<rustpush::statuskit::StatusKitClient<BridgeDefaultAnisetteProvider>>>>>, + /// Shared callback for presence updates delivered by StatusKit. Populated + /// alongside `shared_statuskit` by `init_statuskit()`. + status_callback: Arc<tokio::sync::RwLock<Option<Arc<dyn StatusCallback>>>>, + /// Subscription tokens held to keep presence channels open. Cleared by + /// `unsubscribe_all_status()`. + statuskit_interest_tokens: tokio::sync::Mutex<Vec<rustpush::statuskit::ChannelInterestToken>>, +} + +#[uniffi::export(async_runtime = "tokio")] +pub async fn new_client( + connection: &WrappedAPSConnection, + users: &WrappedIDSUsers, + identity: &WrappedIDSNGMIdentity, + config: &WrappedOSConfig, + token_provider: Option<Arc<WrappedTokenProvider>>, + message_callback: Box<dyn MessageCallback>, + update_users_callback: Box<dyn UpdateUsersCallback>, +) -> Result<Arc<Client>, WrappedError> { + ensure_crypto_provider(); + let conn = connection.inner.clone(); + let users_clone = users.inner.clone(); + let identity_clone = identity.inner.clone(); + let config_clone = config.config.clone(); + + let _ = std::fs::create_dir_all("state"); + + // FACETIME + VIDEO are in the bundle so the IdentityResource's `services` + // slice contains them. Without that, upstream's `get_main_service` (called + // on every FT `cache_keys`) hits its `expect("Topic {topic} not found!")` + // and panics out of the FFI. The earlier best-effort separate-register + // workaround registered FT/Video in the IDSUser but didn't update + // `services`, so the panic still fired. + // + // Trade-off: `register()` is all-or-nothing, so an Apple non-zero status + // for any service in this bundle fails the whole call. For status 6005 + // upstream wraps that in `PushError::DoNotRetry`, and the ResourceManager + // transitions the shared IdentityResource to `Closed` — which would + // permanently break iMessage send too. We accept this risk because Apple + // has not been observed to 6005 FT/Video for this account. + let client = Arc::new( + IMClient::new( + conn.clone(), + users_clone, + identity_clone, + &[&MADRID_SERVICE, &MULTIPLEX_SERVICE, &FACETIME_SERVICE, &VIDEO_SERVICE], + "state/id_cache.plist".into(), + config_clone.clone(), + Box::new(move |updated_keys| { + update_users_callback.update_users(Arc::new(WrappedIDSUsers { + inner: updated_keys, + })); + debug!("Updated IDS keys"); + }), + ) + .await, + ); + + // Start receive loop. + // + // Architecture: two tasks connected by an unbounded mpsc channel. + // + // 1. **Drain task** — reads from the tokio broadcast channel as fast as + // possible and forwards every APSMessage into the mpsc. Because it does + // zero processing, it will almost never lag behind the broadcast. If it + // *does* lag (broadcast capacity 9999 exhausted), it logs the count and + // continues — there is nothing we can do about already-dropped broadcast + // messages, but we won't compound the loss by being slow. + // + // 2. **Process task** — reads from the mpsc (unbounded, so no back-pressure + // on the drain task) and handles each message: decrypting, downloading + // MMCS attachments, and calling the Go callback. Transient errors are + // retried with exponential back-off. This task can take as long as it + // needs without risking broadcast lag. + let client_for_recv = client.clone(); + let callback = Arc::new(message_callback); + + // Pre-warm FaceTime so incoming FT APNs notifications are consumed even + // before any explicit !facetime command initializes the subsystem. + let facetime_state_path = subsystem_state_path("facetime-state.plist"); + let facetime_state = read_plist_state::<rustpush::facetime::FTState>(&facetime_state_path).unwrap_or_default(); + let facetime_state_path_for_closure = facetime_state_path.clone(); + let prewarmed_facetime = Arc::new(WrappedFaceTimeClient { + inner: Arc::new( + rustpush::facetime::FTClient::new( + facetime_state, + Box::new(move |state| persist_plist_state(&facetime_state_path_for_closure, state)), + conn.clone(), + client.identity.clone(), + config_clone.clone(), + ) + .await, + ), + }); + + // Shared StatusKit state: init_statuskit() populates these Arc<RwLock>s, + // and the receive loop below reads them on every message. The raw + // StatusKit client gets first crack at incoming APNs messages so presence + // updates are consumed before iMessage handling. + let shared_statuskit_for_recv: Arc<tokio::sync::RwLock<Option<Arc<rustpush::statuskit::StatusKitClient<BridgeDefaultAnisetteProvider>>>>> = + Arc::new(tokio::sync::RwLock::new(None)); + let status_callback_for_recv: Arc<tokio::sync::RwLock<Option<Arc<dyn StatusCallback>>>> = + Arc::new(tokio::sync::RwLock::new(None)); + + // Shared reconnect timestamp: set when APNs reconnects (generated_signal + // fires) or when the drain task starts listening (initial connection). + // Messages arriving within RECONNECT_WINDOW_MS of a (re)connect are + // marked as stored — they were cached by Apple's servers while offline. + const RECONNECT_WINDOW_MS: u64 = 30_000; // 30 seconds + // Initialize to 0 — the drain task sets it to now() right before + // entering the receive loop. This ensures the window starts when we're + // actually listening for messages, not when receive() is called (which + // can be 20-30s earlier during Go startup). + let reconnected_at = Arc::new(AtomicU64::new(0)); + + // Weak handle to OUR Client, set after Client is constructed below. + // The receive loop upgrades this on each iteration to call back into + // Client::populate_inline_share_profile (mirrors how IconChange is + // downloaded inline). Weak avoids the receive task keeping Client alive. + let client_weak_for_loop: Arc<tokio::sync::OnceCell<std::sync::Weak<Client>>> = + Arc::new(tokio::sync::OnceCell::new()); + + let receive_handle = tokio::spawn({ + let conn = connection.inner.clone(); + let conn_for_download = connection.inner.clone(); + let reconnected_at = reconnected_at.clone(); + let sk_for_recv = shared_statuskit_for_recv.clone(); + let status_cb_for_recv = status_callback_for_recv.clone(); + let ft_for_recv = prewarmed_facetime.inner.clone(); + let client_weak_for_loop = client_weak_for_loop.clone(); + async move { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(rustpush::APSMessage, u64)>(); + let pending = Arc::new(AtomicU64::new(0)); + + // --- Drain task: broadcast → mpsc + reconnect detection ------ + // Combines message draining AND reconnect detection in a SINGLE + // task using select!. This eliminates the scheduling race from + // the old two-task approach: resource_state changes are handled + // in the same task that receives messages, so reconnected_at is + // always updated before the next message is forwarded. + let drain_pending = pending.clone(); + let drain_handle = tokio::spawn({ + let conn = conn.clone(); + let reconnected_at = reconnected_at.clone(); + async move { + let mut recv = conn.messages_cont.subscribe(); + let mut state = conn.resource_state.subscribe(); + // Mark the initial resource_state value as seen so we + // only trigger on future transitions. + state.borrow_and_update(); + // Set reconnected_at NOW — right when we start listening. + // Covers stored messages delivered on first connect. + let start_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + reconnected_at.store(start_ms, Ordering::Relaxed); + info!("Drain task started, marking messages as stored for {}ms", RECONNECT_WINDOW_MS); + loop { + tokio::select! { + biased; + // Reconnect detection: resource_state → Generating + // fires BEFORE generate() establishes the new + // connection, so reconnected_at is set before any + // stored messages can arrive on messages_cont. + result = state.changed() => { + if result.is_err() { + info!("Resource state sender dropped, stopping drain task"); + break; + } + let current = state.borrow_and_update().clone(); + if matches!(current, ResourceState::Generating) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + reconnected_at.store(now, Ordering::Relaxed); + info!("APNs reconnecting (Generating), marking messages as stored for {}ms", RECONNECT_WINDOW_MS); + } + } + // Message drain: forward APNs messages to process task + result = recv.recv() => { + match result { + Ok(msg) => { + drain_pending.fetch_add(1, Ordering::Relaxed); + let drain_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + if tx.send((msg, drain_ts)).is_err() { + info!("Process task gone, stopping drain"); + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + error!( + "APS broadcast receiver lagged — {} messages were DROPPED by the \ + broadcast channel before we could read them. Real-time messages \ + may have been lost. Consider increasing broadcast capacity or \ + investigating processing backlog (pending={}).", + n, + drain_pending.load(Ordering::Relaxed), + ); + } + Err(broadcast::error::RecvError::Closed) => { + info!("Broadcast channel closed, stopping drain task"); + break; + } + } + } + } + } + } + }); + + // --- Process task: mpsc → handle + callback ----------------- + const MAX_RETRIES: u32 = 5; + const INITIAL_BACKOFF: Duration = Duration::from_millis(500); + + while let Some((msg, drain_ts)) = rx.recv().await { + // StatusKit gets first crack at the APNs message. If it + // consumes the message (presence update on a subscribed + // channel), we dispatch to the Go callback and skip iMessage + // handling. Only active when init_statuskit() has been + // called; otherwise falls through. + let sk_opt = sk_for_recv.read().await.clone(); + if let Some(sk) = sk_opt { + // Wrapper-only workaround for upstream silent-drop. + // statuskit.rs:719 destructures payload as Value::Data and + // returns Ok(None) for keysharing-topic notifications whose + // payload arrived as Value::Dictionary — which is the typical + // case because aps.rs:880 greedily plist-decodes payload bytes + // into a Dictionary before reaching handle(). Without this + // intercept, peer keysharing replies are dropped at the + // destructure and state.keys never grows. + // + // We bypass handle() for that exact shape (keysharing topic + + // non-Data payload), call identity.receive_message directly + // (which accepts any Value variant via plist::from_value at + // identity_manager.rs:771), and construct a + // StatusKitSharedDevice via serde with a built Dictionary — + // mirroring the construction at upstream statuskit.rs:776–781 + // but routed through Deserialize so the private fields stay + // private. The device is inserted into the public + // sk.state.keys map and persisted via the same code path + // upstream's update_state callback uses. + let mut workaround_consumed = false; + if let rustpush::APSMessage::Notification { + topic: msg_topic, + payload, + .. + } = &msg + { + let keysharing_topic_hash: [u8; 20] = openssl::sha::sha1( + "com.apple.private.alloy.status.keysharing".as_bytes(), + ); + if *msg_topic == keysharing_topic_hash + && !matches!(payload, plist::Value::Data(_)) + { + let result: Result<bool, String> = async { + use prost::Message as _; + let recv = sk + .identity + .receive_message( + msg.clone(), + &["com.apple.private.alloy.status.keysharing"], + ) + .await + .map_err(|e| format!("identity.receive_message: {e}"))?; + let Some(recv) = recv else { + return Ok::<bool, String>(false); + }; + let Some(message_unenc) = recv.message_unenc else { + return Ok(false); + }; + let Some(sender) = recv.sender else { + return Ok(false); + }; + + #[derive(serde::Deserialize)] + struct LocalRawShared { + #[serde(rename = "r")] + keys: String, + #[serde(rename = "p")] + personal_config: String, + #[serde(rename = "c")] + channel: String, + } + let parsed: LocalRawShared = message_unenc + .plist() + .map_err(|e| format!("plist parse raw shared: {e}"))?; + + let keys_bytes = BASE64_STANDARD + .decode(&parsed.keys) + .map_err(|e| format!("keys base64: {e}"))?; + let pc_bytes = BASE64_STANDARD + .decode(&parsed.personal_config) + .map_err(|e| format!("personal_config base64: {e}"))?; + + let share_message = + rustpush::statuskit::statuskitp::SharedMessage::decode( + std::io::Cursor::new(keys_bytes), + ) + .map_err(|e| format!("SharedMessage decode: {e}"))?; + + let sig_key_arr: [u8; 32] = share_message + .sig_key + .clone() + .try_into() + .map_err(|v: Vec<u8>| { + format!("sig_key length {} != 32", v.len()) + })?; + let compact = rustpush::CompactECKey::<openssl::pkey::Public>::decompress(sig_key_arr); + let der_bytes = compact + .public_key_to_der() + .map_err(|e| format!("public_key_to_der: {e}"))?; + + let shared_keys = + share_message.keys.unwrap_or_default().keys; + if shared_keys.is_empty() { + return Err("share_message.keys.keys is empty".into()); + } + let key_data_array: Vec<plist::Value> = shared_keys + .iter() + .map(|k| plist::Value::Data(k.encode_to_vec())) + .collect(); + let key_count = key_data_array.len(); + + let pc_value: plist::Value = plist::from_bytes(&pc_bytes) + .map_err(|e| format!("personal_config plist: {e}"))?; + + let mut dict = plist::Dictionary::new(); + dict.insert( + "from".into(), + plist::Value::String(sender.clone()), + ); + dict.insert( + "signature".into(), + plist::Value::Data(der_bytes), + ); + dict.insert( + "keys".into(), + plist::Value::Array(key_data_array), + ); + dict.insert("personal_config".into(), pc_value); + + let device: rustpush::statuskit::StatusKitSharedDevice = + plist::from_value(&plist::Value::Dictionary(dict)) + .map_err(|e| { + format!("StatusKitSharedDevice deserialize: {e}") + })?; + + let mut state_w = sk.state.write().await; + let prev = state_w + .keys + .insert(parsed.channel.clone(), device); + let total = state_w.keys.len(); + drop(state_w); + + let action = if prev.is_some() { "replaced" } else { "added" }; + let state_path = + subsystem_state_path("statuskit-state.plist"); + persist_plist_state( + &state_path, + &*sk.state.read().await, + ); + + info!( + "StatusKit workaround {action} channel for {} (keys_in_msg={}, state.keys total={}) — Dictionary-payload bypass", + sender, key_count, total + ); + if let Some(cb) = + status_cb_for_recv.read().await.as_ref() + { + cb.on_keys_received(); + } + Ok(true) + } + .await; + + match result { + Ok(true) => { + workaround_consumed = true; + } + Ok(false) => { + debug!("StatusKit workaround: receive_message returned None or partial fields, falling through"); + } + Err(e) => { + warn!( + "StatusKit workaround failed: {} — falling back to upstream handle()", + e + ); + } + } + } + } + + if workaround_consumed { + pending.fetch_sub(1, Ordering::Relaxed); + continue; + } + + // Extract the APNs topic before spawning the thread so we can + // check it later for key-sharing detection (msg is moved into + // the spawned thread and unavailable afterwards). + let msg_topic = if let rustpush::APSMessage::Notification { topic, .. } = &msg { + Some(*topic) + } else { + None + }; + // Snapshot the set of channel IDs in state.keys before handle() + // so we can detect whether a keysharing APNs message added a new + // channel. Using a HashSet (not len()) so that a re-key — where + // invite_to_channel replaces an existing channel id via + // HashMap::insert — is also detected even though len() would not + // change. + let keys_before: std::collections::HashSet<String> = + sk.state.read().await.keys.keys().cloned().collect(); + // handle() is async and may panic (statuskit.rs:736 "Channel not + // found!"). Spawn as a tokio task on the main runtime so panics + // surface as JoinError rather than crashing the loop, AND so the + // task has access to the main runtime's tokio primitives — the + // IDS identity manager, reqwest HTTP client, and background + // refresh tasks are all bound to this runtime. Running handle() + // on a separate runtime (prior implementation) caused + // receive_message to silently fail its key lookups because the + // HTTP requests and tokio mutexes couldn't complete properly in + // an isolated current-thread runtime. + let sk_clone = sk.clone(); + let msg_clone = msg.clone(); + let handle_result = tokio::task::spawn(async move { + sk_clone.handle(msg_clone).await + }).await; + // Build a human-readable topic label for diagnostic log lines. + let topic_label = if let Some(t) = msg_topic { + let ks: [u8; 20] = openssl::sha::sha1("com.apple.private.alloy.status.keysharing".as_bytes()); + let st: [u8; 20] = openssl::sha::sha1("com.apple.private.alloy.status.status".as_bytes()); + if t == ks { "keysharing" } else if t == st { "status" } else { "unknown" } + } else { + "no-topic" + }; + let diag_cmd = if let rustpush::APSMessage::Notification { payload: plist::Value::Data(payload), .. } = &msg { + plist::from_bytes::<plist::Value>(payload) + .ok() + .and_then(|v| v.into_dictionary()) + .and_then(|d| d.get("c").and_then(|c| c.as_unsigned_integer()).map(|v| v as u8)) + } else { + None + }; + match handle_result { + Ok(Ok(Some(rustpush::statuskit::StatusKitMessage::StatusChanged { user, mode, allowed }))) => { + if let Some(cb) = status_cb_for_recv.read().await.as_ref() { + cb.on_status_update(user, mode, allowed); + } + pending.fetch_sub(1, Ordering::Relaxed); + continue; + } + Ok(Ok(None)) => { + // Not a StatusKit presence update — but check whether it + // was a key-sharing message. Only fire on_keys_received + // if state.keys gained a new channel id. + let keysharing_topic: [u8; 20] = openssl::sha::sha1("com.apple.private.alloy.status.keysharing".as_bytes()); + if let Some(topic) = msg_topic { + if topic == keysharing_topic { + let keys_after: std::collections::HashSet<String> = + sk.state.read().await.keys.keys().cloned().collect(); + let new_channels: Vec<&String> = keys_after.difference(&keys_before).collect(); + if !new_channels.is_empty() { + info!("StatusKit key-sharing message received — state.keys gained {} new channel(s)", new_channels.len()); + if let Some(cb) = status_cb_for_recv.read().await.as_ref() { + cb.on_keys_received(); + } + } else { + // Parse the command byte and IDS envelope field presence + // from the raw payload for diagnostics. + let (cmd_byte, ids_fields) = + if let rustpush::APSMessage::Notification { payload: plist::Value::Data(payload), .. } = &msg { + if let Ok(plist::Value::Dictionary(d)) = plist::from_bytes::<plist::Value>(payload) { + let c = d.get("c").and_then(|v| v.as_unsigned_integer()).map(|v| v as u8); + let sp = d.contains_key("sP"); + let tp = d.contains_key("tP"); + let p = d.contains_key("P"); + let t = d.contains_key("t"); + let e = d.contains_key("E"); + (c, Some((sp, tp, p, t, e))) + } else { + (None, None) + } + } else { + (None, None) + }; + // c=255 = server ACK for our own outgoing invite; suppress. + // c=227 = peer re-invite for an existing channel (expected + // re-key). HashMap::insert replaces the entry in + // place, so HashSet diff yields no growth. Log at + // INFO when keys_before is non-empty. + // c=97 = unknown command on keysharing topic. Log all + // plist keys at WARN so we can see whether this is + // a real iOS keysharing message in a different format + // than the OpenBubbles c=227. Other commands also logged. + let is_silent = cmd_byte.map(|c| c == 255).unwrap_or(false); + let is_reinvite = cmd_byte.map(|c| c == 227).unwrap_or(false) + && !keys_before.is_empty(); + // Non-Notification APS frames (ACKs, connection state, + // keepalives) flow through the keysharing subscription and + // reach this path, but they can never carry a real + // keysharing invite payload — cmd_byte and IDS fields are + // all unknown, and the diagnostic just adds noise. Only + // warn when the msg actually is a Notification and we + // managed to parse a command byte (else there is nothing + // informative to say). + let is_notification = matches!( + &msg, + rustpush::APSMessage::Notification { payload: plist::Value::Data(_), .. } + ); + if is_reinvite { + info!("StatusKit peer re-invite received for existing channel (c=227) — keys replaced in place"); + } else if !is_silent && is_notification && cmd_byte.is_some() { + let all_keys = if let rustpush::APSMessage::Notification { payload: plist::Value::Data(payload), .. } = &msg { + plist::from_bytes::<plist::Value>(payload) + .ok() + .and_then(|v| v.into_dictionary()) + .map(|d| d.keys().cloned().collect::<Vec<_>>().join(", ")) + .unwrap_or_else(|| "<unparseable>".into()) + } else { + "<not a notification>".into() + }; + warn!( + "StatusKit inbound keysharing message (c={}) did not grow state.keys — plist keys: [{}] IDS fields: sP={} tP={} P={} t={} E={}", + cmd_byte.map(|c| c.to_string()).unwrap_or_else(|| "?".into()), + all_keys, + ids_fields.map(|(sp,_,_,_,_)| if sp {"Y"} else {"N"}).unwrap_or("?"), + ids_fields.map(|(_,tp,_,_,_)| if tp {"Y"} else {"N"}).unwrap_or("?"), + ids_fields.map(|(_,_,p,_,_)| if p {"Y"} else {"N"}).unwrap_or("?"), + ids_fields.map(|(_,_,_,t,_)| if t {"Y"} else {"N"}).unwrap_or("?"), + ids_fields.map(|(_,_,_,_,e)| if e {"Y"} else {"N"}).unwrap_or("?"), + ); + } + } + } + } + } + Ok(Err(rustpush::PushError::VerificationFailed)) => { + warn!("StatusKit signature verification failed (c={:?} topic={}) — key ratchet mismatch, will resolve on next keysharing message", diag_cmd, topic_label); + } + Ok(Err(e)) => { + warn!("StatusKit handle error (c={:?} topic={}): {:?}", diag_cmd, topic_label, e); + } + Err(_) => { + warn!("StatusKit handle panicked (c={:?} topic={}) — channel may lack shared keys", diag_cmd, topic_label); + } + } + } + + // Consume FaceTime APNs events to keep FT state live and + // surface incoming-call attempts to Go as synthetic notice + // triggers. ft_handle_with_join_recovery wraps upstream's + // handle() and falls back to local decoding for cmd 207/209 + // when Apple sends them without the context.message field — + // see the helper's docstring for why. + // + // Retry on SendTimedOut: upstream's handle_letmein sends a + // delegation message_session INSIDE handle() — if APNs flaps + // at that moment, the entire LetMeIn is dropped before our + // auto_approve even runs. A single retry after 2s usually + // lands on the reconnected APNs. + let ft_result = { + let first = ft_handle_with_join_recovery(ft_for_recv.as_ref(), msg.clone()).await; + if matches!(&first, Err(rustpush::PushError::SendTimedOut)) { + warn!("FaceTime handle SendTimedOut, retrying in 2s"); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + ft_handle_with_join_recovery(ft_for_recv.as_ref(), msg.clone()).await + } else { + first + } + }; + match ft_result { + Ok(Some(ft_message)) => { + if let rustpush::facetime::FTMessage::LetMeInRequest(request) = &ft_message { + if let Err(e) = auto_approve_bridge_letmein(ft_for_recv.as_ref(), request).await { + warn!("FaceTime auto-approve LetMeIn failed: {:?}", e); + } + } + if let rustpush::facetime::FTMessage::JoinEvent { guid, handle, .. } = &ft_message { + maybe_fire_pending_ring(ft_for_recv.as_ref(), guid, handle).await; + } + if let Some(wrapped) = facetime_event_to_wrapped(ft_for_recv.as_ref(), &ft_message).await { + callback.on_message(wrapped); + } + } + Ok(None) => {} + Err(e) => { + warn!("FaceTime handle error: {:?}", e); + } + } + + let mut retries = 0u32; + let mut backoff = INITIAL_BACKOFF; + + loop { + match client_for_recv.handle(msg.clone()).await { + Ok(Some(msg_inst)) => { + // Certify delivery back to the sender when the + // incoming message carries a certified context. + // Without this, the sender's device displays a + // "not delivered" indicator even though we + // received and decrypted the message successfully. + if let Some(ref context) = msg_inst.certified_context { + if let Err(e) = client_for_recv + .identity + .certify_delivery("com.apple.madrid", context, msg_inst.send_delivered) + .await + { + warn!("Failed to certify delivery for {}: {:?}", msg_inst.id, e); + } + } + + // Diagnostic: surface profile-sharing arrivals so we can confirm + // APNs is actually delivering them. Logged here, before the + // has_payload gate, so a future filter regression can't hide it. + match &msg_inst.message { + Message::ShareProfile(p) => info!( + "received Message::ShareProfile from {:?} (record_key_len={}, has_poster={})", + msg_inst.sender, + p.cloud_kit_record_key.len(), + p.poster.is_some() + ), + Message::UpdateProfile(u) => info!( + "received Message::UpdateProfile from {:?} (share_contacts={}, has_embedded_profile={})", + msg_inst.sender, + u.share_contacts, + u.profile.is_some() + ), + Message::UpdateProfileSharing(s) => info!( + "received Message::UpdateProfileSharing from {:?} (version={}, dismissed={}, all={})", + msg_inst.sender, + s.version, + s.shared_dismissed.len(), + s.shared_all.len() + ), + _ => {} + } + + if msg_inst.has_payload() || matches!(msg_inst.message, Message::Typing(_, _) | Message::Read | Message::Delivered | Message::Error(_) | Message::PeerCacheInvalidate) { + let mut wrapped = message_inst_to_wrapped(&msg_inst); + + // Mark as stored if within the post-reconnect window. + // Uses the drain timestamp (when the message was received + // from APNs) instead of now, so MMCS download time and + // retry backoff don't push messages past the window. + let reconn = reconnected_at.load(Ordering::Relaxed); + if reconn > 0 { + let delta = drain_ts.saturating_sub(reconn); + wrapped.is_stored_message = delta < RECONNECT_WINDOW_MS; + if wrapped.is_stored_message { + info!("Stored message detected: uuid={} delta={}ms (window={}ms)", wrapped.uuid, delta, RECONNECT_WINDOW_MS); + } + } + + // Download MMCS attachments so Go receives inline data + download_mmcs_attachments(&mut wrapped, &msg_inst, &conn_for_download).await; + // Download group photo for IconChange messages via MMCS + if wrapped.is_icon_change && !wrapped.group_photo_cleared { + download_icon_change_photo(&mut wrapped, &msg_inst, &conn_for_download).await; + } + // Download Name & Photo Sharing profile from CloudKit + // (mirrors the IconChange MMCS pattern: fetch in Rust, + // hand Go ready-to-display name + avatar bytes). + if wrapped.is_share_profile { + if let Some(client_arc) = client_weak_for_loop + .get() + .and_then(|w| w.upgrade()) + { + client_arc.populate_inline_share_profile(&msg_inst, &mut wrapped).await; + } + } + callback.on_message(wrapped); + } + break; // success + } + Ok(None) => { + break; // message intentionally ignored by handle() + } + Err(e) => { + // Classify: retryable vs permanent + let is_permanent = matches!( + e, + rustpush::PushError::BadMsg + | rustpush::PushError::DoNotRetry(_) + | rustpush::PushError::VerificationFailed + ); + + if is_permanent || retries >= MAX_RETRIES { + error!( + "Failed to handle APS message after {} attempt(s) (permanent={}): {:?}", + retries + 1, + is_permanent, + e + ); + break; + } + + retries += 1; + warn!( + "Transient error handling APS message (attempt {}/{}), retrying in {:?}: {:?}", + retries, + MAX_RETRIES, + backoff, + e + ); + tokio::time::sleep(backoff).await; + backoff = std::cmp::min(backoff * 2, Duration::from_secs(15)); + } + } + } + + pending.fetch_sub(1, Ordering::Relaxed); + } + + drain_handle.abort(); + info!("Receive loop exited"); + } + }); + + let client = Arc::new(Client { + client, + conn: connection.inner.clone(), + os_config: config_clone, + receive_handle: tokio::sync::Mutex::new(Some(receive_handle)), + token_provider, + cloud_messages_client: tokio::sync::Mutex::new(None), + cloud_keychain_client: tokio::sync::Mutex::new(None), + findmy_client: tokio::sync::Mutex::new(None), + facetime_client: tokio::sync::Mutex::new(Some(prewarmed_facetime)), + passwords_client: tokio::sync::Mutex::new(None), + statuskit_client: tokio::sync::Mutex::new(None), + sharedstreams_client: tokio::sync::Mutex::new(None), + profiles_client: tokio::sync::Mutex::new(None), + shared_statuskit: shared_statuskit_for_recv, + status_callback: status_callback_for_recv, + statuskit_interest_tokens: tokio::sync::Mutex::new(Vec::new()), + }); + // Hand the receive loop a Weak<Client> so it can call back into + // populate_inline_share_profile without preventing Client drop. + let _ = client_weak_for_loop.set(Arc::downgrade(&client)); + Ok(client) +} + +impl Client { + async fn get_or_init_cloud_messages_client(&self) -> Result<Arc<rustpush::cloud_messages::CloudMessagesClient<BridgeDefaultAnisetteProvider>>, WrappedError> { + // Fast path: return cached client without doing any slow work. + // IMPORTANT: the lock is released before any network calls so that + // tokio timeouts can fire and concurrent callers are never blocked by + // a slow initialisation in progress. + { + let locked = self.cloud_messages_client.lock().await; + if let Some(client) = &*locked { + return Ok(client.clone()); + } + } + + info!("Cloud client init: no cached client, initializing now"); + + // All slow work happens here WITHOUT holding cloud_messages_client. + let tp = self.token_provider.as_ref().ok_or(WrappedError::GenericError { + msg: "No TokenProvider available".into(), + })?; + + let dsid = tp.get_dsid().await?; + let adsid = tp.get_adsid().await?; + let mme_delegate = tp.parse_mme_delegate().await?; + let account = tp.get_account(); + let os_config = tp.get_os_config(); + let anisette = account.lock().await.anisette.clone(); + + let cloudkit_state = rustpush::cloudkit::CloudKitState::new(dsid.clone()).ok_or( + WrappedError::GenericError { + msg: "Failed to create CloudKitState".into(), + }, + )?; + let cloudkit = Arc::new(rustpush::cloudkit::CloudKitClient { + state: rustpush::DebugRwLock::new(cloudkit_state), + anisette: anisette.clone(), + config: os_config.clone(), + token_provider: tp.inner.clone(), + }); + + let keychain_state_path = format!("{}/trustedpeers.plist", resolve_xdg_data_dir()); + let mut keychain_state: Option<rustpush::keychain::KeychainClientState> = match std::fs::read(&keychain_state_path) { + Ok(data) => match plist::from_bytes(&data) { + Ok(state) => Some(state), + Err(e) => { + warn!("Failed to parse keychain state at {}: {}", keychain_state_path, e); + None + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + warn!("Failed to read keychain state at {}: {}", keychain_state_path, e); + None + } + }; + if keychain_state.is_none() { + keychain_state = Some( + rustpush::keychain::KeychainClientState::new(dsid, adsid, &mme_delegate) + .ok_or(WrappedError::GenericError { + msg: "Missing KeychainSync config in MobileMe delegate".into(), + })? + ); + } + let path_for_closure = keychain_state_path.clone(); + + let keychain = Arc::new(rustpush::keychain::KeychainClient { + anisette, + token_provider: tp.inner.clone(), + state: rustpush::DebugRwLock::new(keychain_state.expect("keychain state missing")), + config: os_config, + update_state: Box::new(move |state| { + if let Err(e) = plist::to_file_xml(&path_for_closure, state) { + warn!("Failed to persist keychain state to {}: {}", path_for_closure, e); + } + }), + container: tokio::sync::Mutex::new(None), + security_container: tokio::sync::Mutex::new(None), + client: cloudkit.clone(), + }); + + // All pre-init network steps (TLK share refresh, keychain sync, zone + // key prewarm, record count) have been removed: rustpush's keychain + // and CloudKit functions block the tokio executor thread (synchronous + // network I/O inside async futures), so tokio::time::timeout cannot + // fire — causing indefinite hangs regardless of the timeout value. + // + // The client is constructed immediately with cached on-disk state. + // PCS key errors encountered during sync_records are handled lazily + // by recover_cloud_pcs_state, which retries the keychain sync then. + + let cloud_messages = Arc::new(rustpush::cloud_messages::CloudMessagesClient::new( + cloudkit, keychain.clone(), + )); + info!("Cloud client init: complete"); + + // Write path: acquire the lock briefly to store the result. + // If another task raced and initialized first, use their result. + let mut locked = self.cloud_messages_client.lock().await; + if let Some(existing) = &*locked { + info!("Cloud client init: another task initialized first, using their result"); + return Ok(existing.clone()); + } + *locked = Some(cloud_messages.clone()); + *self.cloud_keychain_client.lock().await = Some(keychain); + Ok(cloud_messages) + } + + async fn get_or_init_cloud_keychain_client(&self) -> Result<Arc<rustpush::keychain::KeychainClient<BridgeDefaultAnisetteProvider>>, WrappedError> { + let _ = self.get_or_init_cloud_messages_client().await?; + self.cloud_keychain_client + .lock() + .await + .clone() + .ok_or(WrappedError::GenericError { + msg: "No keychain client available".into(), + }) + } + + async fn recover_cloud_pcs_state(&self, context: &str) -> Result<(), WrappedError> { + info!("{}: starting keychain resync recovery", context); + let keychain = self.get_or_init_cloud_keychain_client().await?; + // refresh_recoverable_tlk_shares removed: fetch_shares_for blocks the + // tokio thread (no timeout possible). sync_keychain covers the same + // key material via a different Cuttlefish path. + sync_keychain_with_retries(&keychain, 6, context).await + } + + async fn get_or_init_profiles_client(&self) -> Result<Arc<rustpush::name_photo_sharing::ProfilesClient<BridgeDefaultAnisetteProvider>>, WrappedError> { + let mut locked = self.profiles_client.lock().await; + if let Some(client) = &*locked { + return Ok(client.clone()); + } + // ProfilesClient shares the CloudKit handle owned by the keychain + // client — initialize cloud messages first so both are available. + let keychain = self.get_or_init_cloud_keychain_client().await?; + let cloudkit = keychain.client.clone(); + let profiles = Arc::new(rustpush::name_photo_sharing::ProfilesClient::new(cloudkit)); + *locked = Some(profiles.clone()); + info!("ProfilesClient initialized"); + Ok(profiles) + } + + /// Inline-fetch a Name & Photo Sharing profile from CloudKit during the + /// receive loop and stuff the decrypted name + avatar bytes onto the + /// outgoing WrappedMessage. Mirrors `download_icon_change_photo` so the + /// Go side never has to make a follow-up FFI call to render the ghost. + /// Failures are logged and left silent — the keys remain on the wrapped + /// message so a Go-side periodic re-fetch can recover later. + async fn populate_inline_share_profile( + &self, + _msg_inst: &MessageInst, + wrapped: &mut WrappedMessage, + ) { + // Drive off the wrapped fields rather than re-matching on + // msg_inst.message — populate_share_profile_keys already pulled the + // keys out of the standalone (Message::ShareProfile / UpdateProfile) + // and embedded (NormalMessage.embedded_profile / React.embedded_profile) + // cases, so by the time we get here the keys are uniformly available. + let (record_key, decryption_key, has_poster) = match ( + wrapped.share_profile_record_key.as_ref(), + wrapped.share_profile_decryption_key.as_ref(), + ) { + (Some(rk), Some(dk)) => (rk.clone(), dk.clone(), wrapped.share_profile_has_poster), + _ => return, + }; + let share_msg = rustpush::ShareProfileMessage { + cloud_kit_record_key: record_key, + cloud_kit_decryption_record_key: decryption_key, + poster: if has_poster { + Some(rustpush::SharedPoster { + low_res_wallpaper_tag: vec![], + wallpaper_tag: vec![], + message_tag: vec![], + }) + } else { + None + }, + }; + + let key_prefix: String = share_msg.cloud_kit_record_key.chars().take(8).collect(); + let profiles = match self.get_or_init_profiles_client().await { + Ok(p) => p, + Err(e) => { + warn!( + "inline ShareProfile fetch: ProfilesClient init failed (record_key={}…): {:?}", + key_prefix, e + ); + return; + } + }; + match profiles.get_record(&share_msg).await { + Ok(record) => { + info!( + "inline ShareProfile fetch ok (record_key={}…, name='{}', avatar_bytes={})", + key_prefix, + record.name.name, + record.image.as_ref().map(|v| v.len()).unwrap_or(0) + ); + wrapped.share_profile_display_name = Some(record.name.name); + wrapped.share_profile_first_name = Some(record.name.first); + wrapped.share_profile_last_name = Some(record.name.last); + wrapped.share_profile_avatar = record.image; + } + Err(e) => { + warn!( + "inline ShareProfile fetch failed (record_key={}…, has_poster={}): {:?}", + key_prefix, + share_msg.poster.is_some(), + e + ); + } + } + } + + async fn get_or_init_findmy_client(&self) -> Result<Arc<WrappedFindMyClient>, WrappedError> { + let mut locked = self.findmy_client.lock().await; + if let Some(client) = &*locked { + return Ok(client.clone()); + } + + let tp = self.token_provider.as_ref().ok_or(WrappedError::GenericError { + msg: "No TokenProvider available".into(), + })?; + let dsid = tp.get_dsid().await?; + let keychain = self.get_or_init_cloud_keychain_client().await?; + let cloudkit = keychain.client.clone(); + let account = tp.get_account(); + let anisette = account.lock().await.anisette.clone(); + + let state_path = subsystem_state_path("findmy.state"); + let state_bytes = match std::fs::read(&state_path) { + Ok(data) => data, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => rustpush::findmy::FindMyState::new(dsid).encode()?, + Err(err) => { + return Err(WrappedError::GenericError { + msg: format!("Failed to read Find My state: {}", err), + }) + } + }; + let state_path_for_closure = state_path.clone(); + let state_manager = rustpush::findmy::FindMyStateManager::new( + &state_bytes, + Box::new(move |data| { + if let Err(err) = std::fs::write(&state_path_for_closure, data) { + warn!("Failed to persist Find My state to {}: {}", state_path_for_closure, err); + } + }), + ); + + let wrapped = Arc::new(WrappedFindMyClient { + inner: Arc::new( + rustpush::findmy::FindMyClient::new( + self.conn.clone(), + cloudkit, + keychain, + self.os_config.clone(), + state_manager, + tp.inner.clone(), + anisette, + self.client.identity.clone(), + ) + .await?, + ), + }); + *locked = Some(wrapped.clone()); + Ok(wrapped) + } + + async fn get_or_init_facetime_client(&self) -> Result<Arc<WrappedFaceTimeClient>, WrappedError> { + let mut locked = self.facetime_client.lock().await; + if let Some(client) = &*locked { + return Ok(client.clone()); + } + + let state_path = subsystem_state_path("facetime-state.plist"); + let state = read_plist_state::<rustpush::facetime::FTState>(&state_path).unwrap_or_default(); + let state_path_for_closure = state_path.clone(); + + let wrapped = Arc::new(WrappedFaceTimeClient { + inner: Arc::new( + rustpush::facetime::FTClient::new( + state, + Box::new(move |state| persist_plist_state(&state_path_for_closure, state)), + self.conn.clone(), + self.client.identity.clone(), + self.os_config.clone(), + ) + .await, + ), + }); + *locked = Some(wrapped.clone()); + Ok(wrapped) + } + + async fn get_or_init_passwords_client(&self) -> Result<Arc<WrappedPasswordsClient>, WrappedError> { + let mut locked = self.passwords_client.lock().await; + if let Some(client) = &*locked { + return Ok(client.clone()); + } + + let keychain = self.get_or_init_cloud_keychain_client().await?; + let cloudkit = keychain.client.clone(); + let state_path = subsystem_state_path("passwords-state.plist"); + let state = read_plist_state::<rustpush::passwords::PasswordState>(&state_path).unwrap_or_default(); + let state_path_for_closure = state_path.clone(); + + let wrapped = Arc::new(WrappedPasswordsClient { + inner: rustpush::passwords::PasswordManager::new( + keychain, + cloudkit, + self.client.identity.clone(), + self.conn.clone(), + state, + Box::new(move |state| persist_plist_state(&state_path_for_closure, state)), + Box::new(|_, _| {}), + ) + .await, + }); + *locked = Some(wrapped.clone()); + Ok(wrapped) + } + + async fn get_or_init_statuskit_client(&self) -> Result<Arc<WrappedStatusKitClient>, WrappedError> { + // Fast path: return cached client without holding the lock during init. + { + let locked = self.statuskit_client.lock().await; + if let Some(client) = &*locked { + return Ok(client.clone()); + } + } + + // Slow path: StatusKitClient::new() calls request_topics which can + // block the tokio thread. Build the client WITHOUT holding the mutex + // so concurrent callers are not blocked. + let tp = self.token_provider.as_ref().ok_or(WrappedError::GenericError { + msg: "No TokenProvider available".into(), + })?; + let state_path = subsystem_state_path("statuskit-state.plist"); + let state = read_plist_state::<rustpush::statuskit::StatusKitState>(&state_path).unwrap_or_default(); + let state_path_for_closure = state_path.clone(); + + let wrapped = Arc::new(WrappedStatusKitClient { + inner: rustpush::statuskit::StatusKitClient::new( + state, + Box::new(move |state| persist_plist_state(&state_path_for_closure, state)), + tp.inner.clone(), + self.conn.clone(), + self.os_config.clone(), + self.client.identity.clone(), + ) + .await, + interests: tokio::sync::Mutex::new(Vec::new()), + }); + + // Write path: re-acquire briefly to store result; handle race. + let mut locked = self.statuskit_client.lock().await; + if let Some(existing) = &*locked { + return Ok(existing.clone()); + } + *locked = Some(wrapped.clone()); + Ok(wrapped) + } + + async fn get_or_init_sharedstreams_client(&self) -> Result<Arc<WrappedSharedStreamsClient>, WrappedError> { + let mut locked = self.sharedstreams_client.lock().await; + if let Some(client) = &*locked { + return Ok(client.clone()); + } + + let tp = self.token_provider.as_ref().ok_or(WrappedError::GenericError { + msg: "No TokenProvider available".into(), + })?; + let dsid = tp.get_dsid().await?; + let mme_delegate = tp.parse_mme_delegate().await?; + let account = tp.get_account(); + let anisette = account.lock().await.anisette.clone(); + let state_path = subsystem_state_path("sharedstreams-state.plist"); + let state = read_plist_state::<rustpush::sharedstreams::SharedStreamsState>(&state_path) + .or_else(|| rustpush::sharedstreams::SharedStreamsState::new(dsid, &mme_delegate)) + .ok_or(WrappedError::GenericError { + msg: "Failed to initialize Shared Streams state".into(), + })?; + let state_path_for_closure = state_path.clone(); + + let wrapped = Arc::new(WrappedSharedStreamsClient { + inner: Arc::new( + rustpush::sharedstreams::SharedStreamClient::new( + state, + Box::new(move |state| persist_plist_state(&state_path_for_closure, state)), + tp.inner.clone(), + self.conn.clone(), + anisette, + self.os_config.clone(), + ) + .await, + ), + }); + *locked = Some(wrapped.clone()); + Ok(wrapped) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl Client { + /// Reset the cached CloudKit client so the next CloudKit operation + /// re-initializes it with fresh auth tokens. Call this after a sync + /// failure (e.g. TokenMissing) before retrying. + pub async fn reset_cloud_client(&self) { + *self.cloud_messages_client.lock().await = None; + *self.cloud_keychain_client.lock().await = None; + *self.profiles_client.lock().await = None; + } + + /// Initialize the StatusKit presence system. Must be called after login + /// when a TokenProvider is available. The callback receives presence + /// updates for subscribed handles via the APNs receive loop. + pub async fn init_statuskit(&self, callback: Box<dyn StatusCallback>) -> Result<(), WrappedError> { + // Reuse the existing get_or_init_statuskit_client() path so the + // WrappedStatusKitClient sub-client (used for share_status etc.) + // and the shared raw handle (used by the receive loop) both point + // at the same underlying StatusKitClient. + let wrapped = self.get_or_init_statuskit_client().await?; + *self.shared_statuskit.write().await = Some(wrapped.inner.clone()); + *self.status_callback.write().await = Some(Arc::from(callback)); + info!("StatusKit initialized — presence system ready"); + Ok(()) + } + + /// Publish our own presence status. `active=true` → online; + /// `active=false` → away/DND (mode defaults to do-not-disturb). + pub async fn set_status(&self, active: bool) -> Result<(), WrappedError> { + let sk = self.shared_statuskit.read().await.clone().ok_or(WrappedError::GenericError { + msg: "StatusKit not initialized".into(), + })?; + let status = if active { + rustpush::statuskit::StatusKitStatus::new_active() + } else { + rustpush::statuskit::StatusKitStatus::new_away("com.apple.donotdisturb.mode.default".to_string()) + }; + sk.share_status(&status).await.map_err(|e| WrappedError::GenericError { + msg: format!("Failed to publish status: {}", e), + }) + } + + /// Subscribe to presence updates for the given handles (e.g. "tel:+1...", + /// "mailto:..."). The subscription is held internally until + /// `unsubscribe_all_status()` is called. + pub async fn subscribe_to_status(&self, handles: Vec<String>) -> Result<(), WrappedError> { + let sk = self.shared_statuskit.read().await.clone().ok_or(WrappedError::GenericError { + msg: "StatusKit not initialized".into(), + })?; + + // Contacts may key back under a different handle form than their ghost + // ID (e.g. mailto: vs tel:). Augment the ghost list with every "from" + // handle persisted in statuskit-state.plist so request_handles matches + // all available channels regardless of handle form. + let ghost_count = handles.len(); + let mut augmented = handles; + { + let mut extra: Vec<String> = Vec::new(); + let state_path = subsystem_state_path("statuskit-state.plist"); + if let Ok(data) = std::fs::read(&state_path) { + if let Ok(value) = plist::from_bytes::<plist::Value>(&data) { + if let Some(dict) = value.as_dictionary() { + if let Some(keys_dict) = dict.get("keys").and_then(|v| v.as_dictionary()) { + let handle_set: std::collections::HashSet<&str> = + augmented.iter().map(|s| s.as_str()).collect(); + for (_channel_id, entry) in keys_dict { + if let Some(from_str) = entry.as_dictionary() + .and_then(|d| d.get("from")) + .and_then(|v| v.as_string()) + { + if !handle_set.contains(from_str) { + extra.push(from_str.to_string()); + } + } + } + } + } + } + } + augmented.extend(extra); + } + + let token = sk.request_handles(&augmented).await; + self.statuskit_interest_tokens.lock().await.push(token); + info!( + "Requested presence subscription for {} handle(s) ({} ghost + {} from keys)", + augmented.len(), + ghost_count, + augmented.len() - ghost_count, + ); + Ok(()) + } + + /// Drop all presence subscriptions. + pub async fn unsubscribe_all_status(&self) { + self.statuskit_interest_tokens.lock().await.clear(); + info!("Unsubscribed from all presence channels"); + } + + /// Resolve a handle to all known handles for the same person by querying + /// the IDS cache for a matching sender_correlation_identifier. If the handle + /// isn't in the cache yet, triggers an IDS query to populate it. + /// + /// Returns a list of handles that share the same Apple ID as the input. + /// For example, if the input is "mailto:user@icloud.com", the result might + /// include "tel:+12012337620" if they belong to the same person. + /// + /// The `known_handles` parameter is a list of ghost handles from the bridge + /// database — we check each one against the IDS cache for a matching + /// correlation ID. + pub async fn resolve_handle(&self, handle: String, known_handles: Vec<String>) -> Result<Vec<String>, WrappedError> { + let my_handles = self.client.identity.get_handles().await; + let my_handle = my_handles.first().ok_or(WrappedError::GenericError { + msg: "no handle available".into(), + })?.clone(); + + // Try two IDS services in order: + // 1. com.apple.madrid (iMessage) — the normal path + // 2. com.apple.private.alloy.status.keysharing (StatusKit) — fallback + // + // Contacts who use iMessage only via their phone number have zero + // Madrid keys for their Apple ID (mailto:) — IDS reports "zero keys". + // However, they DO have StatusKit-keysharing keys because their device + // sent us a key-sharing message using their Apple ID. The keysharing + // service uses the same sender_correlation_identifier as Madrid, so + // the same correlation-ID scan works; we just need the right service. + // + // Each validate_targets call is bounded to 5 s via tokio timeout so + // a single-handle IDS query cannot block the goroutine indefinitely. + const SERVICES: &[&str] = &[ + "com.apple.madrid", + "com.apple.private.alloy.status.keysharing", + ]; + + for &service in SERVICES { + match tokio::time::timeout( + Duration::from_secs(5), + self.client.identity.validate_targets(&[handle.clone()], service, &my_handle), + ).await { + Ok(Ok(_)) => {} + Ok(Err(e)) => info!("resolve_handle: validate_targets({}) failed for {}: {:?}", service, handle, e), + Err(_) => info!("resolve_handle: validate_targets({}) timed out after 5s for {}", service, handle), + } + + let cache = self.client.identity.cache.lock().await; + let correlation_id = match cache.get_correlation_id(service, &my_handle, &handle) { + Some(cid) if !cid.is_empty() => cid, + _ => { + info!("resolve_handle: no correlation ID for {} in {} — trying next service", handle, service); + continue; // try next service + } + }; + + // Scan known handles for matching correlation IDs in this service. + // known_handles are pre-populated from message processing (tel:) and + // StatusKit invite responses (mailto:), so no extra IDS calls needed. + let mut aliases = vec![]; + for known_handle in &known_handles { + if known_handle == &handle { + continue; + } + if let Some(cid) = cache.get_correlation_id(service, &my_handle, known_handle) { + if cid == correlation_id { + aliases.push(known_handle.clone()); + } + } + } + + info!("resolve_handle: {} → {} alias(es) via {} (correlation {})", handle, aliases.len(), service, correlation_id); + return Ok(aliases); + } + + info!("resolve_handle: no correlation ID for {} in any IDS service", handle); + Ok(vec![]) + } + + /// Like resolve_handle but reads ONLY from the in-memory IDS cache — + /// no network call, no validate_targets, no blocking. Returns the + /// tel: (or other) aliases that share the same sender_correlation_identifier + /// as `handle` according to data already cached from prior message processing. + /// Returns an empty Vec if the handle is not in the cache yet. + pub async fn resolve_handle_cached(&self, handle: String, known_handles: Vec<String>) -> Vec<String> { + let my_handles = self.client.identity.get_handles().await; + let my_handle = match my_handles.first() { + Some(h) => h.clone(), + None => return vec![], + }; + + let cache = self.client.identity.cache.lock().await; + let correlation_id = match cache.get_correlation_id("com.apple.madrid", &my_handle, &handle) { + Some(cid) if !cid.is_empty() => cid, + _ => { + info!("resolve_handle_cached: {} not in IDS cache", handle); + return vec![]; + } + }; + + let mut aliases = vec![]; + for known_handle in &known_handles { + if known_handle == &handle { + continue; + } + if let Some(cid) = cache.get_correlation_id("com.apple.madrid", &my_handle, known_handle) { + if cid == correlation_id { + aliases.push(known_handle.clone()); + } + } + } + info!("resolve_handle_cached: {} → {} alias(es) from cache (correlation {})", handle, aliases.len(), correlation_id); + aliases + } + + /// Reset all StatusKit APNs channel cursors (last_msg_ns) to 1 in the + /// persisted state file. Must be called BEFORE init_statuskit() so the + /// StatusKit client loads the reset cursors on startup. Resetting to 1 + /// causes Apple's APNs server to replay the most recently published status + /// for each channel (within the 7-day retention window), allowing the + /// bridge to learn the current presence of contacts on startup instead of + /// waiting for the next change. Keys and channel identifiers are preserved. + pub fn reset_statuskit_cursors(&self) { + let state_path = subsystem_state_path("statuskit-state.plist"); + let mut state = read_plist_state::<rustpush::statuskit::StatusKitState>(&state_path) + .unwrap_or_default(); + let count = state.recent_channels.len(); + for ch in state.recent_channels.iter_mut() { + ch.last_msg_ns = 1; + } + persist_plist_state(&state_path, &state); + info!("Reset {} StatusKit channel cursor(s) to 1 for replay", count); + } + + /// Send our StatusKit key to the specified contact handles (via IDS + /// keysharing), establishing the mutual key exchange needed to receive + /// their Focus/DND status updates. Should be called after init_statuskit() + /// for handles that are not yet in the persisted key state. When the + /// contact's device receives our key, it should respond by sending its own + /// key, which the receive loop stores in the StatusKit state. + pub async fn invite_to_status_sharing( + &self, + sender_handle: String, + handles: Vec<String>, + ) -> Result<(), WrappedError> { + let sk = self.shared_statuskit.read().await.clone().ok_or(WrappedError::GenericError { + msg: "StatusKit not initialized".into(), + })?; + + // Prime the IDS cache for the keysharing topic with strong flags + // (required_for_message=true, result_expected=true) before calling + // invite_to_channel. invite_to_channel internally re-does cache_keys + // with refresh=false and WEAK flags; that call is now idempotent + // (does_not_need_refresh returns true for any freshly-primed entry). + // After priming, get_participants_targets gives us the peer push-tokens + // also needed by get_key_for_sender to decrypt their keysharing response. + let targets = sk.identity.targets_for_handles( + "com.apple.private.alloy.status.keysharing", + &handles, + &sender_handle, + ).await.map_err(|e| WrappedError::GenericError { + msg: format!("StatusKit targets_for_handles failed: {:?}", e), + })?; + info!("StatusKit: IDS found {} delivery target(s) for {} handle(s)", targets.len(), handles.len()); + + // Zero targets here means the cache has stale empty entries from a + // prior session (they're considered "not dirty" for up to 1 hour by + // cache_keys_once even when identities.is_empty()). Invalidate the + // entire IDS cache so the next call hits IDS live. This is a one-off + // cost: it only triggers when a prior query returned empty, and the + // next call will refill caches for all services/handles on demand. + let targets = if targets.is_empty() { + warn!("StatusKit: IDS returned zero targets for keysharing topic — invalidating stale cache and retrying"); + sk.identity.invalidate_id_cache().await; + sk.identity.targets_for_handles( + "com.apple.private.alloy.status.keysharing", + &handles, + &sender_handle, + ).await.map_err(|e| WrappedError::GenericError { + msg: format!("StatusKit targets_for_handles (retry) failed: {:?}", e), + })? + } else { + targets + }; + // Log per-handle target breakdown for diagnosis. + { + let cache = sk.identity.cache.lock().await; + for h in &handles { + let h_targets = cache.get_participants_targets( + "com.apple.private.alloy.status.keysharing", + &sender_handle, + &[h.clone()], + ); + info!("StatusKit invite target breakdown: {} → {} device(s)", h, h_targets.len()); + } + } + info!("StatusKit: IDS resolved {} delivery target(s) for {} handle(s) (after cache check)", targets.len(), handles.len()); + if targets.is_empty() { + return Err(WrappedError::GenericError { + msg: "StatusKit invite aborted: IDS returned zero targets after cache invalidation — peers may not be registered for keysharing topic".into(), + }); + } + + // Populate allowed_modes with standard Focus mode IDs so the + // receiver's iOS sees a real sharing request. An empty list may + // cause iOS to silently ignore the invite. + let standard_modes: Vec<String> = vec![ + "com.apple.donotdisturb.mode.default".into(), + "com.apple.donotdisturb.mode.sleep".into(), + "com.apple.focus.mode.driving".into(), + "com.apple.focus.mode.personal".into(), + "com.apple.focus.mode.work".into(), + ]; + let config_map: std::collections::HashMap<String, rustpush::statuskit::StatusKitPersonalConfig> = + handles.iter() + .map(|h| (h.clone(), rustpush::statuskit::StatusKitPersonalConfig { + allowed_modes: standard_modes.clone(), + })) + .collect(); + + info!("StatusKit: inviting {} handle(s) to key exchange (sender={})", handles.len(), sender_handle); + sk.invite_to_channel(&sender_handle, config_map).await.map_err(|e| WrappedError::GenericError { + msg: format!("StatusKit invite_to_channel failed: {:?}", e), + })?; + info!("StatusKit: invite_to_channel completed successfully for {} handle(s)", handles.len()); + Ok(()) + } + + + /// Fetch a shared iMessage profile (Name & Photo Sharing) from CloudKit. + /// `record_key` and `decryption_key` are obtained from an incoming + /// ShareProfile / UpdateProfile message. + pub async fn fetch_profile( + &self, + record_key: String, + decryption_key: Vec<u8>, + has_poster: bool, + ) -> Result<WrappedProfileRecord, WrappedError> { + let key_prefix: String = record_key.chars().take(8).collect(); + let dec_len = decryption_key.len(); + info!( + "fetch_profile: record_key={}… decryption_key_len={} has_poster={}", + key_prefix, dec_len, has_poster + ); + let profiles = self.get_or_init_profiles_client().await?; + let share_msg = rustpush::ShareProfileMessage { + cloud_kit_record_key: record_key, + cloud_kit_decryption_record_key: decryption_key, + poster: if has_poster { + // Minimal poster struct — the fetch path only checks + // `poster.is_some()` to decide whether to pull the wallpaper + // record alongside the profile record. + Some(rustpush::SharedPoster { + low_res_wallpaper_tag: vec![], + wallpaper_tag: vec![], + message_tag: vec![], + }) + } else { + None + }, + }; + let record = profiles.get_record(&share_msg).await.map_err(|e| { + warn!( + "fetch_profile failed (record_key={}…, has_poster={}): {:?}", + key_prefix, has_poster, e + ); + WrappedError::GenericError { + msg: format!("Failed to fetch profile: {:?}", e), + } + })?; + Ok(WrappedProfileRecord { + display_name: record.name.name, + first_name: record.name.first, + last_name: record.name.last, + avatar: record.image, + }) + } + + pub async fn get_handles(&self) -> Vec<String> { + self.client.identity.get_handles().await + } + + /// Get iCloud auth headers (Authorization + anisette) for MobileMe API calls. + /// Returns None if no token provider is available. + pub async fn get_icloud_auth_headers(&self) -> Result<Option<HashMap<String, String>>, WrappedError> { + match &self.token_provider { + Some(tp) => Ok(Some(tp.get_icloud_auth_headers().await?)), + None => Ok(None), + } + } + + /// Get the contacts CardDAV URL from the MobileMe delegate. + /// Returns None if no token provider is available. + pub async fn get_contacts_url(&self) -> Result<Option<String>, WrappedError> { + match &self.token_provider { + Some(tp) => Ok(tp.get_contacts_url().await?), + None => Ok(None), + } + } + + /// Get the DSID for this account. + pub async fn get_dsid(&self) -> Result<Option<String>, WrappedError> { + match &self.token_provider { + Some(tp) => Ok(Some(tp.get_dsid().await?)), + None => Ok(None), + } + } + + pub async fn get_findmy_client(&self) -> Result<Arc<WrappedFindMyClient>, WrappedError> { + self.get_or_init_findmy_client().await + } + + pub async fn get_facetime_client(&self) -> Result<Arc<WrappedFaceTimeClient>, WrappedError> { + self.get_or_init_facetime_client().await + } + + pub async fn get_passwords_client(&self) -> Result<Arc<WrappedPasswordsClient>, WrappedError> { + self.get_or_init_passwords_client().await + } + + pub async fn get_statuskit_client(&self) -> Result<Arc<WrappedStatusKitClient>, WrappedError> { + self.get_or_init_statuskit_client().await + } + + pub async fn get_sharedstreams_client(&self) -> Result<Arc<WrappedSharedStreamsClient>, WrappedError> { + self.get_or_init_sharedstreams_client().await + } + + pub async fn findmy_phone_refresh_json(&self) -> Result<String, WrappedError> { + let tp = self.token_provider.as_ref().ok_or(WrappedError::GenericError { + msg: "No TokenProvider available".into(), + })?; + let dsid = tp.get_dsid().await?; + let account = tp.get_account(); + let anisette = account.lock().await.anisette.clone(); + + let mut client = rustpush::findmy::FindMyPhoneClient::new( + self.os_config.as_ref(), + dsid, + self.conn.clone(), + anisette, + tp.inner.clone(), + ) + .await?; + client.refresh(self.os_config.as_ref()).await?; + + serde_json::to_string(&client.devices).map_err(|e| WrappedError::GenericError { + msg: format!("Failed to encode Find My Phone devices: {}", e), + }) + } + + pub async fn findmy_friends_refresh_json(&self, daemon: bool) -> Result<String, WrappedError> { + let tp = self.token_provider.as_ref().ok_or(WrappedError::GenericError { + msg: "No TokenProvider available".into(), + })?; + let dsid = tp.get_dsid().await?; + let account = tp.get_account(); + let anisette = account.lock().await.anisette.clone(); + + let mut client = rustpush::findmy::FindMyFriendsClient::new( + self.os_config.as_ref(), + dsid, + tp.inner.clone(), + self.conn.clone(), + anisette, + daemon, + ) + .await?; + client.refresh(self.os_config.as_ref()).await?; + + #[derive(serde::Serialize)] + struct FriendsSnapshot { + selected_friend: Option<String>, + followers: Vec<rustpush::findmy::Follow>, + following: Vec<rustpush::findmy::Follow>, + } + + serde_json::to_string(&FriendsSnapshot { + selected_friend: client.selected_friend, + followers: client.followers, + following: client.following, + }) + .map_err(|e| WrappedError::GenericError { + msg: format!("Failed to encode Find My Friends snapshot: {}", e), + }) + } + + pub async fn findmy_friends_import(&self, daemon: bool, url: String) -> Result<(), WrappedError> { + let tp = self.token_provider.as_ref().ok_or(WrappedError::GenericError { + msg: "No TokenProvider available".into(), + })?; + let dsid = tp.get_dsid().await?; + let account = tp.get_account(); + let anisette = account.lock().await.anisette.clone(); + + let mut client = rustpush::findmy::FindMyFriendsClient::new( + self.os_config.as_ref(), + dsid, + tp.inner.clone(), + self.conn.clone(), + anisette, + daemon, + ) + .await?; + client.import(self.os_config.as_ref(), &url).await?; + Ok(()) + } + + pub async fn validate_targets( + &self, + targets: Vec<String>, + handle: String, + ) -> Vec<String> { + self.client + .identity + .validate_targets(&targets, "com.apple.madrid", &handle) + .await + .unwrap_or_default() + } + + pub async fn send_message( + &self, + conversation: WrappedConversation, + text: String, + html: Option<String>, + handle: String, + reply_guid: Option<String>, + reply_part: Option<String>, + scheduled_ms: Option<u64>, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let service = if conversation.is_sms { + MessageType::SMS { + is_phone: false, + using_number: handle.clone(), + from_handle: None, + } + } else { + MessageType::IMessage + }; + + // Parse rich link encoded as prefix: \x00RL\x01original_url\x01url\x01title\x01summary\x00actual_text + let (actual_text, link_meta) = if text.starts_with("\x00RL\x01") { + let rest = &text[4..]; // skip "\x00RL\x01" + if let Some(end) = rest.find('\x00') { + let metadata = &rest[..end]; + let actual = rest[end + 1..].to_string(); + let fields: Vec<&str> = metadata.splitn(4, '\x01').collect(); + let original_url_str = fields.first().copied().unwrap_or(""); + let url_str = fields.get(1).copied().unwrap_or(""); + let title_str = fields.get(2).copied().unwrap_or(""); + let summary_str = fields.get(3).copied().unwrap_or(""); + + let original_url = NSURL { + base: "$null".to_string(), + relative: original_url_str.to_string(), + }; + let url = if url_str.is_empty() { + None + } else { + Some(NSURL { + base: "$null".to_string(), + relative: url_str.to_string(), + }) + }; + let title = if title_str.is_empty() { None } else { Some(title_str.to_string()) }; + let summary = if summary_str.is_empty() { None } else { Some(summary_str.to_string()) }; + + info!("Sending rich link: url={}, title={:?}", original_url_str, title); + + let lm = LinkMeta { + data: LPLinkMetadata { + image_metadata: None, + version: 1, + icon_metadata: None, + original_url: Some(original_url), + url, + title, + summary, + image: None, + icon: None, + images: None, + icons: None, + is_incomplete: None, + uses_activity_pub: None, + is_encoded_for_local_use: None, + collaboration_type: None, + specialization2: None, + }, + attachments: vec![], + }; + (actual, Some(lm)) + } else { + (text, None) + } + } else { + (text, None) + }; + + let parts = if let Some(ref html_str) = html { + parse_html_to_parts(html_str, &actual_text) + } else { + None + }; + + let schedule = scheduled_ms.map(|ms| ScheduleMode { ms, schedule: true }); + + let normal = if let Some(parts) = parts { + NormalMessage { + parts, + effect: None, + reply_guid: reply_guid.clone(), + reply_part: reply_part.clone(), + service: service.clone(), + subject: None, + app: None, + link_meta, + voice: false, + scheduled: schedule, + embedded_profile: None, + } + } else { + let mut n = NormalMessage::new(actual_text.clone(), service.clone()); + n.link_meta = link_meta; + n.reply_guid = reply_guid.clone(); + n.reply_part = reply_part.clone(); + n.scheduled = schedule; + n + }; + let mut msg = MessageInst::new( + conv.clone(), + &handle, + Message::Message(normal), + ); + match self.client.send(&mut msg).await { + Ok(_) => Ok(msg.id.clone()), + Err(rustpush::PushError::NoValidTargets) if !conversation.is_sms => { + // iMessage failed — no IDS targets. Retry as SMS (without rich link). + info!("No IDS targets, falling back to SMS for {:?}", conv.participants); + let sms_service = MessageType::SMS { + is_phone: false, + using_number: handle.clone(), + from_handle: None, + }; + let mut sms_msg = MessageInst::new( + conv, + &handle, + Message::Message(NormalMessage::new(actual_text, sms_service)), + ); + self.client.send(&mut sms_msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send SMS: {}", e) })?; + Ok(sms_msg.id.clone()) + } + Err(e) => Err(WrappedError::GenericError { msg: format!("Failed to send message: {}", e) }), + } + } + + pub async fn send_tapback( + &self, + conversation: WrappedConversation, + target_uuid: String, + target_part: u64, + reaction: u32, + emoji: Option<String>, + remove: bool, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let reaction_val = match (reaction, &emoji) { + (0, _) => Reaction::Heart, + (1, _) => Reaction::Like, + (2, _) => Reaction::Dislike, + (3, _) => Reaction::Laugh, + (4, _) => Reaction::Emphasize, + (5, _) => Reaction::Question, + (6, Some(em)) => Reaction::Emoji(em.clone()), + _ => Reaction::Heart, + }; + let mut msg = MessageInst::new( + conv, + &handle, + Message::React(ReactMessage { + to_uuid: target_uuid, + to_part: Some(target_part), + reaction: ReactMessageType::React { reaction: reaction_val, enable: !remove }, + to_text: String::new(), + embedded_profile: None, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send tapback: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_typing( + &self, + conversation: WrappedConversation, + typing: bool, + handle: String, + ) -> Result<(), WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new(conv, &handle, Message::Typing(typing, None)); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send typing: {}", e) })?; + Ok(()) + } + + pub async fn send_typing_with_app( + &self, + conversation: WrappedConversation, + typing: bool, + handle: String, + bundle_id: String, + icon: Vec<u8>, + ) -> Result<(), WrappedError> { + let conv: ConversationData = (&conversation).into(); + let app = TypingApp { bundle_id, icon }; + let mut msg = MessageInst::new(conv, &handle, Message::Typing(typing, Some(app))); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send typing with app: {}", e) })?; + Ok(()) + } + + pub async fn send_read_receipt( + &self, + conversation: WrappedConversation, + handle: String, + for_uuid: Option<String>, + ) -> Result<(), WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new(conv, &handle, Message::Read); + if let Some(uuid) = for_uuid { + msg.id = uuid; + } + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send read receipt: {}", e) })?; + Ok(()) + } + + pub async fn send_delivery_receipt( + &self, + conversation: WrappedConversation, + handle: String, + ) -> Result<(), WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new(conv, &handle, Message::Delivered); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send delivery receipt: {}", e) })?; + Ok(()) + } + + pub async fn send_edit( + &self, + conversation: WrappedConversation, + target_uuid: String, + edit_part: u64, + new_text: String, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::Edit(EditMessage { + tuuid: target_uuid, + edit_part, + new_parts: MessageParts(vec![IndexedMessagePart { + part: MessagePart::Text(new_text, Default::default()), + idx: None, + ext: None, + }]), + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send edit: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_unsend( + &self, + conversation: WrappedConversation, + target_uuid: String, + edit_part: u64, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::Unsend(UnsendMessage { + tuuid: target_uuid, + edit_part, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send unsend: {}", e) })?; + Ok(msg.id.clone()) + } + + /// Send a MoveToRecycleBin message to notify other Apple devices that a chat was deleted. + pub async fn send_move_to_recycle_bin( + &self, + conversation: WrappedConversation, + handle: String, + chat_guid: String, + ) -> Result<(), WrappedError> { + let conv: ConversationData = (&conversation).into(); + // Strip mailto:/tel: prefixes and exclude the sender's own handle. + // Apple's OperatedChat.ptcpts contains only the OTHER party's handles, + // not the user's own handle. Including it causes the Mac to not + // recognise the chat being deleted. + let bare_handle = handle.replace("mailto:", "").replace("tel:", ""); + let bare_participants: Vec<String> = conv.participants.iter() + .map(|p| p.replace("mailto:", "").replace("tel:", "")) + .filter(|p| p != &bare_handle) + .collect(); + let operated_chat = OperatedChat { + participants: bare_participants, + group_id: conv.sender_guid.clone().unwrap_or_default(), + guid: chat_guid, + delete_incoming_messages: None, + was_reported_as_junk: None, + }; + let delete_msg = MoveToRecycleBinMessage { + target: DeleteTarget::Chat(operated_chat.clone()), + recoverable_delete_date: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + }; + let mut msg = MessageInst::new(conv, &handle, Message::MoveToRecycleBin(delete_msg)); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send MoveToRecycleBin: {}", e) })?; + // Note: We intentionally do NOT send PermanentDelete. MoveToRecycleBin + // moves the chat to Apple's "Recently Deleted" (30-day retention), + // respecting the user's recycle bin. The chat can be restored with + // !restore-chat which re-uploads the record to CloudKit. + Ok(()) + } + + /// Send a RecoverChat APNs message (command 182) to notify other Apple + /// devices that a chat has been recovered from the recycle bin. + /// This is the inverse of send_move_to_recycle_bin. + pub async fn send_recover_chat( + &self, + conversation: WrappedConversation, + handle: String, + chat_guid: String, + ) -> Result<(), WrappedError> { + let conv: ConversationData = (&conversation).into(); + let bare_handle = handle.replace("mailto:", "").replace("tel:", ""); + let bare_participants: Vec<String> = conv.participants.iter() + .map(|p| p.replace("mailto:", "").replace("tel:", "")) + .filter(|p| p != &bare_handle) + .collect(); + let operated_chat = OperatedChat { + participants: bare_participants, + group_id: conv.sender_guid.clone().unwrap_or_default(), + guid: chat_guid, + delete_incoming_messages: None, + was_reported_as_junk: None, + }; + let mut msg = MessageInst::new(conv, &handle, Message::RecoverChat(operated_chat)); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send RecoverChat: {}", e) })?; + Ok(()) + } + + /// Restore a chat record to CloudKit so it reappears on Apple devices. + /// Re-uploads the chat data using save_chats, which creates or overwrites + /// the record in chatManateeZone. Used by restore-chat to sync un-deletion + /// back to Apple devices. + pub async fn restore_cloud_chat( + &self, + record_name: String, + chat_identifier: String, + group_id: String, + style: i64, + service: String, + display_name: Option<String>, + participants: Vec<String>, + ) -> Result<(), WrappedError> { + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + let chat = rustpush::cloud_messages::CloudChat { + style, + is_filtered: 0, + successful_query: 1, + state: 3, + chat_identifier, + group_id: group_id.clone(), + service_name: service, + original_group_id: group_id, + properties: None, + participants: participants.into_iter().map(|uri| rustpush::cloud_messages::CloudParticipant { uri }).collect(), + prop001: Default::default(), + last_read_message_timestamp: 0, + last_addressed_handle: String::new(), + guid: String::new(), + display_name, + proto001: None, + group_photo_guid: None, + group_photo: None, + }; + let mut chats = std::collections::HashMap::new(); + chats.insert(record_name.clone(), chat); + let results = cloud_messages.save_chats(chats).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to restore CloudKit chat: {}", e) })?; + // Check individual result + if let Some(Err(e)) = results.get(&record_name) { + return Err(WrappedError::GenericError { msg: format!("Failed to save restored chat record: {}", e) }); + } + Ok(()) + } + + /// Delete chat records from CloudKit so they don't reappear during future syncs. + pub async fn delete_cloud_chats( + &self, + chat_ids: Vec<String>, + ) -> Result<(), WrappedError> { + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + cloud_messages.delete_chats(&chat_ids).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to delete CloudKit chats: {}", e) })?; + Ok(()) + } + + /// Delete message records from CloudKit so they don't reappear during future syncs. + pub async fn delete_cloud_messages( + &self, + message_ids: Vec<String>, + ) -> Result<(), WrappedError> { + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + cloud_messages.delete_messages(&message_ids).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to delete CloudKit messages: {}", e) })?; + Ok(()) + } + + /// Purge the recoverable-delete zones from CloudKit to prevent deleted + /// messages from resurrecting. After MoveToRecycleBin, Apple parks records + /// in recoverableMessageDeleteZone; nuking that zone ensures they stay dead. + pub async fn purge_recoverable_zones(&self) -> Result<(), WrappedError> { + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + let container = cloud_messages.get_container().await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to get CloudKit container: {}", e) })?; + container.perform_operations_checked( + &CloudKitSession::new(), + &[ + ZoneDeleteOperation::new(container.private_zone("recoverableMessageDeleteZone".to_string())), + ZoneDeleteOperation::new(container.private_zone("chatBotRecoverableMessageDeleteZone".to_string())), + ], + IsolationLevel::Operation, + ).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to purge recoverable zones: {}", e) })?; + Ok(()) + } + + /// List chat records from Apple's "Recently Deleted" recycle bin. + /// Paginates through all known recoverable-delete zones and returns all + /// non-tombstone chat records found there. + pub async fn list_recoverable_chats(&self) -> Result<Vec<WrappedCloudSyncChat>, WrappedError> { + use rustpush::cloudkit::{pcs_keys_for_record, FetchRecordChangesOperation, CloudKitSession, NO_ASSETS}; + use rustpush::cloudkit_proto::CloudKitRecord; + use rustpush::cloud_messages::{MESSAGES_SERVICE, CloudChat}; + + info!("list_recoverable_chats: starting recycle bin query"); + + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + let container = cloud_messages.get_container().await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to get CloudKit container: {}", e) })?; + + let mut all_chats = Vec::new(); + let mut seen_record_names = std::collections::HashSet::new(); + // Accumulate diagnostics so they appear in the final summary log + // (which we KNOW gets output even if per-page logs are lost). + let mut zone_diag: Vec<String> = Vec::new(); + + for zone_name in ["recoverableMessageDeleteZone", "chatBotRecoverableMessageDeleteZone"] { + let mut token: Option<Vec<u8>> = None; + let mut zone_total_changes = 0usize; + let mut zone_tombstones = 0usize; + let mut zone_non_chat = 0usize; + let mut zone_pcs_err = 0usize; + let mut zone_panic = 0usize; + let mut zone_incomplete = 0usize; + let mut zone_accepted = 0usize; + let mut zone_pages = 0usize; + let mut zone_last_status = 0i32; + + debug!("list_recoverable_chats: fetching zone encryption config for {}", zone_name); + + for page in 0..256 { + zone_pages = page + 1; + let zone_id = container.private_zone(zone_name.to_string()); + let key = container + .get_zone_encryption_config(&zone_id, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("Failed to get zone key for {}: {}", zone_name, e), + })?; + + debug!("list_recoverable_chats: performing FetchRecordChanges on {} page {}", zone_name, page); + + let (_assets, response) = container + .perform( + &CloudKitSession::new(), + FetchRecordChangesOperation::new(zone_id, token, &NO_ASSETS), + ) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("Failed to fetch recoverable chats from {} page {}: {}", zone_name, page, e), + })?; + + let changes_count = response.change.len(); + let status = response.status(); + zone_last_status = status; + zone_total_changes += changes_count; + debug!("list_recoverable_chats: zone={} page={} changes={} status={}", zone_name, page, changes_count, status); + + for change in &response.change { + let id_proto = match change.identifier.as_ref() { + Some(p) => p, + None => continue, + }; + let id_value = match id_proto.value.as_ref() { + Some(v) => v, + None => continue, + }; + let identifier = id_value.name().to_string(); + + let record = match &change.record { + Some(r) => r, + None => { + zone_tombstones += 1; + debug!("list_recoverable_chats: tombstone record {} in {}", identifier, zone_name); + continue; + } + }; + + let record_type = record + .r#type + .as_ref() + .map(|t| t.name().to_string()) + .unwrap_or_else(|| "<missing>".to_string()); + let field_names = recoverable_record_field_names(&record.record_field); + if !record_looks_chat_like(&record.record_field) { + zone_non_chat += 1; + debug!("list_recoverable_chats: skipping non-chat record {} in {} type={} fields={:?}", identifier, zone_name, record_type, field_names); + continue; + } + + let pcskey = match pcs_keys_for_record(record, &key) { + Ok(k) => k, + Err(e) => { + zone_pcs_err += 1; + warn!("list_recoverable_chats: skipping record {} in {}: PCS key error: {}", identifier, zone_name, e); + continue; + } + }; + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + CloudChat::from_record_encrypted(&record.record_field, Some(&pcskey)) + })); + + match result { + Ok(chat) => { + if !seen_record_names.insert(identifier.clone()) { + continue; + } + if let Some(chat) = wrap_recoverable_chat(identifier.clone(), chat) { + zone_accepted += 1; + if record_type != CloudChat::record_type() { + info!( + "list_recoverable_chats: accepted recycle-bin record {} in {} with nonstandard type {}", + identifier, zone_name, record_type + ); + } + all_chats.push(chat); + } else { + zone_incomplete += 1; + warn!( + "list_recoverable_chats: skipping record {} in {}: chat-like fields decoded to incomplete chat (type={}, fields={:?})", + identifier, + zone_name, + record_type, + recoverable_record_field_names(&record.record_field), + ); + } + } + Err(e) => { + zone_panic += 1; + let msg = if let Some(s) = e.downcast_ref::<String>() { s.clone() } + else if let Some(s) = e.downcast_ref::<&str>() { s.to_string() } + else { "unknown panic".to_string() }; + warn!( + "list_recoverable_chats: skipping record {} in {}: deserialization panic: {} (type={}, fields={:?})", + identifier, + zone_name, + msg, + record_type, + recoverable_record_field_names(&record.record_field), + ); + } + } + } + + let next_token = response.sync_continuation_token().to_vec(); + if status == 3 || next_token.is_empty() { + break; + } + token = Some(next_token); + } + + zone_diag.push(format!( + "{}:pages={},changes={},status={},tombstones={},non_chat={},pcs_err={},panic={},incomplete={},accepted={}", + zone_name, zone_pages, zone_total_changes, zone_last_status, + zone_tombstones, zone_non_chat, zone_pcs_err, zone_panic, zone_incomplete, zone_accepted + )); + } + + // Single summary line with ALL diagnostic info embedded. + // This line is confirmed to appear in logs — embed everything here. + info!("list_recoverable_chats: found {} chat(s) in recycle bin [{}]", all_chats.len(), zone_diag.join(" | ")); + Ok(all_chats) + } + + /// Read all message GUIDs from Apple's recoverableMessageDeleteZone. + /// These are the UUIDs of individually deleted messages (moved to trash). + /// When an entire chat is deleted, ALL its messages end up here. + /// Returns the list of message GUIDs that can be matched against + /// cloud_message.uuid to identify deleted chats. + /// Reads both recoverable message zones and returns message GUIDs. + /// These GUIDs match cloud_message.guid in the local DB. Entries may also + /// include base64-encoded metadata after `guid|...`; GUID-only callers can + /// safely ignore the suffix. + pub async fn list_recoverable_message_guids(&self) -> Result<Vec<String>, WrappedError> { + use std::collections::HashSet; + use rustpush::cloudkit::{pcs_keys_for_record, FetchRecordChangesOperation, CloudKitSession, NO_ASSETS}; + use rustpush::cloudkit_proto::CloudKitRecord; + use rustpush::cloud_messages::{MESSAGES_SERVICE, CloudMessage}; + + info!("list_recoverable_message_guids: starting"); + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + let container = cloud_messages.get_container().await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to get CloudKit container: {}", e) })?; + + let mut guids = Vec::new(); + let mut seen = HashSet::new(); + let mut zone_diag = Vec::new(); + let mut successful_zones = 0usize; + let mut zone_errors = Vec::new(); + + for zone_name in ["recoverableMessageDeleteZone", "chatBotRecoverableMessageDeleteZone"] { + let mut token: Option<Vec<u8>> = None; + let zone_id = container.private_zone(zone_name.to_string()); + let key = match container + .get_zone_encryption_config(&zone_id, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await + { + Ok(key) => { + successful_zones += 1; + key + } + Err(e) => { + let err = format!("{}: {}", zone_name, e); + warn!("list_recoverable_message_guids: failed to get zone key for {}", err); + zone_errors.push(err); + continue; + } + }; + + let mut zone_guid_count = 0usize; + for page in 0..256 { + let zone_id = container.private_zone(zone_name.to_string()); + let (_assets, response) = match container + .perform( + &CloudKitSession::new(), + FetchRecordChangesOperation::new(zone_id, token, &NO_ASSETS), + ) + .await + { + Ok(result) => result, + Err(e) => { + let err = format!("{} page {}: {}", zone_name, page, e); + warn!("list_recoverable_message_guids: failed to fetch {}", err); + zone_errors.push(err); + break; + } + }; + + let changes_count = response.change.len(); + let status = response.status(); + + for change in &response.change { + let identifier = change.identifier.as_ref() + .and_then(|i| i.value.as_ref()) + .map(|v| v.name().to_string()) + .unwrap_or_default(); + let record = match &change.record { + Some(r) => r, + None => continue, + }; + + let record_type = record.r#type.as_ref() + .map(|t| t.name().to_string()) + .unwrap_or_default(); + if record_type != "recoverableMessage" { + continue; + } + + let pcskey = match pcs_keys_for_record(record, &key) { + Ok(k) => k, + Err(_) => continue, + }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + CloudMessage::from_record_encrypted(&record.record_field, Some(&pcskey)) + })); + if let Ok(msg) = result { + if !msg.guid.is_empty() && seen.insert(msg.guid.clone()) { + guids.push(encode_recoverable_message_entry(&msg.guid, &identifier, &msg)); + zone_guid_count += 1; + } + } + } + + info!("list_recoverable_message_guids: zone={} page={} changes={} status={} guids_so_far={}", zone_name, page, changes_count, status, guids.len()); + + let next_token = response.sync_continuation_token().to_vec(); + if status == 3 || next_token.is_empty() { + break; + } + token = Some(next_token); + } + zone_diag.push(format!("{}:{}", zone_name, zone_guid_count)); + } + + if successful_zones == 0 && !zone_errors.is_empty() { + return Err(WrappedError::GenericError { + msg: format!("Failed to read recoverable message zones: {}", zone_errors.join(" | ")), + }); + } + + info!("list_recoverable_message_guids: found {} deleted message GUID(s) [{}]", guids.len(), zone_diag.join(" | ")); + Ok(guids) + } + + /// Raw diagnostic dump of the recoverable zone. Returns a human-readable + /// string describing every change record in the zone (not just chat-like + /// ones). Used by the !debug-recycle-bin bridgebot command. + pub async fn debug_recoverable_zones(&self) -> Result<String, WrappedError> { + use rustpush::cloudkit::{FetchRecordChangesOperation, CloudKitSession, NO_ASSETS}; + use rustpush::cloud_messages::MESSAGES_SERVICE; + + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + let container = cloud_messages.get_container().await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to get CloudKit container: {}", e) })?; + + let mut lines = Vec::new(); + + for zone_name in ["recoverableMessageDeleteZone", "chatBotRecoverableMessageDeleteZone"] { + let mut token: Option<Vec<u8>> = None; + let mut total_changes = 0usize; + + // Try to get zone encryption config — if this fails, report the error + let zone_id = container.private_zone(zone_name.to_string()); + let key_result = container + .get_zone_encryption_config(&zone_id, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await; + let _key = match key_result { + Ok(k) => { + lines.push(format!("✅ Zone {} — encryption config OK", zone_name)); + k + } + Err(e) => { + lines.push(format!("❌ Zone {} — encryption config FAILED: {}", zone_name, e)); + continue; + } + }; + + for page in 0..10 { + let zone_id = container.private_zone(zone_name.to_string()); + let fetch_result = container + .perform( + &CloudKitSession::new(), + FetchRecordChangesOperation::new(zone_id, token, &NO_ASSETS), + ) + .await; + + let (_assets, response) = match fetch_result { + Ok(r) => r, + Err(e) => { + lines.push(format!(" ❌ Page {} fetch FAILED: {}", page, e)); + break; + } + }; + + let status = response.status(); + let changes = response.change.len(); + total_changes += changes; + lines.push(format!(" Page {}: {} changes, status={}", page, changes, status)); + + for change in &response.change { + let id = change.identifier.as_ref() + .and_then(|i| i.value.as_ref()) + .map(|v| v.name().to_string()) + .unwrap_or_else(|| "<no-id>".to_string()); + + match &change.record { + None => { + lines.push(format!(" [tombstone] {}", id)); + } + Some(record) => { + let rtype = record.r#type.as_ref() + .map(|t| t.name().to_string()) + .unwrap_or_else(|| "<missing>".to_string()); + let fields = recoverable_record_field_names(&record.record_field); + let chat_like = record_looks_chat_like(&record.record_field); + lines.push(format!(" [record] {} type={} chat_like={} fields={:?}", id, rtype, chat_like, fields)); + } + } + } + + let next_token = response.sync_continuation_token().to_vec(); + if status == 3 || next_token.is_empty() { + break; + } + token = Some(next_token); + } + + lines.push(format!(" Total: {} changes in {}", total_changes, zone_name)); + } + + Ok(lines.join("\n")) + } + + pub async fn send_attachment( + &self, + conversation: WrappedConversation, + data: Vec<u8>, + mime: String, + uti_type: String, + filename: String, + handle: String, + reply_guid: Option<String>, + reply_part: Option<String>, + body: Option<String>, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + // Detect voice messages by UTI (CAF files from OGG→CAF remux are voice recordings) + let is_voice = uti_type == "com.apple.coreaudio-format"; + let service = if conversation.is_sms { + MessageType::SMS { + is_phone: false, + using_number: handle.clone(), + from_handle: None, + } + } else { + MessageType::IMessage + }; + + // Prepare and upload the attachment via MMCS + let cursor = Cursor::new(&data); + let prepared = MMCSFile::prepare_put(cursor).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to prepare MMCS upload: {}", e) })?; + + let cursor2 = Cursor::new(&data); + let attachment = Attachment::new_mmcs( + &self.conn, + &prepared, + cursor2, + &mime, + &uti_type, + &filename, + |_current, _total| {}, + ).await.map_err(|e| WrappedError::GenericError { msg: format!("Failed to upload attachment: {}", e) })?; + + let parts = vec![IndexedMessagePart { + part: MessagePart::Attachment(attachment.clone()), + idx: None, + ext: None, + }]; + + // Captions are sent via the subject field in the iMessage plist, + // not as a separate text part in the XML body. + let subject = body.clone().filter(|s| !s.is_empty()); + + let mut msg = MessageInst::new( + conv.clone(), + &handle, + Message::Message(NormalMessage { + parts: MessageParts(parts), + effect: None, + reply_guid: reply_guid.clone(), + reply_part: reply_part.clone(), + service, + subject, + app: None, + link_meta: None, + voice: is_voice, + scheduled: None, + embedded_profile: None, + }), + ); + match self.client.send(&mut msg).await { + Ok(_) => Ok(msg.id.clone()), + Err(rustpush::PushError::NoValidTargets) if !conversation.is_sms => { + info!("No IDS targets for attachment, falling back to SMS for {:?}", conv.participants); + let sms_service = MessageType::SMS { + is_phone: false, + using_number: handle.clone(), + from_handle: None, + }; + let sms_parts = vec![IndexedMessagePart { + part: MessagePart::Attachment(attachment), + idx: None, + ext: None, + }]; + let sms_subject = body.filter(|s| !s.is_empty()); + let mut sms_msg = MessageInst::new( + conv, + &handle, + Message::Message(NormalMessage { + parts: MessageParts(sms_parts), + effect: None, + reply_guid: reply_guid, + reply_part: reply_part, + service: sms_service, + subject: sms_subject, + app: None, + link_meta: None, + voice: is_voice, + scheduled: None, + embedded_profile: None, + }), + ); + self.client.send(&mut sms_msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send SMS attachment: {}", e) })?; + Ok(sms_msg.id.clone()) + } + Err(e) => Err(WrappedError::GenericError { msg: format!("Failed to send attachment: {}", e) }), + } + } + + /// Cancel a previously scheduled message. + pub async fn send_unschedule( + &self, + conversation: WrappedConversation, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::Unschedule, + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send unschedule: {}", e) })?; + Ok(msg.id.clone()) + } + + /// Rename an iMessage group chat. Delivers a RenameMessage to all participants + /// so the group name updates on all of the user's Apple devices. + pub async fn send_rename_group( + &self, + conversation: WrappedConversation, + new_name: String, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::RenameMessage(RenameMessage { new_name }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send rename: {}", e) })?; + Ok(msg.id.clone()) + } + + /// Update the participant list for an iMessage group chat. + /// `new_participants` must be the FULL new list of all participants (not + /// just the delta). `group_version` should be strictly increasing; using + /// the current Unix timestamp in seconds is a safe default when the exact + /// protocol counter is unknown. + pub async fn send_change_participants( + &self, + conversation: WrappedConversation, + new_participants: Vec<String>, + group_version: u64, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::ChangeParticipants(ChangeParticipantMessage { + new_participants, + group_version, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send participant change: {}", e) })?; + Ok(msg.id.clone()) + } + + /// Upload `photo_data` to MMCS and deliver an IconChange message to set the + /// group chat photo on all of the user's Apple devices. The image should be + /// a 570×570 PNG as Apple expects. `group_version` should be strictly + /// increasing; using the current Unix timestamp in seconds is a safe default. + pub async fn send_icon_change( + &self, + conversation: WrappedConversation, + photo_data: Vec<u8>, + group_version: u64, + handle: String, + ) -> Result<String, WrappedError> { + // Prepare the MMCS encryption envelope (computes signature/key). + let cursor = Cursor::new(&photo_data); + let prepared = MMCSFile::prepare_put(cursor).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to prepare icon MMCS upload: {}", e) })?; + + // Upload to Apple's MMCS servers and get back the file descriptor. + let cursor2 = Cursor::new(&photo_data); + let mmcs = MMCSFile::new(&self.conn, &prepared, cursor2, |_current, _total| {}).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to upload icon to MMCS: {}", e) })?; + + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::IconChange(IconChangeMessage { + file: Some(mmcs), + group_version, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send icon change: {}", e) })?; + Ok(msg.id.clone()) + } + + /// Deliver an IconChange message that clears (removes) the group chat photo + /// on all of the user's Apple devices. + pub async fn send_icon_clear( + &self, + conversation: WrappedConversation, + group_version: u64, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::IconChange(IconChangeMessage { + file: None, + group_version, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send icon clear: {}", e) })?; + Ok(msg.id.clone()) + } + + /// Broadcast a PeerCacheInvalidate to all participants in the conversation. + /// Receiving clients respond by refreshing their IDS key cache for the sender, + /// which resolves delivery failures caused by stale/rotated identity keys. + pub async fn send_peer_cache_invalidate( + &self, + conversation: WrappedConversation, + handle: String, + ) -> Result<(), WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new(conv, &handle, Message::PeerCacheInvalidate); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send peer cache invalidate: {}", e) })?; + Ok(()) + } + + pub async fn send_sms_activation( + &self, + conversation: WrappedConversation, + enable: bool, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new(conv, &handle, Message::EnableSmsActivation(enable)); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send SMS activation: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_sms_confirm_sent( + &self, + conversation: WrappedConversation, + sms_status: bool, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new(conv, &handle, Message::SmsConfirmSent(sms_status)); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send SMS confirm sent: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_message_read_on_device( + &self, + conversation: WrappedConversation, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new(conv, &handle, Message::MessageReadOnDevice); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send message-read-on-device: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_mark_unread( + &self, + conversation: WrappedConversation, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new(conv, &handle, Message::MarkUnread); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send mark unread: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_error_message( + &self, + conversation: WrappedConversation, + for_uuid: String, + error_status: u64, + status_str: String, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::Error(rustpush::ErrorMessage { + for_uuid, + status: error_status, + status_str, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send error message: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_update_extension( + &self, + conversation: WrappedConversation, + for_uuid: String, + extension: WrappedStickerExtension, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let ext = PartExtension::Sticker { + msg_width: extension.msg_width, + rotation: extension.rotation, + sai: extension.sai, + scale: extension.scale, + update: Some(false), + sli: extension.sli, + normalized_x: extension.normalized_x, + normalized_y: extension.normalized_y, + version: extension.version, + hash: extension.hash, + safi: extension.safi, + effect_type: extension.effect_type, + sticker_id: extension.sticker_id, + }; + let mut msg = MessageInst::new( + conv, + &handle, + Message::UpdateExtension(UpdateExtensionMessage { for_uuid, ext }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send update extension: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_permanent_delete_messages( + &self, + conversation: WrappedConversation, + message_uuids: Vec<String>, + is_scheduled: bool, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::PermanentDelete(PermanentDeleteMessage { + target: DeleteTarget::Messages(message_uuids), + is_scheduled, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send permanent delete (messages): {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_permanent_delete_chat( + &self, + conversation: WrappedConversation, + chat_guid: String, + is_scheduled: bool, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let bare_handle = handle.replace("mailto:", "").replace("tel:", ""); + let bare_participants: Vec<String> = conv.participants.iter() + .map(|p| p.replace("mailto:", "").replace("tel:", "")) + .filter(|p| p != &bare_handle) + .collect(); + let target = DeleteTarget::Chat(OperatedChat { + participants: bare_participants, + group_id: conv.sender_guid.clone().unwrap_or_default(), + guid: chat_guid, + delete_incoming_messages: None, + was_reported_as_junk: None, + }); + let mut msg = MessageInst::new( + conv, + &handle, + Message::PermanentDelete(PermanentDeleteMessage { target, is_scheduled }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send permanent delete (chat): {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_update_profile( + &self, + conversation: WrappedConversation, + profile: Option<WrappedShareProfileData>, + share_contacts: bool, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mapped_profile = profile.map(|p| { + let poster = match (p.low_res_wallpaper_tag, p.wallpaper_tag, p.message_tag) { + (Some(low), Some(wall), Some(msg)) => Some(SharedPoster { + low_res_wallpaper_tag: low, + wallpaper_tag: wall, + message_tag: msg, + }), + _ => None, + }; + ShareProfileMessage { + cloud_kit_decryption_record_key: p.cloud_kit_decryption_record_key, + cloud_kit_record_key: p.cloud_kit_record_key, + poster, + } + }); + let mut msg = MessageInst::new( + conv, + &handle, + Message::UpdateProfile(UpdateProfileMessage { + profile: mapped_profile, + share_contacts, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send update profile: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_share_profile( + &self, + conversation: WrappedConversation, + cloud_kit_record_key: String, + cloud_kit_decryption_record_key: Vec<u8>, + low_res_wallpaper_tag: Option<Vec<u8>>, + wallpaper_tag: Option<Vec<u8>>, + message_tag: Option<Vec<u8>>, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let poster = match (low_res_wallpaper_tag, wallpaper_tag, message_tag) { + (Some(low), Some(wall), Some(msg)) => Some(SharedPoster { + low_res_wallpaper_tag: low, + wallpaper_tag: wall, + message_tag: msg, + }), + _ => None, + }; + let mut msg = MessageInst::new( + conv, + &handle, + Message::ShareProfile(ShareProfileMessage { + cloud_kit_decryption_record_key, + cloud_kit_record_key, + poster, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send share profile: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_update_profile_sharing( + &self, + conversation: WrappedConversation, + shared_dismissed: Vec<String>, + shared_all: Vec<String>, + version: u64, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new( + conv, + &handle, + Message::UpdateProfileSharing(UpdateProfileSharingMessage { + shared_dismissed, + shared_all, + version, + }), + ); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send update profile sharing: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_notify_anyways( + &self, + conversation: WrappedConversation, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let mut msg = MessageInst::new(conv, &handle, Message::NotifyAnyways); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send notify anyways: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn send_set_transcript_background( + &self, + conversation: WrappedConversation, + group_version: u64, + image_data: Option<Vec<u8>>, + handle: String, + ) -> Result<String, WrappedError> { + let conv: ConversationData = (&conversation).into(); + let chat_id = conv.sender_guid.clone(); + let mmcs_file = if let Some(data) = image_data { + let prepared = MMCSFile::prepare_put(Cursor::new(&data)).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to prepare transcript background MMCS upload: {}", e) })?; + let uploaded = MMCSFile::new(&self.conn, &prepared, Cursor::new(&data), |_current, _total| {}).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to upload transcript background to MMCS: {}", e) })?; + Some(uploaded) + } else { + None + }; + let update = SetTranscriptBackgroundMessage::from_mmcs(mmcs_file, group_version, chat_id); + let mut msg = MessageInst::new(conv, &handle, Message::SetTranscriptBackground(update)); + self.client.send(&mut msg).await + .map_err(|e| WrappedError::GenericError { msg: format!("Failed to send transcript background update: {}", e) })?; + Ok(msg.id.clone()) + } + + pub async fn cloud_sync_chats( + &self, + continuation_token: Option<String>, + ) -> Result<WrappedCloudSyncChatsPage, WrappedError> { + let token = decode_continuation_token(continuation_token)?; + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + + const MAX_SYNC_ATTEMPTS: usize = 4; + let mut sync_result = None; + let mut last_pcs_err: Option<rustpush::PushError> = None; + + for attempt in 0..MAX_SYNC_ATTEMPTS { + match cloud_messages.sync_chats(token.clone()).await { + Ok(result) => { + sync_result = Some(result); + break; + } + Err(err) if is_pcs_recoverable_error(&err) => { + let attempt_no = attempt + 1; + warn!( + "CloudKit chats sync hit PCS key error on attempt {}/{}: {}", + attempt_no, + MAX_SYNC_ATTEMPTS, + err + ); + last_pcs_err = Some(err); + if attempt_no < MAX_SYNC_ATTEMPTS { + self.recover_cloud_pcs_state("CloudKit chats sync").await?; + continue; + } + } + Err(err) => { + return Err(WrappedError::GenericError { + msg: format!("Failed to sync CloudKit chats: {}", err), + }); + } + } + } + + let (next_token, chats, status) = match sync_result { + Some(result) => result, + None => { + let err = last_pcs_err.map(|e| e.to_string()).unwrap_or_else(|| "unknown error".into()); + return Err(WrappedError::GenericError { + msg: format!("Failed to sync CloudKit chats after PCS recovery retries: {}", err), + }); + } + }; + + let updated_timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|dur| dur.as_millis() as u64) + .unwrap_or_default(); + + let mut normalized = Vec::with_capacity(chats.len()); + for (record_name, chat_opt) in chats { + if let Some(chat) = chat_opt { + let cloud_chat_id = if chat.chat_identifier.is_empty() { + record_name.clone() + } else { + chat.chat_identifier.clone() + }; + normalized.push(WrappedCloudSyncChat { + record_name, + cloud_chat_id, + group_id: chat.group_id, + style: chat.style, + service: chat.service_name, + display_name: chat.display_name, + participants: chat.participants.into_iter().map(|p| p.uri).collect(), + deleted: false, + updated_timestamp_ms, + group_photo_guid: chat.group_photo_guid, + is_filtered: chat.is_filtered, + }); + } else { + normalized.push(WrappedCloudSyncChat { + cloud_chat_id: record_name.clone(), + group_id: String::new(), + style: 0, + record_name, + service: String::new(), + display_name: None, + participants: vec![], + deleted: true, + updated_timestamp_ms, + group_photo_guid: None, + is_filtered: 0, + }); + } + } + + Ok(WrappedCloudSyncChatsPage { + continuation_token: encode_continuation_token(next_token), + status, + done: status == 3, + chats: normalized, + }) + } + + /// Dump ALL CloudKit chat records as raw JSON (paginating until done). + /// Returns a JSON array of objects with record_name + all CloudChat fields. + pub async fn cloud_dump_chats_json(&self) -> Result<String, WrappedError> { + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + + let mut all_records: Vec<serde_json::Value> = Vec::new(); + let mut token: Option<Vec<u8>> = None; + + for page in 0..256 { + let (next_token, chats, status) = cloud_messages.sync_chats(token).await + .map_err(|e| WrappedError::GenericError { + msg: format!("CloudKit chat dump page {} failed: {}", page, e), + })?; + + for (record_name, chat_opt) in &chats { + let mut obj = if let Some(chat) = chat_opt { + serde_json::to_value(chat).unwrap_or(serde_json::Value::Null) + } else { + serde_json::json!({"deleted": true}) + }; + if let Some(map) = obj.as_object_mut() { + map.insert("_record_name".to_string(), serde_json::Value::String(record_name.clone())); + } + all_records.push(obj); + } + + info!("CloudKit chat dump page {}: {} records, status={}", page, chats.len(), status); + + if status == 3 { + break; + } + token = Some(next_token); + } + + serde_json::to_string_pretty(&all_records).map_err(|e| WrappedError::GenericError { + msg: format!("JSON serialization failed: {}", e), + }) + } + + pub async fn cloud_sync_messages( + &self, + continuation_token: Option<String>, + ) -> Result<WrappedCloudSyncMessagesPage, WrappedError> { + let token = decode_continuation_token(continuation_token)?; + info!( + "cloud_sync_messages: token={}, token_bytes={}", + if token.is_some() { "present" } else { "nil" }, + token.as_ref().map_or(0, |t| t.len()) + ); + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + + const MAX_SYNC_ATTEMPTS: usize = 4; + let mut sync_result = None; + let mut last_pcs_err: Option<rustpush::PushError> = None; + + for attempt in 0..MAX_SYNC_ATTEMPTS { + let cm_spawn = Arc::clone(&cloud_messages); + let tok_spawn = token.clone(); + let spawn_result = tokio::task::spawn(async move { + cm_spawn.sync_messages(tok_spawn).await + }).await; + + match spawn_result { + Ok(Ok(result)) => { + info!( + "cloud_sync_messages: sync_messages returned {} records, status={}", + result.1.len(), result.2 + ); + sync_result = Some(result); + break; + } + Ok(Err(err)) if is_pcs_recoverable_error(&err) => { + let attempt_no = attempt + 1; + warn!( + "CloudKit messages sync hit PCS key error on attempt {}/{}: {}", + attempt_no, + MAX_SYNC_ATTEMPTS, + err + ); + last_pcs_err = Some(err); + if attempt_no < MAX_SYNC_ATTEMPTS { + self.recover_cloud_pcs_state("CloudKit messages sync").await?; + continue; + } + } + Ok(Err(err)) => { + return Err(WrappedError::GenericError { + msg: format!("Failed to sync CloudKit messages: {}", err), + }); + } + Err(join_err) => { + warn!( + "cloud_sync_messages: sync_messages panicked on attempt {}/{} \ + (malformed CloudKit record); trying per-record fallback. panic={}", + attempt + 1, MAX_SYNC_ATTEMPTS, join_err + ); + match sync_messages_fallback(&cloud_messages, token.clone()).await { + Ok(result) => { + sync_result = Some(result); + break; + } + Err(e) => { + warn!("cloud_sync_messages: fallback also failed: {}; returning empty done page", e); + return Ok(WrappedCloudSyncMessagesPage { + continuation_token: None, + status: 0, + done: true, + messages: vec![], + }); + } + } + } + } + } + + let (next_token, messages, status) = match sync_result { + Some(result) => result, + None => { + let err = last_pcs_err.map(|e| e.to_string()).unwrap_or_else(|| "unknown error".into()); + return Err(WrappedError::GenericError { + msg: format!("Failed to sync CloudKit messages after PCS recovery retries: {}", err), + }); + } + }; + + let mut normalized = Vec::with_capacity(messages.len()); + let mut skipped_messages = 0usize; + for (record_name, msg_opt) in messages { + if let Some(msg) = msg_opt { + // Skip system/service messages (group renames, participant changes, + // location sharing, etc.). IS_SYSTEM_MESSAGE covers explicit system + // notifications; IS_SERVICE_MESSAGE covers service-generated inline + // notifications (e.g. "You named the conversation"). Neither type + // is user-authored content. + if msg.flags.intersects( + rustpush::cloud_messages::MessageFlags::IS_SYSTEM_MESSAGE + | rustpush::cloud_messages::MessageFlags::IS_SERVICE_MESSAGE + ) { + skipped_messages += 1; + continue; + } + // Wrap per-message normalization in catch_unwind so one bad + // CloudKit record doesn't fail the entire page. + let rn = record_name.clone(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let guid = if msg.guid.is_empty() { + rn.clone() + } else { + msg.guid.clone() + }; + let text = msg.msg_proto.text.clone(); + let subject = msg.msg_proto.subject.clone(); + + // Extract tapback/reaction info from proto fields + let tapback_type = msg.msg_proto.associated_message_type; + let tapback_target_guid = msg.msg_proto.associated_message_guid.clone(); + let tapback_emoji = msg.msg_proto_4.as_ref() + .and_then(|p4| p4.associated_message_emoji.clone()); + + // Extract attachment GUIDs from attributedBody + let mut attachment_guids: Vec<String> = msg.msg_proto.attributed_body + .as_ref() + .map(|body| extract_attachment_guids_from_attributed_body(body)) + .unwrap_or_default() + .into_iter() + .filter(|g| !g.is_empty() && g.len() <= 256 && g.is_ascii()) + .collect(); + + // Also extract from messageSummaryInfo to capture companion + // transfers (e.g. Live Photo MOV) not in attributedBody. + if let Some(ref summary) = msg.msg_proto.message_summary_info { + for sg in extract_attachment_guids_from_summary_info(summary) { + if !sg.is_empty() && sg.len() <= 256 && sg.is_ascii() + && !attachment_guids.contains(&sg) + { + attachment_guids.push(sg); + } + } + } + + let date_read_ms = msg.msg_proto.date_read + .map(|dr| apple_timestamp_ns_to_unix_ms(dr as i64)) + .unwrap_or(0); + + let has_body = msg.msg_proto.attributed_body.is_some(); + + WrappedCloudSyncMessage { + record_name: rn, + guid, + cloud_chat_id: msg.chat_id.clone(), + sender: msg.sender.clone(), + is_from_me: msg + .flags + .contains(rustpush::cloud_messages::MessageFlags::IS_FROM_ME), + text, + subject, + service: msg.service.clone(), + timestamp_ms: apple_timestamp_ns_to_unix_ms(msg.time), + deleted: false, + tapback_type, + tapback_target_guid, + tapback_emoji, + attachment_guids, + date_read_ms, + msg_type: msg.r#type, + has_body, + } + })); + + match result { + Ok(wrapped) => normalized.push(wrapped), + Err(panic_val) => { + let panic_msg = if let Some(s) = panic_val.downcast_ref::<String>() { + s.clone() + } else if let Some(s) = panic_val.downcast_ref::<&str>() { + s.to_string() + } else { + "unknown panic".to_string() + }; + warn!( + "Skipping CloudKit message {} due to normalization panic: {}", + record_name, panic_msg + ); + skipped_messages += 1; + } + } + } else { + normalized.push(WrappedCloudSyncMessage { + guid: record_name.clone(), + record_name, + cloud_chat_id: String::new(), + sender: String::new(), + is_from_me: false, + text: None, + subject: None, + service: String::new(), + timestamp_ms: 0, + deleted: true, + tapback_type: None, + tapback_target_guid: None, + tapback_emoji: None, + attachment_guids: vec![], + date_read_ms: 0, + msg_type: 0, + has_body: false, + }); + } + } + + if skipped_messages > 0 { + warn!( + "CloudKit message sync: skipped {} message(s) due to normalization errors", + skipped_messages + ); + } + + info!( + "CloudKit message sync page: {} messages normalized, {} skipped", + normalized.len(), skipped_messages + ); + + Ok(WrappedCloudSyncMessagesPage { + continuation_token: encode_continuation_token(next_token), + status, + done: status == 3, + messages: normalized, + }) + } + + /// Sync CloudKit attachment zone and return metadata for all attachments. + /// Returns a page of attachment metadata (record_name → attachment info). + /// Paginate with continuation_token until done == true. + /// + /// Parses records as our local `CloudAttachmentWithAvid` instead of + /// upstream's `CloudAttachment` so we get the `avid` field (Live Photo + /// MOV companion) without needing a rustpush patch. Same on-the-wire + /// record type ("attachment"), same PCS-encrypted record fields — + /// `cm`, `lqa`, and `avid` are decoded by the CloudKit field-name + /// matcher on our struct the same way they'd be on upstream's + an + /// extra field. Ford key registration uses both `lqa.protection_info` + /// AND `avid.protection_info`. + pub async fn cloud_sync_attachments( + &self, + continuation_token: Option<String>, + ) -> Result<WrappedCloudSyncAttachmentsPage, WrappedError> { + use rustpush::cloudkit::{pcs_keys_for_record, FetchRecordChangesOperation, CloudKitSession, NO_ASSETS}; + use rustpush::cloud_messages::MESSAGES_SERVICE; + use rustpush::pcs::{PCSShareProtection, PCSEncryptor}; + use rustpush::PushError; + + let token = decode_continuation_token(continuation_token)?; + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + + // Hand-rolled sync with NO_ASSETS — matches master's + // sync_attachments → sync_records → FetchRecordChangesOperation(..., &NO_ASSETS) path. + // + // DO NOT use ALL_ASSETS here. ALL_ASSETS asks the server to include + // download authorization for every asset field in every returned record. + // The server silently omits records whose MMCS assets are unavailable + // (expired, pending GC, or otherwise un-authorizable). Those records ARE + // present in the zone and returned by NO_ASSETS — they just can't have + // their asset download URLs generated. + // + // NOTE: Investigation confirmed that NO_ASSETS vs ALL_ASSETS does NOT + // explain the 4 missing records — master (also NO_ASSETS) misses the + // same 4. The root cause is still under investigation. See the + // QueryRecords diagnostic at the end of the final page. + // + // Ford keys come from lqa.protection_info (PCS-encrypted record field, + // NOT the asset download content), so NO_ASSETS vs ALL_ASSETS makes no + // difference to Ford key population. + // + // We parse records as CloudAttachmentWithAvid (our local type + // that declares the avid field) so sync-time has_avid detection + // AND avid Ford key caching work in one pass. + // SLEDGEHAMMER: the whole container+zone_key+perform call chain runs + // inside a spawned task. Upstream omnisette has an unconditional + // `panic!()` on any non-`AnisetteNotProvisioned` error from + // `get_headers` (remote_anisette_v3.rs:417), and MMCS/PCS paths + // below it have their own scattered `.expect()` calls. Any of + // these can fire mid-sync and abort the entire page, silently + // losing its records because the continuation token hasn't + // advanced yet. + // + // Retry up to 4 times with the SAME continuation token. Between + // attempts, on panic (JoinError), fully reset the cloud client + // (cloud_messages_client + cloud_keychain_client → None) so the + // next get_or_init builds a fresh omnisette provider with fresh + // state. On final failure, return an empty done-page so Go's + // loop breaks without advancing the persisted token — the + // next sync cycle will retry the same page from DB state. + const ATT_SYNC_RETRIES: usize = 4; + let mut cm_handle = cloud_messages; + // All of these are populated on a successful perform() and consumed + // after the retry loop. Types are inferred from the first Some(...) + // assignment so we don't have to hard-code upstream's crate paths. + let mut perform_result = None; + let mut last_perform_err: Option<String> = None; + let mut container_holder = None; + let mut zone_id_holder = None; + let mut zone_key_holder = None; + for attempt in 0..ATT_SYNC_RETRIES { + // Re-fetch container + zone_key INSIDE the retry so a reset + // cloud_messages_client picks up a fresh container, fresh keychain, + // fresh omnisette state on retry. + let container = match cm_handle.get_container().await { + Ok(c) => c, + Err(e) => { + let attempt_no = attempt + 1; + warn!( + "cloud_sync_attachments: get_container() failed on attempt {}/{}: {}", + attempt_no, ATT_SYNC_RETRIES, e + ); + last_perform_err = Some(format!("get_container: {}", e)); + if attempt_no < ATT_SYNC_RETRIES { + self.reset_cloud_client().await; + cm_handle = self.get_or_init_cloud_messages_client().await?; + continue; + } + break; + } + }; + let zone_id_local = container.private_zone("attachmentManateeZone".to_string()); + let zone_key_local = match container + .get_zone_encryption_config(&zone_id_local, &cm_handle.keychain, &MESSAGES_SERVICE) + .await + { + Ok(k) => k, + Err(e) => { + let attempt_no = attempt + 1; + warn!( + "cloud_sync_attachments: zone_key fetch failed on attempt {}/{}: {}", + attempt_no, ATT_SYNC_RETRIES, e + ); + last_perform_err = Some(format!("zone_key: {}", e)); + if attempt_no < ATT_SYNC_RETRIES { + self.reset_cloud_client().await; + cm_handle = self.get_or_init_cloud_messages_client().await?; + continue; + } + break; + } + }; + + let c = Arc::clone(&container); + let z = zone_id_local.clone(); + let tok = token.clone(); + let join = tokio::task::spawn(async move { + // Construct request directly (not via ::new helper) so we can set + // newest_first: Some(true), matching master's sync_records path. + // The ::new helper hard-codes newest_first: Some(false), which + // causes the CloudKit server to return a different (incomplete) + // record set — empirically 4 records are omitted when false. + c.perform( + &CloudKitSession::new(), + FetchRecordChangesOperation(rustpush::cloudkit_proto::RetrieveChangesRequest { + sync_continuation_token: tok, + zone_identifier: Some(z), + requested_changes_types: Some(3), + assets_to_download: Some(NO_ASSETS.clone()), + newest_first: Some(true), + ..Default::default() + }), + ) + .await + }) + .await; + match join { + Ok(Ok(ok)) => { + perform_result = Some(ok); + container_holder = Some(container); + zone_id_holder = Some(zone_id_local); + zone_key_holder = Some(zone_key_local); + break; + } + Ok(Err(e)) => { + let attempt_no = attempt + 1; + warn!( + "cloud_sync_attachments: perform() returned error on attempt {}/{}: {}", + attempt_no, ATT_SYNC_RETRIES, e + ); + last_perform_err = Some(format!("{}", e)); + if attempt_no < ATT_SYNC_RETRIES { + self.reset_cloud_client().await; + cm_handle = self.get_or_init_cloud_messages_client().await?; + continue; + } + } + Err(join_err) => { + let attempt_no = attempt + 1; + warn!( + "cloud_sync_attachments: perform() panicked on attempt {}/{} \ + (likely upstream omnisette/anisette panic); resetting cloud client and retrying with same token. panic={}", + attempt_no, ATT_SYNC_RETRIES, join_err + ); + last_perform_err = Some(format!("panic: {}", join_err)); + if attempt_no < ATT_SYNC_RETRIES { + self.reset_cloud_client().await; + cm_handle = self.get_or_init_cloud_messages_client().await?; + continue; + } + } + } + } + let (_assets, response) = match perform_result { + Some(r) => r, + None => { + let err_msg = last_perform_err.unwrap_or_else(|| "unknown".into()); + warn!( + "cloud_sync_attachments: perform() failed after {} attempts ({}); returning empty done page to let caller skip", + ATT_SYNC_RETRIES, err_msg + ); + return Ok(WrappedCloudSyncAttachmentsPage { + continuation_token: None, + status: 0, + done: true, + attachments: vec![], + }); + } + }; + let container = container_holder.expect("container set on success"); + let zone_id = zone_id_holder.expect("zone_id set on success"); + let mut zone_key = zone_key_holder.expect("zone_key set on success"); + let cloud_messages = cm_handle; + + let status = response.status(); + let next_token = response.sync_continuation_token().to_vec(); + + let mut normalized = Vec::with_capacity(response.change.len()); + let mut ford_cached = 0usize; + let mut refreshed_zone_key_config = false; + let mut pcs_skipped = 0usize; + // Silent drop path counters. These three `continue`s used to produce + // no logs at all, making it impossible to tell from the outside + // whether a missing attachment was (a) never in the change feed, (b) + // a tombstone (deletion), or (c) a different record type. Always + // summarized at the end of the sync page. + let mut no_identifier = 0usize; + let mut no_record_tombstone = 0usize; + let mut wrong_type = 0usize; + // `cm_decode_fail` = records whose `cm` field (GZipWrapper<AttachmentMeta>) + // panicked or returned None on decode. A failed `cm` means we have no + // aguid / no attachment metadata, so the record must be skipped — but + // we now log WHY and increment a counter, instead of silently dropping + // inside a whole-record `catch_unwind`. + // + // `lqa_decode_fail` = records whose `lqa` field (Asset, the primary + // MMCS blob) panicked on decode. Unlike `cm`, a failed `lqa` does NOT + // drop the record — we still emit the record with a missing Ford key, + // so Go sees the aguid in attachments_json and the download path can + // attempt Ford recovery. This is the fix for the 4-record regression: + // upstream's `Asset::from_value_encrypted` unwraps `protection_info` + // twice (cloudkit-proto/src/lib.rs:273), which panics on any record + // whose lqa has `protection_info=None` or `protection_info.protection_info=None`. + // The previous catch_unwind(from_record_encrypted) caught that panic + // but nuked the whole record, losing `cm` along with `lqa`. + let mut cm_decode_fail = 0usize; + let mut lqa_decode_fail = 0usize; + for change in &response.change { + let identifier = match change.identifier.as_ref().and_then(|i| i.value.as_ref()) { + Some(v) => v.name().to_string(), + None => { + no_identifier += 1; + continue; + } + }; + let record = match &change.record { + Some(r) => r, + None => { + // CloudKit change feed returns identifier without a record + // body when the record has been deleted. The deletion + // applies to the zone regardless of record type, so we log + // the record_name to help correlate with any missing + // attachments in the bridge. + no_record_tombstone += 1; + info!( + "cloud_sync_attachments: tombstone (deleted record) {}", + identifier + ); + continue; + } + }; + if record.r#type.as_ref().and_then(|t| t.name.as_deref()) + != Some(<rustpush::cloud_messages::CloudAttachment as rustpush::cloudkit_proto::CloudKitRecord>::record_type()) + { + wrong_type += 1; + continue; + } + + // Master's PCS-record-key-missing refresh retry pattern: + // when `pcs_keys_for_record` returns PCSRecordKeyMissing, the + // zone encryption config is likely stale (a new key was + // rotated after our cache was seeded). Clear the zone config + // cache, re-fetch, and retry once. For other PCS-related + // errors (ShareKeyNotFound, DecryptionKeyNotFound, + // MasterKeyNotFound), gracefully skip the record with a warn. + // Without this, my sync was silently skipping records whose + // Ford keys master would have recovered — producing gaps in + // the cache that showed up as "all cached keys exhausted" + // failures during Ford dedup recovery. + // Upstream's decode_record_protection uses .unwrap()/.expect() and + // can panic when zone-key lookup fails. Wrap in catch_unwind so a + // single bad record skips gracefully instead of aborting the whole + // page and freezing the continuation token forever. + let pcs_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + pcs_keys_for_record(record, &zone_key) + })); + let pcskey_opt = match pcs_result { + Err(_panic) => { + // pcs_keys_for_record panicked — upstream's decode_record_protection + // uses byte comparison (key.compress() vs stored pub_key) which + // fails when the zone was key-rotated and the old key is missing + // from zone_keys but still in the keychain. + // + // Fall back to decrypt_with_keychain, which does a format-agnostic + // keychain lookup (base64 of stored pub_key bytes) and succeeds + // where the byte comparison fails. This is the wrapper-layer + // re-implementation of the aecc7ed fix from the vendored tree. + if let Some(protection) = &record.protection_info { + let record_protection = PCSShareProtection::from_protection_info(protection); + let keychain_state = cloud_messages.keychain.state.read().await; + let fallback = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + record_protection.decrypt_with_keychain(&keychain_state, &MESSAGES_SERVICE, false) + })); + match fallback { + Ok(Ok((pcs_keys, _))) => { + let record_id = record.record_identifier.clone().expect("no record id"); + info!( + "cloud_sync_attachments: fallback decrypt_with_keychain succeeded for {}", + identifier + ); + Some(PCSEncryptor { keys: pcs_keys, record_id }) + } + Ok(Err(e)) => { + pcs_skipped += 1; + warn!( + "cloud_sync_attachments: pcs panic + fallback failed for {}: {}", + identifier, e + ); + None + } + Err(_) => { + pcs_skipped += 1; + warn!( + "cloud_sync_attachments: pcs panic + fallback panicked for {}", + identifier + ); + None + } + } + } else { + pcs_skipped += 1; + warn!( + "cloud_sync_attachments: pcs_keys_for_record panicked for {} (no protection_info for fallback)", + identifier + ); + None + } + } + Ok(Ok(k)) => Some(k), + Ok(Err(PushError::PCSRecordKeyMissing)) if !refreshed_zone_key_config => { + info!("cloud_sync_attachments: PCSRecordKeyMissing for {}, refreshing zone config", identifier); + container.clear_cache_zone_encryption_config(&zone_id).await; + refreshed_zone_key_config = true; + match container + .get_zone_encryption_config(&zone_id, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await + { + Ok(new_key) => { + zone_key = new_key; + let retry_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + pcs_keys_for_record(record, &zone_key) + })); + match retry_result { + Err(_panic) => { + pcs_skipped += 1; + warn!( + "cloud_sync_attachments: pcs_keys_for_record panicked on retry for {}, skipping", + identifier + ); + None + } + Ok(Ok(k)) => Some(k), + Ok(Err(e)) => { + pcs_skipped += 1; + warn!( + "cloud_sync_attachments: PCS key still missing after refresh for {}: {}", + identifier, e + ); + None + } + } + } + Err(e) => { + warn!("cloud_sync_attachments: zone config refresh failed: {}", e); + None + } + } + } + Ok(Err(err)) + if matches!( + err, + PushError::PCSRecordKeyMissing + | PushError::ShareKeyNotFound(_) + | PushError::DecryptionKeyNotFound(_) + | PushError::MasterKeyNotFound + ) => + { + pcs_skipped += 1; + warn!( + "cloud_sync_attachments: skipping {} due to PCS key error: {}", + identifier, err + ); + None + } + Ok(Err(e)) => { + pcs_skipped += 1; + warn!("cloud_sync_attachments: unexpected PCS error for {}: {}", identifier, e); + None + } + }; + let pcskey = match pcskey_opt { + Some(k) => k, + None => continue, + }; + + // Per-field manual decode. + // + // Previously this call used upstream's derive-generated + // `<CloudAttachment as CloudKitRecord>::from_record_encrypted` + // inside a whole-record `catch_unwind`. That path has two + // load-bearing panic sites in upstream's code that silently + // nuked entire records: + // + // 1. `Asset::from_value_encrypted` + // (cloudkit-proto/src/lib.rs:273) double-unwraps + // `asset.protection_info` and `.protection_info`. Any + // record whose `lqa` Asset is missing either nested Option + // panics immediately. + // 2. `GZipWrapper::from_bytes` + // (cloud_messages.rs:211) calls `.expect("ungzip fialed")`. + // Any record whose `cm` field decrypts to non-gzip bytes + // panics immediately. + // + // The old catch_unwind caught the panic but then `continue`d, + // dropping the entire record — so the aguid never reached Go, + // `attMap` never saw it, and the bridge ingest code logged + // "Attachment GUID not found in attachment zone". That's the + // observed regression for the 4 stubborn aguids. + // + // Fix: decode each field independently with its own + // catch_unwind. `cm` is required (no aguid → skip record); + // `lqa` and `avid` are best-effort (missing Asset just means + // we emit the record without a Ford key). + let find_field = |name: &str| -> Option<&rustpush::cloudkit_proto::record::field::Value> { + record + .record_field + .iter() + .find(|f| { + f.identifier + .as_ref() + .and_then(|i| i.name.as_deref()) + == Some(name) + }) + .and_then(|f| f.value.as_ref()) + }; + + let field_names: Vec<String> = record + .record_field + .iter() + .filter_map(|f| f.identifier.as_ref().and_then(|i| i.name.clone())) + .collect(); + + // Decode `cm` (GZipWrapper<AttachmentMeta>). + // We pull the GZipWrapper out, then move the inner AttachmentMeta. + let cm_opt: Option<rustpush::cloud_messages::AttachmentMeta> = match find_field("cm") { + None => None, + Some(v) => match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + <rustpush::cloud_messages::GZipWrapper<rustpush::cloud_messages::AttachmentMeta> + as rustpush::cloudkit_proto::CloudKitEncryptedValue>::from_value_encrypted( + v, &pcskey, "cm", + ) + })) { + Ok(Some(gzw)) => Some(gzw.0), + Ok(None) => None, + Err(_panic) => { + // from_bytes panic (ungzip or plist parse). Log and + // fall through — counted as cm_decode_fail below. + warn!( + "cloud_sync_attachments: {} — cm decode panicked (likely ungzip/plist). fields={:?}", + identifier, field_names + ); + None + } + }, + }; + let cm = match cm_opt { + Some(cm) => cm, + None => { + cm_decode_fail += 1; + warn!( + "cloud_sync_attachments: skipping {} — cm field missing or undecodable. fields={:?}", + identifier, field_names + ); + continue; + } + }; + + // Decode `lqa` (Asset). Best-effort: on failure we still emit + // the record so Go sees the aguid in attMap. The Ford download + // path will attempt recovery later if needed. + // + // `lqa_outcome` distinguishes three states: Ok(asset present), + // Ok(None) = field absent (fine, not counted), Err = present + // but decode panicked or returned None (counted + logged). + let lqa_field = find_field("lqa"); + let lqa_opt: Option<rustpush::cloudkit_proto::Asset> = match lqa_field { + None => None, + Some(v) => { + let decoded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + <rustpush::cloudkit_proto::Asset as rustpush::cloudkit_proto::CloudKitEncryptedValue>::from_value_encrypted(v, &pcskey, "lqa") + })); + match decoded { + Ok(Some(asset)) => Some(asset), + Ok(None) => { + lqa_decode_fail += 1; + warn!( + "cloud_sync_attachments: {} aguid={} — lqa present but from_value_encrypted returned None; emitting record without Ford key", + identifier, cm.guid + ); + None + } + Err(_panic) => { + lqa_decode_fail += 1; + warn!( + "cloud_sync_attachments: {} aguid={} — lqa decode panicked (likely None protection_info); emitting record without Ford key. fields={:?}", + identifier, cm.guid, field_names + ); + None + } + } + } + }; + + // Decode `avid` (Live Photo MOV companion, Asset). Best-effort. + // Same panic risk as lqa. + let avid_asset: Option<rustpush::cloudkit_proto::Asset> = match find_field("avid") { + None => None, + Some(v) => match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + <rustpush::cloudkit_proto::Asset as rustpush::cloudkit_proto::CloudKitEncryptedValue>::from_value_encrypted(v, &pcskey, "avid") + })) { + Ok(parsed) => parsed, + Err(_panic) => { + warn!( + "cloud_sync_attachments: {} aguid={} — avid decode panicked; treating as no Live Photo", + identifier, cm.guid + ); + None + } + }, + }; + + let has_avid = avid_asset + .as_ref() + .and_then(|a| a.size) + .unwrap_or(0) + > 0; + let avid_ford_key = avid_asset + .as_ref() + .and_then(|a| a.protection_info.as_ref()) + .and_then(|p| p.protection_info.as_ref()) + .cloned(); + + let ford_key = lqa_opt + .as_ref() + .and_then(|lqa| lqa.protection_info.as_ref()) + .and_then(|p| p.protection_info.as_ref()) + .cloned(); + + // Register BOTH lqa and avid Ford keys into the wrapper cache + // immediately. Matches master's sync_attachments cache-population + // loop. Also propagated to Go's cache via the Go-side register. + if let Some(ref k) = ford_key { + register_ford_key(k.clone()); + ford_cached += 1; + } + if let Some(ref k) = avid_ford_key { + register_ford_key(k.clone()); + ford_cached += 1; + } + + // Per-record log at INFO so the exact aguids reaching Go can be + // grep'd. `grep cloud_sync_attachments: att` against the journal + // will list every aguid → record_name pair the bridge normalized. + // Missing a specific aguid from this list = CloudKit change feed + // isn't returning it, and the fix is not in the sync loop. + info!( + "cloud_sync_attachments: att guid={} record={} mime={:?} size={} lqa_ok={} avid_ok={}", + cm.guid, + identifier, + cm.mime_type, + cm.total_bytes, + lqa_opt.is_some(), + avid_asset.is_some(), + ); + normalized.push(WrappedCloudAttachmentInfo { + guid: cm.guid.clone(), + mime_type: cm.mime_type.clone(), + uti_type: cm.uti.clone(), + filename: cm.transfer_name.clone().or_else(|| cm.filename.clone()), + file_size: cm.total_bytes, + record_name: identifier, + hide_attachment: cm.hide_attachment, + has_avid, + ford_key, + avid_ford_key, + }); + } + + info!( + "cloud_sync_attachments: {} normalized, {} ford_cached, {} pcs_skipped, {} no_id, {} tombstones, {} wrong_type, {} cm_decode_fail, {} lqa_decode_fail, {} total_change_entries", + normalized.len(), + ford_cached, + pcs_skipped, + no_identifier, + no_record_tombstone, + wrong_type, + cm_decode_fail, + lqa_decode_fail, + response.change.len() + ); + + Ok(WrappedCloudSyncAttachmentsPage { + continuation_token: encode_continuation_token(next_token), + status, + done: status == 3, + attachments: normalized, + }) + } + + /// QueryRecords fallback for attachmentManateeZone. + /// + /// FetchRecordChanges misses some live records — confirmed on both master and + /// refactor, same count regardless of newest_first/NO_ASSETS. QueryRecords + /// queries current zone state without relying on the change-feed token and + /// returns records the feed omits. + /// + /// Call once after the full cloud_sync_attachments loop completes, passing + /// the record_names of all attachments already collected from the change feed. + /// Returns only records NOT in known_record_names, processed with the same + /// PCS + cm/lqa/avid decode logic as cloud_sync_attachments. + pub async fn cloud_query_attachments_fallback( + &self, + known_record_names: Vec<String>, + ) -> Result<WrappedCloudSyncAttachmentsPage, WrappedError> { + use rustpush::cloudkit::{pcs_keys_for_record, CloudKitOp, CloudKitSession, NO_ASSETS}; + use rustpush::cloud_messages::MESSAGES_SERVICE; + use rustpush::pcs::{PCSShareProtection, PCSEncryptor}; + use rustpush::PushError; + use rustpush::cloudkit_proto; + + struct RawQueryOp(cloudkit_proto::QueryRetrieveRequest); + impl CloudKitOp for RawQueryOp { + type Response = cloudkit_proto::QueryRetrieveResponse; + fn set_request(&self, output: &mut cloudkit_proto::RequestOperation) { + output.query_retrieve_request = Some(self.0.clone()); + } + fn retrieve_response(response: &cloudkit_proto::ResponseOperation) -> Self::Response { + response.query_retrieve_response.clone().unwrap_or_default() + } + fn flow_control_key() -> &'static str { "CKDQueryOperation" } + fn link() -> &'static str { "https://gateway.icloud.com/ckdatabase/api/client/query/retrieve" } + fn operation() -> cloudkit_proto::operation::Type { + cloudkit_proto::operation::Type::QueryRetrieveType + } + fn tags() -> bool { false } + } + + let seen_ids: std::collections::HashSet<String> = known_record_names.into_iter().collect(); + + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + let container = match cloud_messages.get_container().await { + Ok(c) => c, + Err(e) => return Err(WrappedError::GenericError { msg: format!("cloud_query_attachments_fallback: get_container failed: {}", e) }), + }; + let zone_id = container.private_zone("attachmentManateeZone".to_string()); + let mut zone_key = match container + .get_zone_encryption_config(&zone_id, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await + { + Ok(k) => k, + Err(e) => return Err(WrappedError::GenericError { msg: format!("cloud_query_attachments_fallback: zone_key failed: {}", e) }), + }; + + let mut normalized: Vec<WrappedCloudAttachmentInfo> = Vec::new(); + let mut pcs_skipped = 0usize; + let mut cm_decode_fail = 0usize; + let mut lqa_decode_fail = 0usize; + let mut ford_cached = 0usize; + let mut refreshed_zone_key_config = false; + let mut qr_continuation: Option<Vec<u8>> = None; + let mut qr_page = 0usize; + + loop { + qr_page += 1; + let c = Arc::clone(&container); + let z = zone_id.clone(); + let qr_cont = qr_continuation.clone(); + let join = tokio::task::spawn(async move { + c.perform(&CloudKitSession::new(), RawQueryOp(cloudkit_proto::QueryRetrieveRequest { + query: Some(cloudkit_proto::Query { + types: vec![cloudkit_proto::record::Type { name: Some("attachment".to_string()) }], + filters: vec![], + sorts: vec![], + ..Default::default() + }), + zone_identifier: Some(z), + assets_to_download: Some(NO_ASSETS.clone()), + continuation_marker: qr_cont, + ..Default::default() + })).await + }).await; + + let qr = match join { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + warn!("cloud_query_attachments_fallback: page {} failed: {}", qr_page, e); + break; + } + Err(e) => { + warn!("cloud_query_attachments_fallback: page {} panicked: {}", qr_page, e); + break; + } + }; + let next_marker = qr.continuation_marker.clone(); + + for qresult in &qr.query_results { + let record = match &qresult.record { Some(r) => r, None => continue }; + let identifier = match record.record_identifier.as_ref() + .and_then(|i| i.value.as_ref()) + .and_then(|v| v.name.as_deref()) + { + Some(n) => n.to_string(), + None => continue, + }; + if seen_ids.contains(&identifier) { + continue; + } + info!("cloud_query_attachments_fallback: new record not in change feed: {}", identifier); + + let pcs_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + pcs_keys_for_record(record, &zone_key) + })); + let pcskey_opt = match pcs_result { + Err(_panic) => { + if let Some(protection) = &record.protection_info { + let record_protection = PCSShareProtection::from_protection_info(protection); + let keychain_state = cloud_messages.keychain.state.read().await; + let fallback = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + record_protection.decrypt_with_keychain(&keychain_state, &MESSAGES_SERVICE, false) + })); + match fallback { + Ok(Ok((pcs_keys, _))) => { + let record_id = record.record_identifier.clone().expect("no record id"); + Some(PCSEncryptor { keys: pcs_keys, record_id }) + } + Ok(Err(e)) => { pcs_skipped += 1; warn!("cloud_query_attachments_fallback: pcs panic + fallback failed for {}: {}", identifier, e); None } + Err(_) => { pcs_skipped += 1; warn!("cloud_query_attachments_fallback: pcs panic + fallback panicked for {}", identifier); None } + } + } else { + pcs_skipped += 1; + warn!("cloud_query_attachments_fallback: pcs panicked for {} (no protection_info)", identifier); + None + } + } + Ok(Ok(k)) => Some(k), + Ok(Err(PushError::PCSRecordKeyMissing)) if !refreshed_zone_key_config => { + container.clear_cache_zone_encryption_config(&zone_id).await; + refreshed_zone_key_config = true; + match container.get_zone_encryption_config(&zone_id, &cloud_messages.keychain, &MESSAGES_SERVICE).await { + Ok(new_key) => { + zone_key = new_key; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| pcs_keys_for_record(record, &zone_key))) { + Err(_) => { pcs_skipped += 1; None } + Ok(Ok(k)) => Some(k), + Ok(Err(e)) => { pcs_skipped += 1; warn!("cloud_query_attachments_fallback: PCS still missing after refresh for {}: {}", identifier, e); None } + } + } + Err(e) => { warn!("cloud_query_attachments_fallback: zone config refresh failed: {}", e); None } + } + } + Ok(Err(e)) => { pcs_skipped += 1; warn!("cloud_query_attachments_fallback: PCS error for {}: {}", identifier, e); None } + }; + let pcskey = match pcskey_opt { Some(k) => k, None => continue }; + + let find_field = |name: &str| -> Option<&rustpush::cloudkit_proto::record::field::Value> { + record.record_field.iter() + .find(|f| f.identifier.as_ref().and_then(|i| i.name.as_deref()) == Some(name)) + .and_then(|f| f.value.as_ref()) + }; + let field_names: Vec<String> = record.record_field.iter() + .filter_map(|f| f.identifier.as_ref().and_then(|i| i.name.clone())) + .collect(); + + let cm_opt: Option<rustpush::cloud_messages::AttachmentMeta> = match find_field("cm") { + None => None, + Some(v) => match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + <rustpush::cloud_messages::GZipWrapper<rustpush::cloud_messages::AttachmentMeta> + as rustpush::cloudkit_proto::CloudKitEncryptedValue>::from_value_encrypted(v, &pcskey, "cm") + })) { + Ok(Some(gzw)) => Some(gzw.0), + Ok(None) => None, + Err(_panic) => { warn!("cloud_query_attachments_fallback: {} cm decode panicked. fields={:?}", identifier, field_names); None } + }, + }; + let cm = match cm_opt { + Some(cm) => cm, + None => { cm_decode_fail += 1; warn!("cloud_query_attachments_fallback: skipping {} — cm missing/undecodable. fields={:?}", identifier, field_names); continue; } + }; + + let lqa_opt: Option<rustpush::cloudkit_proto::Asset> = match find_field("lqa") { + None => None, + Some(v) => match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + <rustpush::cloudkit_proto::Asset as rustpush::cloudkit_proto::CloudKitEncryptedValue>::from_value_encrypted(v, &pcskey, "lqa") + })) { + Ok(Some(asset)) => Some(asset), + Ok(None) => { lqa_decode_fail += 1; None } + Err(_panic) => { lqa_decode_fail += 1; warn!("cloud_query_attachments_fallback: {} lqa decode panicked", identifier); None } + }, + }; + + let avid_asset: Option<rustpush::cloudkit_proto::Asset> = match find_field("avid") { + None => None, + Some(v) => match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + <rustpush::cloudkit_proto::Asset as rustpush::cloudkit_proto::CloudKitEncryptedValue>::from_value_encrypted(v, &pcskey, "avid") + })) { + Ok(parsed) => parsed, + Err(_panic) => { warn!("cloud_query_attachments_fallback: {} avid decode panicked", identifier); None } + }, + }; + + let has_avid = avid_asset.as_ref().and_then(|a| a.size).unwrap_or(0) > 0; + let avid_ford_key = avid_asset.as_ref().and_then(|a| a.protection_info.as_ref()).and_then(|p| p.protection_info.as_ref()).cloned(); + let ford_key = lqa_opt.as_ref().and_then(|lqa| lqa.protection_info.as_ref()).and_then(|p| p.protection_info.as_ref()).cloned(); + + if let Some(ref k) = ford_key { register_ford_key(k.clone()); ford_cached += 1; } + if let Some(ref k) = avid_ford_key { register_ford_key(k.clone()); ford_cached += 1; } + + info!( + "cloud_query_attachments_fallback: att guid={} record={} mime={:?} size={} lqa_ok={} avid_ok={}", + cm.guid, identifier, cm.mime_type, cm.total_bytes, lqa_opt.is_some(), avid_asset.is_some(), + ); + normalized.push(WrappedCloudAttachmentInfo { + guid: cm.guid.clone(), + mime_type: cm.mime_type.clone(), + uti_type: cm.uti.clone(), + filename: cm.transfer_name.clone().or_else(|| cm.filename.clone()), + file_size: cm.total_bytes, + record_name: identifier, + hide_attachment: cm.hide_attachment, + has_avid, + ford_key, + avid_ford_key, + }); + } + + match next_marker { + Some(m) if !m.is_empty() => { qr_continuation = Some(m); } + _ => break, + } + } + + info!( + "cloud_query_attachments_fallback: {} new records across {} pages, {} pcs_skipped, {} cm_fail, {} lqa_fail, {} ford_cached", + normalized.len(), qr_page, pcs_skipped, cm_decode_fail, lqa_decode_fail, ford_cached + ); + + Ok(WrappedCloudSyncAttachmentsPage { + continuation_token: None, + status: 3, + done: true, + attachments: normalized, + }) + } + + /// Download an attachment from CloudKit by its record name. + /// Returns the raw file bytes. + /// + /// # Ford dedup recovery + /// + /// First tries upstream's `download_attachment`. If that path panics + /// (which happens when MMCS serves a deduplicated Ford blob encrypted + /// with a different record's key — the `.unwrap()` at the top of + /// `get_mmcs`'s SIV decrypt), the panic is caught here and the wrapper + /// retries manually by iterating cached Ford keys. For each cached key, + /// we fetch the record, mutate `lqa.protection_info.protection_info` to + /// inject the candidate key, and call `container.get_assets(...)` + /// directly. This matches the 94f7b8e fix's semantics without touching + /// upstream rustpush source. + pub async fn cloud_download_attachment( + &self, + record_name: String, + ) -> Result<Vec<u8>, WrappedError> { + use futures::FutureExt; + use rustpush::cloudkit::{FetchRecordOperation, FetchedRecords, ALL_ASSETS}; + use rustpush::cloud_messages::MESSAGES_SERVICE; + + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + + // Hand-rolled mirror of upstream's `download_attachment` that + // replicates master's Ford-registration-before-get_assets pattern: + // + // 1. perform_operations(FetchRecordOperation::many(ALL_ASSETS)) + // 2. parse records as CloudAttachmentWithAvid (lqa + avid) + // 3. register BOTH lqa.protection_info and avid.protection_info + // Ford keys into the wrapper cache BEFORE get_assets runs + // 4. call container.get_assets(&records.assets, &record.lqa) + // + // This ensures that every attachment download contributes its + // record's own Ford keys to the cache even if sync never reached + // this record yet (new attachments, cross-device dedup where the + // source record hasn't been synced). Matches master's behavior + // at rustpush/src/imessage/cloud_messages.rs:download_attachment. + // + // Wrapped in catch_unwind so any SIV panic deep in get_mmcs (Ford + // dedup with a key not yet in the cache) falls through to + // `cloud_download_attachment_ford_recovery` for brute-force retry. + let container = cloud_messages.get_container().await.map_err(|e| WrappedError::GenericError { + msg: format!("cloud_download_attachment {}: get_container: {e}", record_name), + })?; + let zone = container.private_zone("attachmentManateeZone".to_string()); + let zone_key = container + .get_zone_encryption_config(&zone, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("cloud_download_attachment {}: zone key: {e}", record_name), + })?; + + let invoke = container + .perform_operations( + &CloudKitSession::new(), + &FetchRecordOperation::many(&ALL_ASSETS, &zone, &[record_name.clone()]), + IsolationLevel::Operation, + ) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("cloud_download_attachment {}: perform_operations: {e}", record_name), + })?; + let records = FetchedRecords::new(&invoke); + let record: CloudAttachmentWithAvid = records.get_record(&record_name, Some(&zone_key)); + + // Register this record's Ford keys (lqa + avid) into the cache + // BEFORE the get_assets call — matches master's download_attachment + // behavior so the fordChecksum / brute-force fallback always has + // at least the current record's keys available. + if let Some(pi) = record + .lqa + .protection_info + .as_ref() + .and_then(|p| p.protection_info.as_ref()) + { + register_ford_key(pi.clone()); + } + if let Some(pi) = record + .avid + .protection_info + .as_ref() + .and_then(|p| p.protection_info.as_ref()) + { + register_ford_key(pi.clone()); + } + + // Determine upfront whether this asset is Ford-encrypted by parsing + // the AuthorizeGetResponse body and checking if our file's + // wanted_chunks entry has a `ford_reference`. Only Ford-encrypted + // assets (typically videos) benefit from Ford dedup recovery — + // image attachments use V1 per-chunk encryption and DON'T have a + // Ford blob, so running recovery on them would brute-force cached + // keys against bytes that aren't Ford ciphertext (guaranteed to + // fail, pure wasted work). + // + // Master's retry was inside get_mmcs, scoped to the Ford container + // loop, so it naturally only fired on real Ford failures. The + // wrapper has to do this check explicitly. + let is_ford_asset = is_ford_encrypted_asset(&records.assets, &record.lqa); + // Diagnostic log so we can verify from logs that only Ford-encrypted + // records are entering the recovery path. If this ever shows + // is_ford=false for a record that still panics through Ford + // recovery, something's wrong with the gating. + info!( + "cloud_download_attachment {}: is_ford={} mime={:?} filename={:?}", + record_name, + is_ford_asset, + record.cm.0.mime_type.as_deref().unwrap_or("<none>"), + record.cm.0.transfer_name.as_deref().or(record.cm.0.filename.as_deref()).unwrap_or("<none>") + ); + + // Attempt 1 — get_assets with the record's own lqa, wrapped in + // catch_unwind so a panic doesn't take down the bridge. + let shared = SharedWriter::new(); + let assets_tuple: Vec<(&rustpush::cloudkit_proto::Asset, SharedWriter)> = + vec![(&record.lqa, shared.clone())]; + let fut = container.get_assets(&records.assets, assets_tuple); + let wrapped = std::panic::AssertUnwindSafe(fut).catch_unwind().await; + + match wrapped { + Ok(Ok(())) => return Ok(shared.into_bytes()), + Ok(Err(e)) => { + return Err(WrappedError::GenericError { + msg: format!("Failed to download CloudKit attachment {}: {}", record_name, e), + }); + } + Err(_panic) => { + if !is_ford_asset { + // Non-Ford asset (image / V1 per-chunk encryption) — + // the panic is NOT a Ford dedup issue. Brute-forcing + // cached keys can't help: there's no Ford blob, and + // our cached keys are for Ford-encrypted records that + // will never match. Return the panic as a terminal + // error instead of burning retries. + warn!( + "cloud_download_attachment {}: non-Ford asset panic, skipping recovery (image or other V1-encrypted)", + record_name + ); + return Err(WrappedError::GenericError { + msg: format!( + "Failed to download CloudKit attachment {} (non-Ford panic, likely bundled_request_id or PCS issue)", + record_name + ), + }); + } + warn!( + "cloud_download_attachment {}: Ford SIV panic, attempting recovery with {} cached Ford keys", + record_name, + ford_key_cache_size() + ); + } + } + + // Attempt 2 — Ford dedup recovery: iterate cached keys in parallel + // batches, mutate protection_info per attempt, re-try get_assets. + // Only reached when is_ford_asset == true. + self.cloud_download_attachment_ford_recovery_with_record( + container, + records, + record, + &record_name, + ) + .await + } + + // Ford recovery helper lives in a separate non-uniffi impl block below + // (`impl WrappedClient { ford_recovery_download ... }`) — it takes + // non-FFI reference types and uniffi can't codegen wrappers for it. + + /// Whether this build supports CloudKit avid (Live Photo MOV) downloads. + /// Always true now — the wrapper hand-rolls the avid path using our + /// local `CloudAttachmentWithAvid` record type and `container.get_assets`, + /// so it doesn't depend on any rustpush cargo feature or patch. + pub fn cloud_supports_avid_download(&self) -> bool { + true + } + + /// Download the Live Photo video (avid asset) from a CloudKit attachment record. + /// + /// Upstream rustpush's `CloudAttachment` type doesn't have an `avid` + /// field, so we fetch the record using our local `CloudAttachmentWithAvid` + /// (which declares the same `attachment` CloudKit record type plus the + /// extra `avid: Asset` field), register the record's Ford keys into + /// the wrapper cache, and call `container.get_assets` with + /// `&record.avid` as the target asset. Wrapped in catch_unwind for + /// SIV panic safety, and falls through to `cloud_download_avid_ford_recovery` + /// if the initial attempt panics on a MMCS dedup SIV failure. + pub async fn cloud_download_attachment_avid( + &self, + record_name: String, + ) -> Result<Vec<u8>, WrappedError> { + use futures::FutureExt; + use rustpush::cloudkit::{FetchRecordOperation, FetchedRecords, ALL_ASSETS}; + use rustpush::cloud_messages::MESSAGES_SERVICE; + + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + let container = cloud_messages.get_container().await.map_err(|e| WrappedError::GenericError { + msg: format!("cloud_download_attachment_avid {}: get_container: {e}", record_name), + })?; + let zone = container.private_zone("attachmentManateeZone".to_string()); + let zone_key = container + .get_zone_encryption_config(&zone, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("cloud_download_attachment_avid {}: zone key: {e}", record_name), + })?; + + let invoke = container + .perform_operations( + &CloudKitSession::new(), + &FetchRecordOperation::many(&ALL_ASSETS, &zone, &[record_name.clone()]), + IsolationLevel::Operation, + ) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("cloud_download_attachment_avid {}: perform_operations: {e}", record_name), + })?; + let records = FetchedRecords::new(&invoke); + let base_record: CloudAttachmentWithAvid = records.get_record(&record_name, Some(&zone_key)); + + // Register this record's Ford keys (both lqa and avid) into the + // wrapper cache so recovery has visibility if the initial attempt + // panics on a dedup mismatch. + if let Some(pi) = base_record + .lqa + .protection_info + .as_ref() + .and_then(|p| p.protection_info.as_ref()) + { + register_ford_key(pi.clone()); + } + if let Some(pi) = base_record + .avid + .protection_info + .as_ref() + .and_then(|p| p.protection_info.as_ref()) + { + register_ford_key(pi.clone()); + } + + // Attempt 1 — record's own avid key. + let shared = SharedWriter::new(); + let assets_tuple: Vec<(&rustpush::cloudkit_proto::Asset, SharedWriter)> = + vec![(&base_record.avid, shared.clone())]; + let fut = container.get_assets(&records.assets, assets_tuple); + let result = std::panic::AssertUnwindSafe(fut).catch_unwind().await; + match result { + Ok(Ok(())) => return Ok(shared.into_bytes()), + Ok(Err(e)) => { + return Err(WrappedError::GenericError { + msg: format!("cloud_download_attachment_avid {}: get_assets: {e}", record_name), + }); + } + Err(_panic) => { + warn!( + "cloud_download_attachment_avid {}: SIV panic, attempting Ford recovery \ + with {} cached keys", + record_name, + ford_key_cache_size() + ); + } + } + + // Attempt 2+ — Ford dedup recovery, same pattern as + // cloud_download_attachment_ford_recovery but targets record.avid. + self.cloud_download_avid_ford_recovery(container, &records, base_record, &record_name) + .await + } + + /// Download a group photo from CloudKit by the chat's record name. + /// Returns the raw image bytes. The record_name is the CloudKit record + /// in chatManateeZone where the chat's "gp" asset is stored. + pub async fn cloud_download_group_photo( + &self, + record_name: String, + ) -> Result<Vec<u8>, WrappedError> { + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + + let shared = SharedWriter::new(); + let mut files = HashMap::new(); + files.insert(record_name.clone(), shared.clone()); + cloud_messages.download_group_photo(files).await + .map_err(|e| WrappedError::GenericError { + msg: format!("Failed to download CloudKit group photo {}: {}", record_name, e), + })?; + Ok(shared.into_bytes()) + } + + /// Diagnostic: do a full fresh sync from scratch (no continuation token) + /// and return total record count + the newest message timestamps per chat. + /// This bypasses any stored token to check what CloudKit actually has. + pub async fn cloud_diag_full_count( + &self, + ) -> Result<String, WrappedError> { + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + + let mut token: Option<Vec<u8>> = None; + let mut total_records: usize = 0; + let mut total_deleted: usize = 0; + let mut chat_id_counts: HashMap<String, usize> = HashMap::new(); + let mut newest_ts: i64 = 0; + let mut newest_guid = String::new(); + let mut newest_chat = String::new(); + + for page in 0..512 { + let (next_token, messages, status) = cloud_messages.sync_messages(token).await + .map_err(|e| WrappedError::GenericError { msg: format!("diag sync page {} failed: {}", page, e) })?; + + let page_total = messages.len(); + for (_record_name, msg_opt) in &messages { + if let Some(msg) = msg_opt { + total_records += 1; + let ts = apple_timestamp_ns_to_unix_ms(msg.time); + let chat = &msg.chat_id; + *chat_id_counts.entry(chat.clone()).or_insert(0) += 1; + if ts > newest_ts { + newest_ts = ts; + newest_guid = msg.guid.clone(); + newest_chat = chat.clone(); + } + } else { + total_deleted += 1; + } + } + info!("diag page {} => {} records (status={})", page, page_total, status); + + if status == 3 { + break; + } + token = Some(next_token); + } + + let unique_chats = chat_id_counts.len(); + let result = format!( + "total_records={} deleted={} unique_chats={} newest_ts={} newest_guid={} newest_chat={}", + total_records, total_deleted, unique_chats, newest_ts, newest_guid, newest_chat + ); + info!("CloudKit diag: {}", result); + Ok(result) + } + + pub async fn cloud_fetch_recent_messages( + &self, + since_timestamp_ms: u64, + chat_id: Option<String>, + max_pages: u32, + max_results: u32, + ) -> Result<Vec<WrappedCloudSyncMessage>, WrappedError> { + let cloud_messages = self.get_or_init_cloud_messages_client().await?; + let since = since_timestamp_ms as i64; + let max_pages = if max_pages == 0 { 1 } else { max_pages }; + let max_results = if max_results == 0 { 1 } else { max_results as usize }; + + let mut token: Option<Vec<u8>> = None; + let mut deduped: HashMap<String, WrappedCloudSyncMessage> = HashMap::new(); + + 'pages: for _ in 0..max_pages { + const MAX_SYNC_ATTEMPTS: usize = 4; + let mut sync_result = None; + let mut last_pcs_err: Option<rustpush::PushError> = None; + + for attempt in 0..MAX_SYNC_ATTEMPTS { + match fetch_main_zone_page_newest_first(&cloud_messages, token.clone()).await { + Ok(result) => { + sync_result = Some(result); + break; + } + Err(err) if is_pcs_recoverable_error(&err) => { + let attempt_no = attempt + 1; + warn!( + "CloudKit recent fetch hit PCS key error on attempt {}/{}: {}", + attempt_no, + MAX_SYNC_ATTEMPTS, + err + ); + last_pcs_err = Some(err); + if attempt_no < MAX_SYNC_ATTEMPTS { + self.recover_cloud_pcs_state("CloudKit recent fetch").await?; + continue; + } + } + Err(err) => { + return Err(WrappedError::GenericError { + msg: format!("Failed to sync CloudKit messages: {}", err), + }); + } + } + } + + let (next_token, messages, status) = match sync_result { + Some(result) => result, + None => { + let err = last_pcs_err.map(|e| e.to_string()).unwrap_or_else(|| "unknown error".into()); + return Err(WrappedError::GenericError { + msg: format!("Failed to sync CloudKit messages after PCS recovery retries: {}", err), + }); + } + }; + + for (record_name, msg_opt) in messages { + let Some(msg) = msg_opt else { + continue; + }; + + // Skip system/service messages (group renames, participant changes, etc.) + if msg.flags.intersects( + rustpush::cloud_messages::MessageFlags::IS_SYSTEM_MESSAGE + | rustpush::cloud_messages::MessageFlags::IS_SERVICE_MESSAGE + ) { + continue; + } + + if let Some(ref wanted_chat) = chat_id { + if &msg.chat_id != wanted_chat { + continue; + } + } + + let timestamp_ms = apple_timestamp_ns_to_unix_ms(msg.time); + if timestamp_ms < since { + continue; + } + + let guid = if msg.guid.is_empty() { + record_name.clone() + } else { + msg.guid.clone() + }; + + let tapback_type = msg.msg_proto.associated_message_type; + let tapback_target_guid = msg.msg_proto.associated_message_guid.clone(); + let tapback_emoji = msg.msg_proto_4.as_ref() + .and_then(|p4| p4.associated_message_emoji.clone()); + + let date_read_ms = msg.msg_proto.date_read + .map(|dr| apple_timestamp_ns_to_unix_ms(dr as i64)) + .unwrap_or(0); + + // Extract attachment GUIDs from attributedBody + messageSummaryInfo + let mut attachment_guids: Vec<String> = msg.msg_proto.attributed_body + .as_ref() + .map(|body| extract_attachment_guids_from_attributed_body(body)) + .unwrap_or_default() + .into_iter() + .filter(|g| !g.is_empty() && g.len() <= 256 && g.is_ascii()) + .collect(); + if let Some(ref summary) = msg.msg_proto.message_summary_info { + for sg in extract_attachment_guids_from_summary_info(summary) { + if !sg.is_empty() && sg.len() <= 256 && sg.is_ascii() + && !attachment_guids.contains(&sg) + { + attachment_guids.push(sg); + } + } + } + + deduped.insert( + guid.clone(), + WrappedCloudSyncMessage { + record_name, + guid, + cloud_chat_id: msg.chat_id, + sender: msg.sender, + is_from_me: msg + .flags + .contains(rustpush::cloud_messages::MessageFlags::IS_FROM_ME), + text: msg.msg_proto.text.clone(), + subject: msg.msg_proto.subject.clone(), + service: msg.service, + timestamp_ms, + deleted: false, + tapback_type, + tapback_target_guid, + tapback_emoji, + attachment_guids, + date_read_ms, + msg_type: msg.r#type, + has_body: msg.msg_proto.attributed_body.is_some(), + }, + ); + + if deduped.len() >= max_results { + break 'pages; + } + } + + if status == 3 { + break; + } + token = Some(next_token); + } + + let mut output = deduped.into_values().collect::<Vec<_>>(); + output.sort_by(|a, b| { + a.timestamp_ms + .cmp(&b.timestamp_ms) + .then_with(|| a.guid.cmp(&b.guid)) + }); + if output.len() > max_results { + output = output[output.len() - max_results..].to_vec(); + } + + Ok(output) + } + + /// Test CloudKit Messages access: creates CloudKitClient + KeychainClient + CloudMessagesClient, + /// then tries to sync chats and messages. Logs results. Returns a summary string. + pub async fn test_cloud_messages(&self) -> Result<String, WrappedError> { + let tp = self.token_provider.as_ref() + .ok_or(WrappedError::GenericError { msg: "No TokenProvider available".into() })?; + + info!("=== CloudKit Messages Test ==="); + + // Get needed credentials + let dsid = tp.get_dsid().await?; + let adsid = tp.get_adsid().await?; + let mme_delegate = tp.parse_mme_delegate().await?; + let account = tp.get_account(); + let os_config = tp.get_os_config(); + + info!("DSID: {}, ADSID: {}", dsid, adsid); + + // Get anisette client from the account + let anisette = account.lock().await.anisette.clone(); + + // Create CloudKitState + let cloudkit_state = rustpush::cloudkit::CloudKitState::new(dsid.clone()) + .ok_or(WrappedError::GenericError { msg: "Failed to create CloudKitState".into() })?; + + // Create CloudKitClient + let cloudkit = Arc::new(rustpush::cloudkit::CloudKitClient { + state: rustpush::DebugRwLock::new(cloudkit_state), + anisette: anisette.clone(), + config: os_config.clone(), + token_provider: tp.inner.clone(), + }); + + // Create KeychainClientState + let keychain_state = rustpush::keychain::KeychainClientState::new(dsid.clone(), adsid.clone(), &mme_delegate) + .ok_or(WrappedError::GenericError { msg: "Failed to create KeychainClientState — missing KeychainSync config in MobileMe delegate".into() })?; + + info!("KeychainClientState created successfully"); + + // Create KeychainClient + let keychain = Arc::new(rustpush::keychain::KeychainClient { + anisette: anisette.clone(), + token_provider: tp.inner.clone(), + state: rustpush::DebugRwLock::new(keychain_state), + config: os_config.clone(), + update_state: Box::new(|_state| { + // For now, don't persist keychain state + info!("Keychain state updated (not persisted yet)"); + }), + container: tokio::sync::Mutex::new(None), + security_container: tokio::sync::Mutex::new(None), + client: cloudkit.clone(), + }); + + // Try to sync the keychain (needed for PCS decryption keys) + info!("Syncing iCloud Keychain..."); + match keychain.sync_keychain(&rustpush::keychain::KEYCHAIN_ZONES).await { + Ok(()) => info!("Keychain sync successful"), + Err(e) => { + let msg = format!("Keychain sync failed: {}. This likely means we need to join the trust circle first.", e); + warn!("{}", msg); + return Ok(msg); + } + } + + // Create CloudMessagesClient + let cloud_messages = rustpush::cloud_messages::CloudMessagesClient::new(cloudkit.clone(), keychain.clone()); + + // Try counting records first + info!("Counting CloudKit message records..."); + match cloud_messages.count_records().await { + Ok(summary) => { + info!("CloudKit record counts — messages: {}, chats: {}, attachments: {}", + summary.messages_summary.len(), summary.chat_summary.len(), summary.attachment_summary.len()); + } + Err(e) => { + warn!("Failed to count records: {}", e); + } + } + + // Try syncing chats + info!("Syncing CloudKit chats..."); + let mut total_chats = 0; + let mut chat_names: Vec<String> = Vec::new(); + match cloud_messages.sync_chats(None).await { + Ok((_token, chats, status)) => { + info!("Chat sync returned {} chats (status={})", chats.len(), status); + for (id, chat_opt) in &chats { + if let Some(chat) = chat_opt { + let name = chat.display_name.as_deref().unwrap_or("(unnamed)"); + let participants: Vec<&str> = chat.participants.iter().map(|p| p.uri.as_str()).collect(); + info!(" Chat: {} | id={} | svc={} | participants={:?}", name, chat.chat_identifier, chat.service_name, participants); + chat_names.push(format!("{}: {} [{}]", id, name, chat.chat_identifier)); + } else { + info!(" Chat {} deleted", id); + } + total_chats += 1; + } + } + Err(e) => { + let msg = format!("Chat sync failed: {}", e); + warn!("{}", msg); + return Ok(msg); + } + } + + // Try syncing messages (first page) + info!("Syncing CloudKit messages (first page)..."); + let mut total_messages = 0; + match cloud_messages.sync_messages(None).await { + Ok((_token, messages, status)) => { + info!("Message sync returned {} messages (status={})", messages.len(), status); + for (id, msg_opt) in messages.iter().take(20) { + if let Some(msg) = msg_opt { + let from_me = msg.flags.contains(rustpush::cloud_messages::MessageFlags::IS_FROM_ME); + info!(" Msg: {} | chat={} | sender={} | from_me={} | svc={} | guid={}", + id, msg.chat_id, msg.sender, from_me, msg.service, msg.guid); + } else { + info!(" Msg {} deleted", id); + } + total_messages += 1; + } + if messages.len() > 20 { + info!(" ... and {} more messages", messages.len() - 20); + total_messages = messages.len(); + } + } + Err(e) => { + let msg = format!("Message sync failed: {}", e); + warn!("{}", msg); + return Ok(format!("Chats OK ({} chats), but message sync failed: {}", total_chats, e)); + } + } + + let summary = format!("CloudKit sync OK: {} chats, {} messages (first page)", total_chats, total_messages); + info!("{}", summary); + Ok(summary) + } + + pub async fn stop(&self) { + let mut handle = self.receive_handle.lock().await; + if let Some(h) = handle.take() { + h.abort(); + } + } +} + +// Non-uniffi-exported helpers on `Client`. Methods here take reference types +// (like `&CloudMessagesClient<...>` or `&str`) that uniffi can't generate FFI +// wrappers for, so they live in a plain impl block that the FFI codegen +// ignores. +impl Client { + /// Ford dedup recovery using an already-fetched record + FetchedRecords. + /// This variant is called by `cloud_download_attachment` after its first + /// `get_assets` attempt panics — it reuses the existing record/assets + /// instead of re-fetching, which saves a CloudKit round-trip per download. + async fn cloud_download_attachment_ford_recovery_with_record( + &self, + container: Arc<rustpush::cloudkit::CloudKitOpenContainer<'static, BridgeDefaultAnisetteProvider>>, + records: rustpush::cloudkit::FetchedRecords, + base_record: CloudAttachmentWithAvid, + record_name: &str, + ) -> Result<Vec<u8>, WrappedError> { + // Fully-manual Ford download. Bypasses upstream's V1-only + // `get_mmcs` (which panics on V2 Ford records) and handles V1, + // V2, and dedup (cross-batch brute force via the cached key set) + // in a single pure-crypto pass. + // + // The outer `cloud_download_attachment` attempted upstream's + // happy path first and catch_unwind'd its panic; we're here + // because that failed. The ONLY path upstream takes to its + // panic is the Ford path, so we know this is a Ford-encrypted + // asset (is_ford_asset was also pre-checked by the caller). + // + // What this function does NOT do that upstream does: + // - getComplete confirmation (CloudKit passes empty url, so + // upstream skips it too — see mmcs.rs:1260-1272) + // - chunk-level HTTP range requests with streaming matcher + // (we fetch each container body in full — simpler, same + // result for typical attachment sizes) + + let lqa = &base_record.lqa; + let bundled_id = lqa.bundled_request_id.as_ref().ok_or_else(|| { + WrappedError::GenericError { + msg: format!("manual_ford_download {}: lqa.bundled_request_id is None", record_name), + } + })?; + let signature = lqa.signature.as_ref().ok_or_else(|| { + WrappedError::GenericError { + msg: format!("manual_ford_download {}: lqa.signature is None", record_name), + } + })?; + let ford_key = lqa + .protection_info + .as_ref() + .and_then(|pi| pi.protection_info.as_ref()) + .cloned() + .unwrap_or_default(); + + // Find the AssetGetResponse for this asset's bundled_request_id. + let asset_response = records + .assets + .iter() + .find(|r| r.asset_id.as_ref() == Some(bundled_id)) + .ok_or_else(|| WrappedError::GenericError { + msg: format!( + "manual_ford_download {}: no AssetGetResponse for bundled_request_id", + record_name + ), + })?; + let body = asset_response.body.as_ref().ok_or_else(|| { + WrappedError::GenericError { + msg: format!( + "manual_ford_download {}: AssetGetResponse.body is None", + record_name + ), + } + })?; + + // User agent for MMCS fetches — CloudKit style (matches what + // upstream's `get_assets` constructs at cloudkit.rs:2080). + let user_agent = container + .client + .config + .get_normal_ua("CloudKit/1970"); + + info!( + "manual_ford_download {}: starting V1+V2 Ford path (ford_key_len={} cache_size={})", + record_name, + ford_key.len(), + ford_key_cache_size() + ); + + match manual_ford::manual_ford_download_asset( + body, + signature, + &ford_key, + &user_agent, + record_name, + ) + .await + { + Ok(bytes) => { + warn!( + "manual_ford_download {}: SUCCESS bytes={}", + record_name, + bytes.len() + ); + Ok(bytes) + } + Err(e) => { + warn!("manual_ford_download {}: FAILED: {}", record_name, e); + Err(WrappedError::GenericError { + msg: format!("Manual Ford download for {}: {}", record_name, e), + }) + } + } + } + + /// Ford dedup recovery path: fetch the CloudAttachment record, iterate + /// over every cached Ford key, mutate `Asset.protection_info` per attempt, + /// and retry `container.get_assets(...)` wrapped in `catch_unwind` until + /// one candidate SIV-decrypts cleanly. Matches the 94f7b8e fix's + /// cross-batch recovery semantics without modifying upstream rustpush. + #[allow(dead_code)] + async fn cloud_download_attachment_ford_recovery( + &self, + cloud_messages: &rustpush::cloud_messages::CloudMessagesClient<BridgeDefaultAnisetteProvider>, + record_name: &str, + ) -> Result<Vec<u8>, WrappedError> { + use futures::FutureExt; + use rustpush::cloudkit::{FetchRecordOperation, FetchedRecords, ALL_ASSETS}; + use rustpush::cloud_messages::{CloudAttachment, MESSAGES_SERVICE}; + + let container = cloud_messages.get_container().await.map_err(|e| WrappedError::GenericError { + msg: format!("Ford recovery: get_container failed: {e}"), + })?; + let zone = container.private_zone("attachmentManateeZone".to_string()); + let zone_key = container + .get_zone_encryption_config(&zone, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("Ford recovery: get_zone_encryption_config failed: {e}"), + })?; + + let invoke = container + .perform_operations( + &CloudKitSession::new(), + &FetchRecordOperation::many(&ALL_ASSETS, &zone, &[record_name.to_string()]), + IsolationLevel::Operation, + ) + .await + .map_err(|e| WrappedError::GenericError { + msg: format!("Ford recovery: perform_operations failed: {e}"), + })?; + let records = FetchedRecords::new(&invoke); + let base_record: CloudAttachment = records.get_record(record_name, Some(&zone_key)); + + // Register the record's own key in case sync hasn't touched it yet + // (e.g. direct download of a just-arrived attachment). + if let Some(pi) = base_record + .lqa + .protection_info + .as_ref() + .and_then(|p| p.protection_info.as_ref()) + { + register_ford_key(pi.clone()); + } + + let cached_keys = ford_key_cache_values(); + info!( + "Ford recovery {}: trying {} cached keys", + record_name, + cached_keys.len() + ); + + // Pre-flight: if the record's lqa has no bundled_request_id, upstream's + // `get_assets` would panic immediately at cloudkit.rs:2075 with "No + // bundled asset!" — no point iterating 900 keys. Bail out early with + // a clear error instead. + if base_record.lqa.bundled_request_id.is_none() { + warn!( + "Ford recovery {}: lqa.bundled_request_id is None (record not ALL_ASSETS-authorized?), skipping recovery", + record_name + ); + return Err(WrappedError::GenericError { + msg: format!("Ford dedup recovery for {}: lqa asset has no bundled_request_id", record_name), + }); + } + + for (idx, alt_key) in cached_keys.iter().enumerate() { + // Clone per attempt so we can mutate `protection_info` + // independently and retry cleanly on panic. + let mut record = base_record.clone(); + if let Some(pi) = record.lqa.protection_info.as_mut() { + pi.protection_info = Some(alt_key.clone()); + } else { + continue; + } + + let shared = SharedWriter::new(); + let assets_tuple: Vec<(&rustpush::cloudkit_proto::Asset, SharedWriter)> = + vec![(&record.lqa, shared.clone())]; + + let fut = container.get_assets(&records.assets, assets_tuple); + let result = std::panic::AssertUnwindSafe(fut).catch_unwind().await; + match result { + Ok(Ok(())) => { + let bytes = shared.into_bytes(); + warn!( + "Ford dedup recovery SUCCESS: record={} attempt={}/{} bytes={}", + record_name, + idx + 1, + cached_keys.len(), + bytes.len() + ); + return Ok(bytes); + } + Ok(Err(e)) => { + debug!("Ford recovery attempt {} returned error: {}", idx + 1, e); + } + Err(_panic) => { + debug!( + "Ford recovery attempt {} panicked (wrong key, retrying)", + idx + 1 + ); + } + } + } + + warn!( + "Ford dedup recovery FAILED: record={} tried={} all cached keys exhausted", + record_name, + cached_keys.len() + ); + Err(WrappedError::GenericError { + msg: format!( + "Ford dedup recovery for {}: all {} cached keys failed SIV decrypt", + record_name, + cached_keys.len() + ), + }) + } + + /// Ford dedup recovery for the Live Photo MOV (`avid`) asset. Same + /// algorithm as `cloud_download_attachment_ford_recovery` but operates + /// on `record.avid` instead of `record.lqa`. Takes an already-fetched + /// `CloudAttachmentWithAvid` and `FetchedRecords` so we don't re-hit + /// the CloudKit record endpoint per attempt. + async fn cloud_download_avid_ford_recovery( + &self, + container: Arc<rustpush::cloudkit::CloudKitOpenContainer<'static, BridgeDefaultAnisetteProvider>>, + records: &rustpush::cloudkit::FetchedRecords, + base_record: CloudAttachmentWithAvid, + record_name: &str, + ) -> Result<Vec<u8>, WrappedError> { + // Manual V1+V2 Ford download for the Live Photo (avid) asset. + // Same algorithm as the lqa recovery path, but targeting + // `base_record.avid` instead of `base_record.lqa`. + let avid = &base_record.avid; + let bundled_id = avid.bundled_request_id.as_ref().ok_or_else(|| { + WrappedError::GenericError { + msg: format!( + "manual_ford_download (avid) {}: avid.bundled_request_id is None", + record_name + ), + } + })?; + let signature = avid.signature.as_ref().ok_or_else(|| { + WrappedError::GenericError { + msg: format!("manual_ford_download (avid) {}: avid.signature is None", record_name), + } + })?; + let ford_key = avid + .protection_info + .as_ref() + .and_then(|pi| pi.protection_info.as_ref()) + .cloned() + .unwrap_or_default(); + + let asset_response = records + .assets + .iter() + .find(|r| r.asset_id.as_ref() == Some(bundled_id)) + .ok_or_else(|| WrappedError::GenericError { + msg: format!( + "manual_ford_download (avid) {}: no AssetGetResponse for bundled_request_id", + record_name + ), + })?; + let body = asset_response.body.as_ref().ok_or_else(|| { + WrappedError::GenericError { + msg: format!( + "manual_ford_download (avid) {}: AssetGetResponse.body is None", + record_name + ), + } + })?; + + let user_agent = container + .client + .config + .get_normal_ua("CloudKit/1970"); + + info!( + "manual_ford_download (avid) {}: starting V1+V2 Ford path", + record_name + ); + + match manual_ford::manual_ford_download_asset( + body, + signature, + &ford_key, + &user_agent, + record_name, + ) + .await + { + Ok(bytes) => { + warn!( + "manual_ford_download (avid) {}: SUCCESS bytes={}", + record_name, + bytes.len() + ); + Ok(bytes) + } + Err(e) => { + warn!("manual_ford_download (avid) {}: FAILED: {}", record_name, e); + Err(WrappedError::GenericError { + msg: format!("Manual Ford download (avid) for {}: {}", record_name, e), + }) + } + } + } +} + +/// Fetch one page of messageManateeZone with newest-first ordering. +/// Returns (next_token, messages_map, status) — same shape as sync_messages(). +/// +/// Standalone (not a method on Client) so uniffi doesn't try to generate FFI +/// bindings for it. We call the CloudKit container directly (same pattern as +/// list_recoverable_chats) so we can set newest_first=true without touching the +/// vendored sync_messages path. This lets cloud_fetch_recent_messages find recent +/// messages in the first ~50 pages instead of requiring 200+ oldest-first pages. +async fn fetch_main_zone_page_newest_first( + cloud_messages: &rustpush::cloud_messages::CloudMessagesClient<BridgeDefaultAnisetteProvider>, + token: Option<Vec<u8>>, +) -> Result<(Vec<u8>, HashMap<String, Option<rustpush::cloud_messages::CloudMessage>>, i32), rustpush::PushError> { + use rustpush::cloudkit::{pcs_keys_for_record, FetchRecordChangesOperation, CloudKitSession, NO_ASSETS}; + use rustpush::cloudkit_proto::CloudKitRecord; + use rustpush::cloud_messages::{MESSAGES_SERVICE, CloudMessage}; + + let container = cloud_messages.get_container().await?; + let zone_id = container.private_zone("messageManateeZone".to_string()); + let mut key = container + .get_zone_encryption_config(&zone_id, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await?; + + // Create the op then flip newest_first — the inner proto field is pub, so we + // can mutate it here without touching the vendored FetchRecordChangesOperation::new. + let mut op = FetchRecordChangesOperation::new(zone_id.clone(), token, &NO_ASSETS); + op.0.newest_first = Some(true); + + let (_assets, response) = container.perform(&CloudKitSession::new(), op).await?; + + let mut results = HashMap::new(); + let mut refreshed = false; + for change in &response.change { + let identifier = change.identifier.as_ref().unwrap() + .value.as_ref().unwrap().name().to_string(); + let Some(record) = &change.record else { + results.insert(identifier, None); + continue; + }; + if record.r#type.as_ref().unwrap().name() != CloudMessage::record_type() { + continue; + } + let pcskey = match pcs_keys_for_record(record, &key) { + Ok(k) => Some(k), + Err(rustpush::PushError::PCSRecordKeyMissing) if !refreshed => { + container.clear_cache_zone_encryption_config(&zone_id).await; + key = container.get_zone_encryption_config(&zone_id, &cloud_messages.keychain, &MESSAGES_SERVICE).await?; + refreshed = true; + match pcs_keys_for_record(record, &key) { + Ok(k) => Some(k), + Err(rustpush::PushError::PCSRecordKeyMissing) => { + warn!("Skipping record {}: PCS key missing (newest-first fetch)", identifier); + None + } + Err(e) => return Err(e), + } + } + Err(e) if matches!(e, + rustpush::PushError::PCSRecordKeyMissing + | rustpush::PushError::ShareKeyNotFound(_) + | rustpush::PushError::DecryptionKeyNotFound(_) + | rustpush::PushError::MasterKeyNotFound + ) => { + warn!("Skipping record {} due to PCS key error: {}", identifier, e); + None + } + Err(e) => return Err(e), + }; + let Some(pcskey) = pcskey else { continue }; + let item = CloudMessage::from_record_encrypted( + &record.record_field, + Some(&pcskey), + ); + results.insert(identifier, Some(item)); + } + Ok((response.sync_continuation_token().to_vec(), results, response.status())) +} + +impl Drop for Client { + fn drop(&mut self) { + if let Ok(mut handle) = self.receive_handle.try_lock() { + if let Some(h) = handle.take() { + h.abort(); + } + } + } +} + +/// Fallback for cloud_sync_messages when sync_messages panics on a malformed CloudKit record. +/// Replicates sync_records logic for "messageManateeZone" but handles None proto fields +/// gracefully (no .unwrap()) and wraps from_record_encrypted in catch_unwind per record. +async fn sync_messages_fallback( + cloud_messages: &Arc<rustpush::cloud_messages::CloudMessagesClient<BridgeDefaultAnisetteProvider>>, + token: Option<Vec<u8>>, +) -> Result<(Vec<u8>, HashMap<String, Option<rustpush::cloud_messages::CloudMessage>>, i32), rustpush::PushError> { + use rustpush::cloudkit::{pcs_keys_for_record, FetchRecordChangesOperation, CloudKitSession, NO_ASSETS}; + use rustpush::cloudkit_proto::CloudKitRecord; + use rustpush::cloud_messages::{MESSAGES_SERVICE, CloudMessage}; + + let container = cloud_messages.get_container().await?; + let zone_id = container.private_zone("messageManateeZone".to_string()); + let key = container + .get_zone_encryption_config(&zone_id, &cloud_messages.keychain, &MESSAGES_SERVICE) + .await?; + + let (_assets, response) = container + .perform( + &CloudKitSession::new(), + FetchRecordChangesOperation::new(zone_id, token, &NO_ASSETS), + ) + .await?; + + let mut results: HashMap<String, Option<CloudMessage>> = HashMap::new(); + let mut skipped = 0usize; + + for change in &response.change { + // Graceful identifier extraction — no .unwrap() + let id_proto = match change.identifier.as_ref() { + Some(p) => p, + None => { warn!("sync_messages_fallback: skipping change with missing identifier"); skipped += 1; continue; } + }; + let id_value = match id_proto.value.as_ref() { + Some(v) => v, + None => { warn!("sync_messages_fallback: skipping change with missing identifier value"); skipped += 1; continue; } + }; + let identifier = id_value.name().to_string(); + + let record = match &change.record { + Some(r) => r, + None => { results.insert(identifier, None); continue; } // deleted record + }; + + // Graceful type check — no .unwrap() + if record.r#type.as_ref().map_or(true, |t| t.name() != CloudMessage::record_type()) { + continue; + } + + let pcskey = match pcs_keys_for_record(record, &key) { + Ok(k) => k, + Err(e) => { + warn!("sync_messages_fallback: skipping record {}: PCS key error: {}", identifier, e); + skipped += 1; + continue; + } + }; + + // from_record_encrypted may panic on corrupt field data — catch it + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + CloudMessage::from_record_encrypted(&record.record_field, Some(&pcskey)) + })); + + match result { + Ok(msg) => { results.insert(identifier, Some(msg)); } + Err(e) => { + let msg = if let Some(s) = e.downcast_ref::<String>() { s.clone() } + else if let Some(s) = e.downcast_ref::<&str>() { s.to_string() } + else { "unknown panic".to_string() }; + warn!("sync_messages_fallback: skipping record {}: deserialization panic: {}", identifier, msg); + skipped += 1; + } + } + } + + if skipped > 0 { + warn!("sync_messages_fallback: skipped {} malformed record(s)", skipped); + } + + Ok(( + response.sync_continuation_token().to_vec(), + results, + response.status(), + )) +} + +uniffi::setup_scaffolding!(); diff --git a/pkg/rustpushgo/src/local_config.rs b/pkg/rustpushgo/src/local_config.rs new file mode 100644 index 00000000..7053e724 --- /dev/null +++ b/pkg/rustpushgo/src/local_config.rs @@ -0,0 +1,188 @@ +//! LocalMacOSConfig — reads hardware info from IOKit and produces a +//! `rustpush::macos::MacOSConfig` via `into_macos_config()`. +//! +//! `MacOSConfig` already implements `OSConfig` (inside the upstream crate where +//! `ActivationInfo` is visible), so we don't need to re-implement `OSConfig` here. +//! Our overlaid `rustpush/open-absinthe/src/nac.rs` provides the native NAC path +//! (`AAAbsintheContext` via `nac-validation`) when the `native-nac` feature is on, +//! so `MacOSConfig::generate_validation_data` automatically uses it. +//! +//! _enc fields are left empty — the native NAC path reads hardware identifiers +//! directly from IOKit and does not use them. + +use std::ffi::CStr; + +use rustpush::macos::{HardwareConfig, MacOSConfig}; + +// FFI for hardware_info.m +#[repr(C)] +struct CHardwareInfo { + product_name: *mut std::os::raw::c_char, + serial_number: *mut std::os::raw::c_char, + platform_uuid: *mut std::os::raw::c_char, + board_id: *mut std::os::raw::c_char, + os_build_num: *mut std::os::raw::c_char, + os_version: *mut std::os::raw::c_char, + rom: *mut u8, + rom_len: usize, + mlb: *mut std::os::raw::c_char, + mac_address: *mut u8, + mac_address_len: usize, + root_disk_uuid: *mut std::os::raw::c_char, + darwin_version: *mut std::os::raw::c_char, + error: *mut std::os::raw::c_char, +} + +extern "C" { + fn hw_info_read() -> CHardwareInfo; + fn hw_info_free(info: *mut CHardwareInfo); +} + +fn c_str_to_string(ptr: *mut std::os::raw::c_char) -> Option<String> { + if ptr.is_null() { + return None; + } + Some(unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned()) +} + +fn c_data_to_vec(ptr: *mut u8, len: usize) -> Vec<u8> { + if ptr.is_null() || len == 0 { + return vec![]; + } + unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec() +} + +/// Hardware info read from IOKit. +#[derive(Debug, Clone)] +pub struct HardwareInfo { + pub product_name: String, + pub serial_number: String, + pub platform_uuid: String, + pub board_id: String, + pub os_build_num: String, + pub os_version: String, + pub rom: Vec<u8>, + pub mlb: String, + pub mac_address: [u8; 6], + pub root_disk_uuid: String, + pub darwin_version: String, +} + +impl HardwareInfo { + pub fn read() -> Result<Self, String> { + let mut raw = unsafe { hw_info_read() }; + + if !raw.error.is_null() { + let err = c_str_to_string(raw.error).unwrap_or_default(); + unsafe { hw_info_free(&mut raw) }; + return Err(err); + } + + let mac_vec = c_data_to_vec(raw.mac_address, raw.mac_address_len); + let mac_address: [u8; 6] = if mac_vec.len() == 6 { + mac_vec.try_into().unwrap() + } else { + [0; 6] + }; + + let info = HardwareInfo { + product_name: c_str_to_string(raw.product_name).unwrap_or_else(|| "Mac".to_string()), + serial_number: c_str_to_string(raw.serial_number).unwrap_or_default(), + platform_uuid: c_str_to_string(raw.platform_uuid).unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + board_id: c_str_to_string(raw.board_id).unwrap_or_default(), + os_build_num: c_str_to_string(raw.os_build_num).unwrap_or_else(|| "25B78".to_string()), + os_version: c_str_to_string(raw.os_version).unwrap_or_else(|| "26.1".to_string()), + rom: c_data_to_vec(raw.rom, raw.rom_len), + mlb: c_str_to_string(raw.mlb).unwrap_or_default(), + mac_address, + root_disk_uuid: c_str_to_string(raw.root_disk_uuid).unwrap_or_default(), + darwin_version: c_str_to_string(raw.darwin_version).unwrap_or_else(|| "24.0.0".to_string()), + }; + + unsafe { hw_info_free(&mut raw) }; + Ok(info) + } +} + +/// Local macOS configuration builder. +/// Call `into_macos_config()` to get the `MacOSConfig` that implements `OSConfig`. +#[derive(Clone)] +pub struct LocalMacOSConfig { + pub hw: HardwareInfo, + pub device_id: String, + pub protocol_version: u32, + pub icloud_ua: String, + pub aoskit_version: String, +} + +impl LocalMacOSConfig { + pub fn new() -> Result<Self, String> { + let hw = HardwareInfo::read()?; + // Use the real hardware UUID — AAAbsintheContext embeds it in + // validation data, so a random UUID would cause Apple to reject + // the registration (error 6001). + let device_id = hw.platform_uuid.to_uppercase(); + + let darwin = &hw.darwin_version; + let icloud_ua = format!( + "com.apple.iCloudHelper/282 CFNetwork/1568.100.1 Darwin/{}", + darwin + ); + let aoskit_version = "com.apple.AOSKit/282 (com.apple.accountsd/113)".to_string(); + + Ok(Self { + hw, + device_id, + protocol_version: 1660, + icloud_ua, + aoskit_version, + }) + } + + pub fn with_device_id(self, id: String) -> Self { + // For LocalMacOSConfig, the device ID must always be the hardware + // UUID because AAAbsintheContext embeds it in the validation data. + // Ignore any persisted device ID — it may be a stale random UUID + // from before this fix. + if id != self.device_id { + log::warn!( + "Ignoring persisted device ID {} — LocalMacOSConfig must use hardware UUID {}", + id, self.device_id + ); + } + self + } + + /// Convert to the upstream `MacOSConfig` that implements `OSConfig`. + /// + /// `_enc` fields are empty — the native NAC path (our overlaid + /// `rustpush/open-absinthe/src/nac.rs`) reads hardware identifiers from + /// IOKit directly and does not use these fields. + pub fn into_macos_config(self) -> MacOSConfig { + MacOSConfig { + inner: HardwareConfig { + product_name: self.hw.product_name, + io_mac_address: self.hw.mac_address, + platform_serial_number: self.hw.serial_number, + platform_uuid: self.hw.platform_uuid, + root_disk_uuid: self.hw.root_disk_uuid, + board_id: self.hw.board_id, + os_build_num: self.hw.os_build_num, + // _enc fields empty — native NAC reads from IOKit internally + platform_serial_number_enc: vec![], + platform_uuid_enc: vec![], + root_disk_uuid_enc: vec![], + rom: self.hw.rom, + rom_enc: vec![], + mlb: self.hw.mlb, + mlb_enc: vec![], + }, + version: self.hw.os_version, + protocol_version: self.protocol_version, + device_id: self.device_id.clone(), + icloud_ua: self.icloud_ua, + aoskit_version: self.aoskit_version, + udid: Some(self.device_id), + } + } +} diff --git a/pkg/rustpushgo/src/test_hwinfo.rs b/pkg/rustpushgo/src/test_hwinfo.rs new file mode 100644 index 00000000..f3d6f28d --- /dev/null +++ b/pkg/rustpushgo/src/test_hwinfo.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod tests { + use crate::local_config::HardwareInfo; + + #[test] + fn test_hardware_info_read() { + let info = HardwareInfo::read().expect("Failed to read hardware info"); + eprintln!("Product: {}", info.product_name); + eprintln!("Serial: {}", info.serial_number); + eprintln!("UUID: {}", info.platform_uuid); + eprintln!("Build: {}", info.os_build_num); + eprintln!("Version: {}", info.os_version); + eprintln!("ROM: {} bytes", info.rom.len()); + eprintln!("MLB: {}", info.mlb); + eprintln!("MAC: {:02x?}", info.mac_address); + + assert!(!info.product_name.is_empty(), "product name should not be empty"); + assert!(!info.serial_number.is_empty(), "serial number should not be empty"); + assert!(!info.os_version.is_empty(), "os version should not be empty"); + } +} diff --git a/pkg/rustpushgo/src/util.rs b/pkg/rustpushgo/src/util.rs new file mode 100644 index 00000000..3a5446ac --- /dev/null +++ b/pkg/rustpushgo/src/util.rs @@ -0,0 +1,24 @@ +use std::io::Cursor; + +pub fn plist_to_buf<T: serde::Serialize + ?Sized>(value: &T) -> Result<Vec<u8>, plist::Error> { + let mut buf: Vec<u8> = Vec::new(); + let writer = Cursor::new(&mut buf); + plist::to_writer_xml(writer, &value)?; + Ok(buf) +} + +pub fn plist_to_string<T>(value: &T) -> Result<String, plist::Error> +where + T: serde::Serialize + ?Sized, +{ + plist_to_buf(value).map(|val| String::from_utf8(val).unwrap()) +} + +pub fn plist_from_buf<T: serde::de::DeserializeOwned>(buf: &[u8]) -> Result<T, plist::Error> { + let reader = Cursor::new(buf); + plist::from_reader_xml(reader) +} + +pub fn plist_from_string<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, plist::Error> { + plist_from_buf(s.as_bytes()) +} diff --git a/portal.go b/portal.go deleted file mode 100644 index 8afb8904..00000000 --- a/portal.go +++ /dev/null @@ -1,2430 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <https://www.gnu.org/licenses/>. - -package main - -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - "path/filepath" - "runtime/debug" - "sort" - "strings" - "sync" - "time" - - "github.com/gabriel-vasile/mimetype" - "github.com/rs/zerolog" - "go.mau.fi/util/dbutil" - "go.mau.fi/util/ffmpeg" - "go.mau.fi/util/jsontime" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/maulogger/v2/maulogadapt" - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/bridge/status" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/database" - "go.mau.fi/mautrix-imessage/imessage" - "go.mau.fi/mautrix-imessage/ipc" -) - -func (br *IMBridge) GetPortalByMXID(mxid id.RoomID) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - portal, ok := br.portalsByMXID[mxid] - if !ok { - return br.loadDBPortal(nil, br.DB.Portal.GetByMXID(mxid), "") - } - return portal -} - -func (br *IMBridge) GetPortalByGUID(guid string) *Portal { - return br.GetPortalByGUIDWithTransaction(nil, guid) -} - -func (br *IMBridge) GetPortalByGUIDWithTransaction(txn dbutil.Execable, guid string) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - return br.maybeGetPortalByGUID(txn, guid, true) -} - -func (br *IMBridge) GetPortalByGUIDIfExists(guid string) *Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - return br.maybeGetPortalByGUID(nil, guid, false) -} - -func (br *IMBridge) maybeGetPortalByGUID(txn dbutil.Execable, guid string, createIfNotExist bool) *Portal { - if br.Config.Bridge.DisableSMSPortals && strings.HasPrefix(guid, "SMS;-;") { - parsed := imessage.ParseIdentifier(guid) - if !parsed.IsGroup && parsed.Service == "SMS" { - parsed.Service = "iMessage" - guid = parsed.String() - } - } - fallbackGUID := guid - if !createIfNotExist { - fallbackGUID = "" - } - portal, ok := br.portalsByGUID[guid] - if !ok { - return br.loadDBPortal(txn, br.DB.Portal.GetByGUID(guid), fallbackGUID) - } - return portal -} - -func (br *IMBridge) GetMessagesSince(chatGUID string, since time.Time) (out []string) { - return br.DB.Message.GetIDsSince(chatGUID, since) -} - -func (br *IMBridge) ReIDPortal(oldGUID, newGUID string, mergeExisting bool) bool { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - portal := br.maybeGetPortalByGUID(nil, oldGUID, false) - if portal == nil { - br.Log.Debugfln("Ignoring chat ID change %s->%s, no portal with old ID found", oldGUID, newGUID) - return false - } - - return portal.reIDInto(newGUID, nil, false, mergeExisting) -} - -func (portal *Portal) reIDInto(newGUID string, newPortal *Portal, lock, mergeExisting bool) bool { - br := portal.bridge - if lock { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - } - if newPortal == nil { - newPortal = br.maybeGetPortalByGUID(nil, newGUID, false) - } - if newPortal != nil { - if mergeExisting && portal.MXID != "" && newPortal.MXID != "" && br.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { - br.Log.Infofln("Got chat ID change %s->%s, but portal with new ID already exists. Merging portals in background", portal.GUID, newGUID) - go newPortal.Merge([]*Portal{portal}) - return false - } else if newPortal.MXID == "" && portal.MXID != "" { - br.Log.Infofln("Got chat ID change %s->%s. Portal with new ID already exists, but it doesn't have a room. Nuking new portal row before changing ID", portal.GUID, newGUID) - newPortal.unlockedDelete() - } else { - br.Log.Warnfln("Got chat ID change %s->%s, but portal with new ID already exists. Nuking old portal and not changing ID", portal.GUID, newGUID) - portal.unlockedDelete() - if len(portal.MXID) > 0 && portal.bridge.user.DoublePuppetIntent != nil { - _, _ = portal.bridge.user.DoublePuppetIntent.LeaveRoom(portal.MXID) - } - portal.Cleanup(false) - return false - } - } - - portal.log.Infoln("Changing chat ID to", newGUID) - delete(br.portalsByGUID, portal.GUID) - portal.Portal.ReID(newGUID) - portal.Identifier = imessage.ParseIdentifier(portal.GUID) - portal.log = portal.bridge.Log.Sub(fmt.Sprintf("Portal/%s", portal.GUID)) - br.portalsByGUID[portal.GUID] = portal - if len(portal.MXID) > 0 { - portal.UpdateBridgeInfo() - } - portal.log.Debugln("Chat ID changed successfully") - return true -} - -func (br *IMBridge) GetAllPortals() []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAllWithMXID()) -} - -func (br *IMBridge) FindPortalsByThreadID(threadID string) []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.FindByThreadID(threadID)) -} - -func (br *IMBridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal { - br.portalsLock.Lock() - defer br.portalsLock.Unlock() - output := make([]*Portal, len(dbPortals)) - for index, dbPortal := range dbPortals { - if dbPortal == nil { - continue - } - portal, ok := br.portalsByGUID[dbPortal.GUID] - if !ok { - portal = br.loadDBPortal(nil, dbPortal, "") - } - output[index] = portal - } - return output -} - -func (br *IMBridge) loadDBPortal(txn dbutil.Execable, dbPortal *database.Portal, guid string) *Portal { - if dbPortal == nil { - if guid == "" { - return nil - } - dbPortal = br.DB.Portal.New() - dbPortal.GUID = guid - dbPortal.Insert(txn) - } else if guid != dbPortal.GUID { - aliasedPortal, ok := br.portalsByGUID[dbPortal.GUID] - if ok { - br.portalsByGUID[guid] = aliasedPortal - return aliasedPortal - } - } - portal := br.NewPortal(dbPortal) - br.portalsByGUID[portal.GUID] = portal - if portal.IsPrivateChat() { - portal.SecondaryGUIDs = br.DB.MergedChat.GetAllForTarget(portal.GUID) - for _, sourceGUID := range portal.SecondaryGUIDs { - br.portalsByGUID[sourceGUID] = portal - } - } - if len(portal.MXID) > 0 { - br.portalsByMXID[portal.MXID] = portal - } - return portal -} - -func (br *IMBridge) NewPortal(dbPortal *database.Portal) *Portal { - portal := &Portal{ - Portal: dbPortal, - bridge: br, - zlog: br.ZLog.With().Str("portal_guid", dbPortal.GUID).Logger(), - - Identifier: imessage.ParseIdentifier(dbPortal.GUID), - Messages: make(chan *imessage.Message, 100), - ReadReceipts: make(chan *imessage.ReadReceipt, 100), - MessageStatuses: make(chan *imessage.SendMessageStatus, 100), - MatrixMessages: make(chan *event.Event, 100), - backfillStart: make(chan struct{}), - } - portal.log = maulogadapt.ZeroAsMau(&portal.zlog) - if !br.IM.Capabilities().MessageSendResponses { - portal.messageDedup = make(map[string]SentMessage) - } - go portal.handleMessageLoop() - return portal -} - -type SentMessage struct { - EventID id.EventID - Timestamp time.Time -} - -type Portal struct { - *database.Portal - - bridge *IMBridge - // Deprecated - log log.Logger - zlog zerolog.Logger - - SecondaryGUIDs []string - - Messages chan *imessage.Message - ReadReceipts chan *imessage.ReadReceipt - MessageStatuses chan *imessage.SendMessageStatus - MatrixMessages chan *event.Event - backfillStart chan struct{} - backfillWait sync.WaitGroup - backfillLock sync.Mutex - - roomCreateLock sync.Mutex - messageDedup map[string]SentMessage - messageDedupLock sync.Mutex - Identifier imessage.Identifier - - userIsTyping bool - typingLock sync.Mutex -} - -var ( - _ bridge.Portal = (*Portal)(nil) - _ bridge.ReadReceiptHandlingPortal = (*Portal)(nil) - _ bridge.TypingPortal = (*Portal)(nil) - // _ bridge.MembershipHandlingPortal = (*Portal)(nil) - // _ bridge.MetaHandlingPortal = (*Portal)(nil) - // _ bridge.DisappearingPortal = (*Portal)(nil) -) - -func (portal *Portal) IsEncrypted() bool { - return portal.Encrypted -} - -func (portal *Portal) MarkEncrypted() { - portal.Encrypted = true - portal.Update(nil) -} - -func (portal *Portal) ReceiveMatrixEvent(_ bridge.User, evt *event.Event) { - portal.MatrixMessages <- evt -} - -func (portal *Portal) addSecondaryGUIDs(guids []string) { - portal.SecondaryGUIDs = append(portal.SecondaryGUIDs, guids...) - sort.Strings(portal.SecondaryGUIDs) - filtered := portal.SecondaryGUIDs[:0] - for i, guid := range portal.SecondaryGUIDs { - if i >= len(portal.SecondaryGUIDs)-1 || guid != portal.SecondaryGUIDs[i+1] { - filtered = append(filtered, guid) - } - } - portal.SecondaryGUIDs = filtered -} - -func (portal *Portal) SyncParticipants(chatInfo *imessage.ChatInfo) (memberIDs []id.UserID) { - var members map[id.UserID]mautrix.JoinedMember - if portal.MXID != "" { - membersResp, err := portal.MainIntent().JoinedMembers(portal.MXID) - if err != nil { - portal.log.Warnfln("Failed to get members in room to remove extra members: %v", err) - } else { - members = membersResp.Joined - delete(members, portal.bridge.Bot.UserID) - delete(members, portal.bridge.user.MXID) - } - } - portal.zlog.Debug(). - Int("chat_info_member_count", len(chatInfo.Members)). - Int("room_member_count", len(members)). - Msg("Syncing participants") - for _, member := range chatInfo.Members { - puppet := portal.bridge.GetPuppetByLocalID(member) - puppet.Sync() - memberIDs = append(memberIDs, puppet.MXID) - if portal.MXID != "" { - err := puppet.Intent.EnsureJoined(portal.MXID) - if err != nil { - portal.log.Warnfln("Failed to make puppet of %s join %s: %v", member, portal.MXID, err) - } - } - if members != nil { - delete(members, puppet.MXID) - } - } - if members != nil && !portal.bridge.Config.Bridge.Relay.Enabled { - for userID := range members { - portal.log.Debugfln("Removing %s as they don't seem to be in the group anymore", userID) - _, err := portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ - Reason: "user is no longer in group", - UserID: userID, - }) - if err != nil { - portal.log.Errorfln("Failed to remove %s: %v", userID, err) - } - } - } - return memberIDs -} - -func (portal *Portal) UpdateName(name string, intent *appservice.IntentAPI) *id.EventID { - if portal.Name != name || intent != nil { - if intent == nil { - intent = portal.MainIntent() - } - resp, err := intent.SetRoomName(portal.MXID, name) - if mainIntent := portal.MainIntent(); errors.Is(err, mautrix.MForbidden) && intent != mainIntent { - resp, err = mainIntent.SetRoomName(portal.MXID, name) - } - if err != nil { - portal.log.Warnln("Failed to set room name:", err) - } else { - portal.Name = name - portal.UpdateBridgeInfo() - return &resp.EventID - } - } - return nil -} - -func (portal *Portal) SyncWithInfo(chatInfo *imessage.ChatInfo) { - portal.zlog.Debug().Interface("chat_info", chatInfo).Msg("Syncing with chat info") - update := false - if chatInfo.ThreadID != "" && chatInfo.ThreadID != portal.ThreadID { - portal.log.Infofln("Found portal thread ID in sync: %s (prev: %s)", chatInfo.ThreadID, portal.ThreadID) - portal.ThreadID = chatInfo.ThreadID - update = true - } - if len(chatInfo.DisplayName) > 0 { - update = portal.UpdateName(chatInfo.DisplayName, nil) != nil || update - } - if !portal.IsPrivateChat() && chatInfo.Members != nil { - portal.SyncParticipants(chatInfo) - } - if update { - portal.Update(nil) - portal.UpdateBridgeInfo() - } -} - -func (portal *Portal) ensureUserInvited(user *User) { - user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) -} - -func (portal *Portal) Sync(backfill bool) { - if len(portal.MXID) == 0 { - portal.log.Infoln("Creating Matrix room due to sync") - err := portal.CreateMatrixRoom(nil, nil) - if err != nil { - portal.log.Errorln("Failed to create portal room:", err) - } - return - } - - portal.ensureUserInvited(portal.bridge.user) - portal.addToSpace(portal.bridge.user) - - pls, err := portal.MainIntent().PowerLevels(portal.MXID) - if err != nil { - portal.zlog.Warn().Err(err).Msg("Failed to get power levels") - } else if portal.updatePowerLevels(pls) { - resp, err := portal.MainIntent().SetPowerLevels(portal.MXID, pls) - if err != nil { - portal.zlog.Warn().Err(err).Msg("Failed to update power levels") - } else { - portal.zlog.Debug().Str("event_id", resp.EventID.String()).Msg("Updated power levels") - } - } - - if !portal.IsPrivateChat() { - chatInfo, err := portal.bridge.IM.GetChatInfo(portal.GUID, portal.ThreadID) - if err != nil { - portal.log.Errorln("Failed to get chat info:", err) - } - if chatInfo != nil { - portal.SyncWithInfo(chatInfo) - } else { - portal.log.Warnln("Didn't get any chat info") - } - - avatar, err := portal.bridge.IM.GetGroupAvatar(portal.GUID) - if err != nil { - portal.log.Warnln("Failed to get avatar:", err) - } else if avatar != nil { - portal.UpdateAvatar(avatar, portal.MainIntent()) - } - } else { - puppet := portal.bridge.GetPuppetByLocalID(portal.Identifier.LocalID) - puppet.Sync() - } - - if backfill && portal.bridge.Config.Bridge.Backfill.Enable { - portal.lockBackfill() - portal.forwardBackfill() - portal.unlockBackfill() - } -} - -type CustomReadReceipt struct { - Timestamp int64 `json:"ts,omitempty"` - DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"` -} - -type CustomReadMarkers struct { - mautrix.ReqSetReadMarkers - ReadExtra CustomReadReceipt `json:"com.beeper.read.extra"` - FullyReadExtra CustomReadReceipt `json:"com.beeper.fully_read.extra"` -} - -func (portal *Portal) markRead(intent *appservice.IntentAPI, eventID id.EventID, readAt time.Time) error { - if intent == nil { - return nil - } - - var extra CustomReadReceipt - if intent == portal.bridge.user.DoublePuppetIntent { - extra.DoublePuppetSource = portal.bridge.Name - } - if !readAt.IsZero() { - extra.Timestamp = readAt.UnixMilli() - } - content := CustomReadMarkers{ - ReqSetReadMarkers: mautrix.ReqSetReadMarkers{ - Read: eventID, - FullyRead: eventID, - }, - ReadExtra: extra, - FullyReadExtra: extra, - } - return intent.SetReadMarkers(portal.MXID, &content) -} - -func (portal *Portal) HandleiMessageReadReceipt(rr *imessage.ReadReceipt) { - if len(portal.MXID) == 0 { - return - } - var intent *appservice.IntentAPI - if rr.IsFromMe { - intent = portal.bridge.user.DoublePuppetIntent - } else if rr.SenderGUID == rr.ChatGUID { - intent = portal.MainIntent() - } else { - portal.log.Debugfln("Dropping unexpected read receipt %+v", *rr) - return - } - if intent == nil { - return - } - - if message := portal.bridge.DB.Message.GetLastByGUID(portal.GUID, rr.ReadUpTo); message != nil { - err := portal.markRead(intent, message.MXID, rr.ReadAt) - if err != nil { - portal.log.Warnln("Failed to send read receipt for %s from %s: %v", message.MXID, intent.UserID) - } - } else if tapback := portal.bridge.DB.Tapback.GetByTapbackGUID(portal.GUID, rr.ReadUpTo); tapback != nil { - err := portal.markRead(intent, tapback.MXID, rr.ReadAt) - if err != nil { - portal.log.Warnln("Failed to send read receipt for %s from %s: %v", tapback.MXID, intent.UserID) - } - } else { - portal.log.Debugfln("Dropping read receipt for %s: not found in db messages or tapbacks", rr.ReadUpTo) - } -} - -func (portal *Portal) handleMessageLoop() { - for { - var start time.Time - var thing string - select { - case msg := <-portal.Messages: - start = time.Now() - thing = "iMessage" - portal.HandleiMessage(msg) - case readReceipt := <-portal.ReadReceipts: - start = time.Now() - thing = "read receipt" - portal.HandleiMessageReadReceipt(readReceipt) - case <-portal.backfillStart: - thing = "backfill lock" - start = time.Now() - portal.backfillWait.Wait() - case evt := <-portal.MatrixMessages: - start = time.Now() - switch evt.Type { - case event.EventMessage, event.EventSticker: - thing = "Matrix message" - portal.HandleMatrixMessage(evt) - case event.EventRedaction: - thing = "Matrix redaction" - portal.HandleMatrixRedaction(evt) - case event.EventReaction: - thing = "Matrix reaction" - portal.HandleMatrixReaction(evt) - default: - thing = "unsupported Matrix event" - portal.log.Warnln("Unsupported event type %+v in portal message channel", evt.Type) - } - case msgStatus := <-portal.MessageStatuses: - start = time.Now() - thing = "message status" - portal.HandleiMessageSendMessageStatus(msgStatus) - } - portal.log.Debugfln( - "Handled %s in %s (queued: %di/%dr/%dm/%ds)", - thing, time.Since(start), - len(portal.messageDedup), len(portal.ReadReceipts), len(portal.MatrixMessages), len(portal.MessageStatuses), - ) - } -} - -func (portal *Portal) HandleiMessageSendMessageStatus(msgStatus *imessage.SendMessageStatus) { - if msgStatus.GUID == portal.bridge.pendingHackyTestGUID && portal.Identifier.LocalID == portal.bridge.Config.HackyStartupTest.Identifier { - portal.bridge.trackStartupTestPingStatus(msgStatus) - } - - var eventID id.EventID - if msg := portal.bridge.DB.Message.GetLastByGUID(portal.GUID, msgStatus.GUID); msg != nil { - eventID = msg.MXID - } else if tapback := portal.bridge.DB.Tapback.GetByTapbackGUID(portal.GUID, msgStatus.GUID); tapback != nil { - eventID = tapback.MXID - } else { - portal.log.Debugfln("Dropping send message status for %s: not found in db messages or tapbacks", msgStatus.GUID) - return - } - portal.log.Debugfln("Processing message status with type %s/%s for event %s/%s in %s/%s", msgStatus.Status, msgStatus.StatusCode, eventID, msgStatus.GUID, portal.MXID, msgStatus.ChatGUID) - switch msgStatus.Status { - case "delivered": - go portal.bridge.SendRawMessageCheckpoint(&status.MessageCheckpoint{ - EventID: eventID, - RoomID: portal.MXID, - Step: status.MsgStepRemote, - Timestamp: jsontime.UnixMilliNow(), - Status: status.MsgStatusDelivered, - ReportedBy: status.MsgReportedByBridge, - }) - if p := portal.GetDMPuppet(); p != nil { - go portal.sendSuccessMessageStatus(eventID, msgStatus.Service, msgStatus.ChatGUID, []id.UserID{p.MXID}) - } - case "sent": - portal.sendSuccessCheckpoint(eventID, msgStatus.Service, msgStatus.ChatGUID) - case "failed": - evt, err := portal.MainIntent().GetEvent(portal.MXID, eventID) - if err != nil { - portal.log.Warnfln("Failed to lookup event %s/%s %s/%s: %v", string(eventID), portal.MXID, msgStatus.GUID, msgStatus.ChatGUID, err) - return - } - errString := "internal error" - humanReadableError := "internal error" - if len(msgStatus.Message) != 0 { - humanReadableError = msgStatus.Message - if len(msgStatus.StatusCode) != 0 { - errString = fmt.Sprintf("%s: %s", msgStatus.StatusCode, msgStatus.Message) - } else { - errString = msgStatus.Message - } - } else if len(msgStatus.StatusCode) != 0 { - errString = msgStatus.StatusCode - } - if portal.bridge.isWarmingUp() { - errString = "warmingUp: " + errString - humanReadableError = "The bridge is still warming up - please wait" - } - portal.sendErrorMessage(evt, errors.New(errString), humanReadableError, true, status.MsgStatusPermFailure, msgStatus.ChatGUID) - default: - portal.log.Warnfln("Unrecognized message status type %s", msgStatus.Status) - } -} - -func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { - anyone := 0 - nope := 99 - return &event.PowerLevelsEventContent{ - UsersDefault: anyone, - EventsDefault: anyone, - RedactPtr: &anyone, - StateDefaultPtr: &nope, - BanPtr: &nope, - InvitePtr: &nope, - KickPtr: &nope, - Users: map[id.UserID]int{ - portal.MainIntent().UserID: 100, - portal.bridge.Bot.UserID: 100, - }, - Events: map[string]int{ - event.StateRoomName.Type: anyone, - event.StateRoomAvatar.Type: anyone, - event.StateTopic.Type: anyone, - }, - } -} - -func (portal *Portal) updatePowerLevels(pl *event.PowerLevelsEventContent) bool { - return pl.EnsureUserLevel(portal.bridge.Bot.UserID, 100) -} - -func (portal *Portal) getBridgeInfoStateKey() string { - key := fmt.Sprintf("%s://%s/%s", - bridgeInfoProto, strings.ToLower(portal.Identifier.Service), portal.GUID) - if len(key) > 255 { - key = fmt.Sprintf("%s://%s/%s", bridgeInfoProto, strings.ToLower(portal.Identifier.Service), sha256.Sum256([]byte(portal.GUID))) - } - return key -} - -func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) { - bridgeInfo := CustomBridgeInfoContent{ - BridgeEventContent: event.BridgeEventContent{ - BridgeBot: portal.bridge.Bot.UserID, - Creator: portal.MainIntent().UserID, - Protocol: event.BridgeInfoSection{ - ID: "imessage", - DisplayName: "iMessage", - AvatarURL: id.ContentURIString(portal.bridge.Config.AppService.Bot.Avatar), - ExternalURL: "https://support.apple.com/messages", - }, - }, - Channel: CustomBridgeInfoSection{ - BridgeInfoSection: event.BridgeInfoSection{ - ID: portal.Identifier.LocalID, - DisplayName: portal.Name, - AvatarURL: portal.AvatarURL.CUString(), - }, - - GUID: portal.GUID, - ThreadID: portal.ThreadID, - IsGroup: portal.Identifier.IsGroup, - Service: portal.Identifier.Service, - - SendStatusStart: portal.bridge.SendStatusStartTS, - TimeoutSeconds: portal.bridge.Config.Bridge.MaxHandleSeconds, - }, - } - if portal.Identifier.Service == "SMS" { - if portal.bridge.Config.IMessage.Platform == "android" { - bridgeInfo.Protocol.ID = "android-sms" - bridgeInfo.Protocol.DisplayName = "Android SMS" - bridgeInfo.Protocol.ExternalURL = "" - bridgeInfo.Channel.DeviceID = portal.bridge.Config.Bridge.DeviceID - } else { - bridgeInfo.Protocol.ID = "imessage-sms" - bridgeInfo.Protocol.DisplayName = "iMessage (SMS)" - } - } else if portal.bridge.Config.IMessage.Platform == "ios" { - bridgeInfo.Protocol.ID = "imessage-ios" - } else if portal.bridge.Config.IMessage.Platform == "mac-nosip" { - bridgeInfo.Protocol.ID = "imessage-nosip" - } else if portal.bridge.Config.IMessage.Platform == "bluebubbles" { - bridgeInfo.Protocol.ID = "imessagego" - } - return portal.getBridgeInfoStateKey(), bridgeInfo -} - -func (portal *Portal) UpdateBridgeInfo() { - if len(portal.MXID) == 0 { - portal.log.Debugln("Not updating bridge info: no Matrix room created") - return - } - portal.log.Debugln("Updating bridge info...") - intent := portal.MainIntent() - if portal.Encrypted && intent != portal.bridge.Bot && portal.IsPrivateChat() { - intent = portal.bridge.Bot - } - stateKey, content := portal.getBridgeInfo() - _, err := intent.SendStateEvent(portal.MXID, event.StateBridge, stateKey, content) - if err != nil { - portal.log.Warnln("Failed to update m.bridge:", err) - } - _, err = intent.SendStateEvent(portal.MXID, event.StateHalfShotBridge, stateKey, content) - if err != nil { - portal.log.Warnln("Failed to update uk.half-shot.bridge:", err) - } -} - -func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) { - evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1} - if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom { - evt.RotationPeriodMillis = rot.Milliseconds - evt.RotationPeriodMessages = rot.Messages - } - return -} - -func (portal *Portal) shouldSetDMRoomMetadata() bool { - return !portal.IsPrivateChat() || - portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" || - (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never") -} - -func (portal *Portal) getRoomCreateContent() *mautrix.ReqCreateRoom { - bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() - - initialState := []*event.Event{{ - Type: event.StatePowerLevels, - Content: event.Content{ - Parsed: portal.GetBasePowerLevels(), - }, - }, { - Type: event.StateBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }, { - // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - Type: event.StateHalfShotBridge, - Content: event.Content{Parsed: bridgeInfo}, - StateKey: &bridgeInfoStateKey, - }} - if portal.bridge.Config.Bridge.Encryption.Default { - initialState = append(initialState, &event.Event{ - Type: event.StateEncryption, - Content: event.Content{ - Parsed: portal.GetEncryptionEventContent(), - }, - }) - portal.Encrypted = true - } - if !portal.AvatarURL.IsEmpty() && portal.shouldSetDMRoomMetadata() { - initialState = append(initialState, &event.Event{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: event.RoomAvatarEventContent{URL: portal.AvatarURL}, - }, - }) - } - - var invite []id.UserID - - if portal.IsPrivateChat() { - invite = append(invite, portal.bridge.Bot.UserID) - } - - autoJoinInvites := portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry - if autoJoinInvites { - invite = append(invite, portal.bridge.user.MXID) - } - - creationContent := make(map[string]interface{}) - if !portal.bridge.Config.Bridge.FederateRooms { - creationContent["m.federate"] = false - } - req := &mautrix.ReqCreateRoom{ - Visibility: "private", - Name: portal.Name, - Invite: invite, - Preset: "private_chat", - IsDirect: portal.IsPrivateChat(), - InitialState: initialState, - CreationContent: creationContent, - RoomVersion: "11", - - BeeperAutoJoinInvites: autoJoinInvites, - } - if !portal.shouldSetDMRoomMetadata() { - req.Name = "" - } - return req -} - -func (portal *Portal) preCreateDMSync(profileOverride *ProfileOverride) { - puppet := portal.bridge.GetPuppetByLocalID(portal.Identifier.LocalID) - puppet.Sync() - if profileOverride != nil { - puppet.SyncWithProfileOverride(*profileOverride) - } - portal.Name = puppet.Displayname - portal.AvatarURL = puppet.AvatarURL - portal.AvatarHash = puppet.AvatarHash -} - -func (portal *Portal) CreateMatrixRoom(chatInfo *imessage.ChatInfo, profileOverride *ProfileOverride) error { - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - if len(portal.MXID) > 0 { - return nil - } - - intent := portal.MainIntent() - err := intent.EnsureRegistered() - if err != nil { - return err - } - - if chatInfo == nil { - portal.log.Debugln("Getting chat info to create Matrix room") - chatInfo, err = portal.bridge.IM.GetChatInfo(portal.GUID, portal.ThreadID) - if err != nil && !portal.IsPrivateChat() { - // If there's no chat info for a group, it probably doesn't exist, and we shouldn't auto-create a Matrix room for it. - return fmt.Errorf("failed to get chat info: %w", err) - } - } - if chatInfo != nil { - portal.Name = chatInfo.DisplayName - portal.ThreadID = chatInfo.ThreadID - } else { - portal.log.Warnln("Didn't get any chat info") - } - - if portal.IsPrivateChat() { - portal.preCreateDMSync(profileOverride) - } else { - avatar, err := portal.bridge.IM.GetGroupAvatar(portal.GUID) - if err != nil { - portal.log.Warnln("Failed to get avatar:", err) - } else if avatar != nil { - portal.UpdateAvatar(avatar, portal.MainIntent()) - } - } - - req := portal.getRoomCreateContent() - if req.BeeperAutoJoinInvites && !portal.IsPrivateChat() { - req.Invite = append(req.Invite, portal.SyncParticipants(chatInfo)...) - } - resp, err := intent.CreateRoom(req) - if err != nil { - return err - } - doBackfill := portal.bridge.Config.Bridge.Backfill.Enable && portal.bridge.Config.Bridge.Backfill.InitialLimit > 0 - if doBackfill { - portal.log.Debugln("Locking backfill (create)") - portal.lockBackfill() - } - portal.MXID = resp.RoomID - portal.log.Debugln("Storing created room ID", portal.MXID, "in database") - portal.Update(nil) - portal.bridge.portalsLock.Lock() - portal.bridge.portalsByMXID[portal.MXID] = portal - portal.bridge.portalsLock.Unlock() - - portal.log.Debugln("Updating state store with initial memberships") - inviteeMembership := event.MembershipInvite - if req.BeeperAutoJoinInvites { - inviteeMembership = event.MembershipJoin - } - for _, user := range req.Invite { - portal.bridge.StateStore.SetMembership(portal.MXID, user, inviteeMembership) - } - - if portal.Encrypted && !req.BeeperAutoJoinInvites { - portal.log.Debugln("Ensuring bridge bot is joined to portal") - err = portal.bridge.Bot.EnsureJoined(portal.MXID) - if err != nil { - portal.log.Errorln("Failed to join created portal with bridge bot for e2be:", err) - } - } - - if !req.BeeperAutoJoinInvites { - portal.ensureUserInvited(portal.bridge.user) - } - portal.addToSpace(portal.bridge.user) - - if !portal.IsPrivateChat() { - if !req.BeeperAutoJoinInvites { - portal.log.Debugln("New portal is group chat, syncing participants") - portal.SyncParticipants(chatInfo) - } - } else { - portal.bridge.user.UpdateDirectChats(map[id.UserID][]id.RoomID{portal.GetDMPuppet().MXID: {portal.MXID}}) - } - if portal.bridge.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { - firstEventResp, err := portal.MainIntent().SendMessageEvent(portal.MXID, PortalCreationDummyEvent, struct{}{}) - if err != nil { - portal.log.Errorln("Failed to send dummy event to mark portal creation:", err) - } else { - portal.FirstEventID = firstEventResp.EventID - portal.Update(nil) - } - } - if doBackfill { - go func() { - portal.log.Debugln("Starting initial backfill") - portal.forwardBackfill() - portal.log.Debugln("Unlocking backfill (create)") - portal.unlockBackfill() - }() - } - portal.log.Debugln("Finished creating Matrix room") - - if portal.bridge.IM.Capabilities().ChatBridgeResult { - portal.bridge.IM.SendChatBridgeResult(portal.GUID, portal.MXID) - } - - return nil -} - -func (portal *Portal) addToSpace(user *User) { - spaceID := user.GetSpaceRoom() - if len(spaceID) == 0 || portal.InSpace { - return - } - _, err := portal.bridge.Bot.SendStateEvent(spaceID, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{ - Via: []string{portal.bridge.Config.Homeserver.Domain}, - }) - if err != nil { - portal.log.Errorfln("Failed to add room to %s's personal filtering space (%s): %v", user.MXID, spaceID, err) - } else { - portal.log.Debugfln("Added room to %s's personal filtering space (%s)", user.MXID, spaceID) - portal.InSpace = true - portal.Update(nil) - } -} - -func (portal *Portal) IsPrivateChat() bool { - return !portal.Identifier.IsGroup -} - -func (portal *Portal) GetDMPuppet() *Puppet { - if portal.IsPrivateChat() { - return portal.bridge.GetPuppetByLocalID(portal.Identifier.LocalID) - } - return nil -} - -func (portal *Portal) MainIntent() *appservice.IntentAPI { - if portal.IsPrivateChat() { - return portal.GetDMPuppet().Intent - } - return portal.bridge.Bot -} - -func (portal *Portal) sendMainIntentMessage(content interface{}) (*mautrix.RespSendEvent, error) { - return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, map[string]interface{}{}, 0) -} - -func (portal *Portal) encrypt(intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) { - if portal.Encrypted && portal.bridge.Crypto != nil { - intent.AddDoublePuppetValue(content) - handle, ok := content.Raw[bridgeInfoHandle].(string) - err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, content) - if err != nil { - return eventType, fmt.Errorf("failed to encrypt event: %w", err) - } - eventType = event.EventEncrypted - if ok && content.Raw == nil { - content.Raw = map[string]any{ - bridgeInfoHandle: handle, - } - } - } - return eventType, nil -} - -func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content interface{}, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { - wrappedContent := &event.Content{Parsed: content} - wrappedContent.Raw = extraContent - var err error - eventType, err = portal.encrypt(intent, wrappedContent, eventType) - if err != nil { - return nil, err - } - - _, _ = intent.UserTyping(portal.MXID, false, 0) - if timestamp == 0 { - return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent) - } else { - return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp) - } -} - -func (portal *Portal) encryptFile(data []byte, mimeType string) (string, *event.EncryptedFileInfo) { - if !portal.Encrypted { - return mimeType, nil - } - - file := &event.EncryptedFileInfo{ - EncryptedFile: *attachment.NewEncryptedFile(), - URL: "", - } - file.EncryptInPlace(data) - return "application/octet-stream", file -} - -func (portal *Portal) sendErrorMessage(evt *event.Event, rootErr error, humanReadableError string, isCertain bool, checkpointStatus status.MessageCheckpointStatus, handle string) { - portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, rootErr, checkpointStatus, 0) - - possibility := "may not have been" - if isCertain { - possibility = "was not" - } - - errorIntent := portal.bridge.Bot - if !portal.Encrypted { - // Bridge bot isn't present in unencrypted DMs - errorIntent = portal.MainIntent() - } - - if portal.bridge.Config.Bridge.MessageStatusEvents { - reason := event.MessageStatusGenericError - msgStatusCode := event.MessageStatusRetriable - switch checkpointStatus { - case status.MsgStatusUnsupported: - reason = event.MessageStatusUnsupported - msgStatusCode = event.MessageStatusFail - case status.MsgStatusTimeout: - reason = event.MessageStatusTooOld - } - - content := event.BeeperMessageStatusEventContent{ - Network: portal.getBridgeInfoStateKey(), - RelatesTo: event.RelatesTo{ - Type: event.RelReference, - EventID: evt.ID, - }, - Reason: reason, - Status: msgStatusCode, - Error: rootErr.Error(), - Message: humanReadableError, - } - extraContent := map[string]any{} - if handle != "" && portal.bridge.IM.Capabilities().ContactChatMerging { - extraContent[bridgeInfoHandle] = handle - content.MutateEventKey = bridgeInfoHandle - } - _, err := errorIntent.SendMessageEvent(portal.MXID, event.BeeperMessageStatus, &event.Content{ - Parsed: &content, - Raw: extraContent, - }) - if err != nil { - portal.log.Warnln("Failed to send message send status event:", err) - return - } - } - if portal.bridge.Config.Bridge.SendErrorNotices { - _, err := portal.sendMessage(errorIntent, event.EventMessage, event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("\u26a0 Your message %s bridged: %v", possibility, rootErr), - }, map[string]interface{}{}, 0) - if err != nil { - portal.log.Warnfln("Failed to send bridging error message:", err) - return - } - } -} - -func (portal *Portal) sendDeliveryReceipt(eventID id.EventID, service, handle string, sendCheckpoint bool) { - if portal.bridge.Config.Bridge.DeliveryReceipts { - err := portal.bridge.Bot.MarkRead(portal.MXID, eventID) - if err != nil { - portal.log.Debugfln("Failed to send delivery receipt for %s: %v", eventID, err) - } - } - - if sendCheckpoint { - portal.sendSuccessCheckpoint(eventID, service, handle) - } -} - -func (portal *Portal) sendSuccessCheckpoint(eventID id.EventID, service, handle string) { - // We don't have access to the entire event, so we are omitting some - // metadata here. However, that metadata can be inferred from previous - // checkpoints. - checkpoint := status.MessageCheckpoint{ - EventID: eventID, - RoomID: portal.MXID, - Step: status.MsgStepRemote, - Timestamp: jsontime.UnixMilliNow(), - Status: status.MsgStatusSuccess, - ReportedBy: status.MsgReportedByBridge, - } - go func() { - portal.bridge.SendRawMessageCheckpoint(&checkpoint) - if (portal.Identifier.IsGroup || portal.Identifier.Service == "SMS") && portal.bridge.IM.Capabilities().DeliveredStatus { - portal.bridge.SendRawMessageCheckpoint(&status.MessageCheckpoint{ - EventID: eventID, - RoomID: portal.MXID, - Step: status.MsgStepRemote, - Timestamp: jsontime.UnixMilliNow(), - Status: status.MsgStatusDelivered, - ReportedBy: status.MsgReportedByBridge, - Info: "fake group delivered status", - }) - } - }() - - portal.sendSuccessMessageStatus(eventID, service, handle, []id.UserID{}) -} - -func (portal *Portal) sendSuccessMessageStatus(eventID id.EventID, service, handle string, deliveredTo []id.UserID) { - if !portal.bridge.Config.Bridge.MessageStatusEvents { - return - } - - mainContent := &event.BeeperMessageStatusEventContent{ - Network: portal.getBridgeInfoStateKey(), - RelatesTo: event.RelatesTo{ - Type: event.RelReference, - EventID: eventID, - }, - Status: event.MessageStatusSuccess, - } - - if !portal.Identifier.IsGroup && portal.Identifier.Service == "iMessage" && portal.bridge.IM.Capabilities().DeliveredStatus { - // This is an iMessage DM, then we want to include the list of users - // that the message has been delivered to. - mainContent.DeliveredToUsers = &deliveredTo - } - - var extraContent map[string]any - if portal.bridge.IM.Capabilities().ContactChatMerging { - extraContent = map[string]any{ - bridgeInfoService: service, - bridgeInfoHandle: handle, - } - mainContent.MutateEventKey = bridgeInfoHandle - } - content := &event.Content{ - Parsed: mainContent, - Raw: extraContent, - } - - statusIntent := portal.bridge.Bot - if !portal.Encrypted { - statusIntent = portal.MainIntent() - } - _, err := statusIntent.SendMessageEvent(portal.MXID, event.BeeperMessageStatus, content) - if err != nil { - portal.log.Warnln("Failed to send message send status event:", err) - } -} - -func (portal *Portal) addDedup(eventID id.EventID, body string) { - if portal.messageDedup != nil { - portal.messageDedupLock.Lock() - portal.messageDedup[strings.TrimSpace(body)] = SentMessage{ - EventID: eventID, - // Set the timestamp to a bit before now to make sure the deduplication catches it properly - Timestamp: time.Now().Add(-10 * time.Second), - } - portal.messageDedupLock.Unlock() - } -} - -func (portal *Portal) shouldHandleMessage(evt *event.Event) error { - if portal.bridge.Config.Bridge.MaxHandleSeconds == 0 { - return nil - } - if time.Since(time.UnixMilli(evt.Timestamp)) < time.Duration(portal.bridge.Config.Bridge.MaxHandleSeconds)*time.Second { - return nil - } - - return fmt.Errorf("message is too old (over %d seconds)", portal.bridge.Config.Bridge.MaxHandleSeconds) -} - -func (portal *Portal) addRelaybotFormat(sender id.UserID, content *event.MessageEventContent) bool { - member := portal.MainIntent().Member(portal.MXID, sender) - if member == nil { - member = &event.MemberEventContent{} - } - - data, err := portal.bridge.Config.Bridge.Relay.FormatMessage(content, sender, *member) - if err != nil { - portal.log.Errorln("Failed to apply relaybot format:", err) - } - content.Body = data - return true -} - -func (portal *Portal) getTargetGUID(thing string, eventID id.EventID, targetGUID string) string { - if portal.IsPrivateChat() && portal.bridge.IM.Capabilities().ContactChatMerging { - if targetGUID != "" { - portal.log.Debugfln("Sending Matrix %s %s to %s (target guid)", thing, eventID, targetGUID) - return targetGUID - } else if portal.LastSeenHandle != "" && portal.LastSeenHandle != portal.GUID { - portal.log.Debugfln("Sending Matrix %s %s to %s (last seen handle)", thing, eventID, portal.LastSeenHandle) - return portal.LastSeenHandle - } - portal.log.Debugfln("Sending Matrix %s %s to %s (portal guid)", thing, eventID, portal.GUID) - } - return portal.GUID -} - -func (portal *Portal) HandleMatrixMessage(evt *event.Event) { - msg, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok { - // TODO log - return - } - portal.log.Debugln("Starting handling Matrix message", evt.ID) - - var messageReplyID string - var messageReplyPart int - replyToID := msg.RelatesTo.GetReplyTo() - if len(replyToID) > 0 { - imsg := portal.bridge.DB.Message.GetByMXID(replyToID) - if imsg != nil { - messageReplyID = imsg.GUID - messageReplyPart = imsg.Part - } - } - - if err := portal.shouldHandleMessage(evt); err != nil { - portal.log.Debug(err) - portal.sendErrorMessage(evt, err, err.Error(), true, status.MsgStatusTimeout, "") - return - } - - editEventID := msg.RelatesTo.GetReplaceID() - if editEventID != "" && msg.NewContent != nil { - msg = msg.NewContent - } - - var err error - var resp *imessage.SendResponse - var wasEdit bool - - if editEventID != "" { - wasEdit = true - if !portal.bridge.IM.Capabilities().EditMessages { - portal.zlog.Err(errors.ErrUnsupported).Msg("Bridge doesn't support editing messages!") - return - } - - editedMessage := portal.bridge.DB.Message.GetByMXID(editEventID) - if editedMessage == nil { - portal.zlog.Error().Msg("Failed to get message by MXID") - return - } - - if portal.bridge.IM.(imessage.VenturaFeatures) != nil { - resp, err = portal.bridge.IM.(imessage.VenturaFeatures).EditMessage(portal.getTargetGUID("message edit", evt.ID, editedMessage.HandleGUID), editedMessage.GUID, msg.Body, editedMessage.Part) - } else { - portal.zlog.Err(errors.ErrUnsupported).Msg("Bridge didn't implment EditMessage!") - return - } - } - - var imessageRichLink *imessage.RichLink - if portal.bridge.IM.Capabilities().RichLinks { - imessageRichLink = portal.convertURLPreviewToIMessage(evt) - } - metadata, _ := evt.Content.Raw["com.beeper.message_metadata"].(imessage.MessageMetadata) - - if (msg.MsgType == event.MsgText || msg.MsgType == event.MsgNotice || msg.MsgType == event.MsgEmote) && !wasEdit { - if evt.Sender != portal.bridge.user.MXID { - portal.addRelaybotFormat(evt.Sender, msg) - if len(msg.Body) == 0 { - return - } - } else if msg.MsgType == event.MsgEmote { - msg.Body = "/me " + msg.Body - } - portal.addDedup(evt.ID, msg.Body) - resp, err = portal.bridge.IM.SendMessage(portal.getTargetGUID("text message", evt.ID, ""), msg.Body, messageReplyID, messageReplyPart, imessageRichLink, metadata) - } else if len(msg.URL) > 0 || msg.File != nil { - resp, err = portal.handleMatrixMedia(msg, evt, messageReplyID, messageReplyPart, metadata) - } - - if err != nil { - portal.log.Errorln("Error sending to iMessage:", err) - statusCode := status.MsgStatusPermFailure - certain := false - if errors.Is(err, ipc.ErrSizeLimitExceeded) { - certain = true - statusCode = status.MsgStatusUnsupported - } - var ipcErr ipc.Error - if errors.As(err, &ipcErr) { - certain = true - err = errors.New(ipcErr.Message) - switch ipcErr.Code { - case ipc.ErrUnsupportedError.Code: - statusCode = status.MsgStatusUnsupported - case ipc.ErrTimeoutError.Code: - statusCode = status.MsgStatusTimeout - } - } - portal.sendErrorMessage(evt, err, ipcErr.Message, certain, statusCode, "") - } else if resp != nil { - dbMessage := portal.bridge.DB.Message.New() - dbMessage.PortalGUID = portal.GUID - dbMessage.HandleGUID = resp.ChatGUID - dbMessage.GUID = resp.GUID - dbMessage.MXID = evt.ID - dbMessage.Timestamp = resp.Time.UnixMilli() - portal.sendDeliveryReceipt(evt.ID, resp.Service, resp.ChatGUID, !portal.bridge.IM.Capabilities().MessageStatusCheckpoints) - dbMessage.Insert(nil) - portal.log.Debugln("Handled Matrix message", evt.ID, "->", resp.GUID) - } else { - portal.log.Debugln("Handled Matrix message", evt.ID, "(waiting for echo)") - } -} - -func (portal *Portal) handleMatrixMedia(msg *event.MessageEventContent, evt *event.Event, messageReplyID string, messageReplyPart int, metadata imessage.MessageMetadata) (*imessage.SendResponse, error) { - var url id.ContentURI - var file *event.EncryptedFileInfo - var err error - if msg.File != nil { - file = msg.File - url, err = msg.File.URL.Parse() - } else { - url, err = msg.URL.Parse() - } - if err != nil { - portal.sendErrorMessage(evt, fmt.Errorf("malformed attachment URL: %w", err), "malformed attachment URL", true, status.MsgStatusPermFailure, "") - portal.log.Warnfln("Malformed content URI in %s: %v", evt.ID, err) - return nil, nil - } - var caption string - filename := msg.Body - if msg.FileName != "" && msg.FileName != msg.Body { - filename = msg.FileName - caption = msg.Body - } - portal.addDedup(evt.ID, filename) - if evt.Sender != portal.bridge.user.MXID { - portal.addRelaybotFormat(evt.Sender, msg) - caption = msg.Body - } - - mediaViewerMinSize := portal.bridge.Config.Bridge.MediaViewer.IMMinSize - if portal.Identifier.Service == "SMS" { - mediaViewerMinSize = portal.bridge.Config.Bridge.MediaViewer.SMSMinSize - } - if len(portal.bridge.Config.Bridge.MediaViewer.URL) > 0 && mediaViewerMinSize > 0 && msg.Info != nil && msg.Info.Size >= mediaViewerMinSize { - // SMS chat and the file is too big, make a media viewer URL - var mediaURL string - mediaURL, err = portal.bridge.createMediaViewerURL(&evt.Content) - if err != nil { - return nil, fmt.Errorf("failed to create media viewer URL: %w", err) - } - if len(caption) > 0 { - caption += ": " - } - caption += fmt.Sprintf(portal.bridge.Config.Bridge.MediaViewer.Template, mediaURL) - - // Check if there's a thumbnail we can bridge. - // If not, just send the link. If yes, send the thumbnail and the link as a caption. - // TODO: we could try to compress images to fit even if the provided thumbnail is too big. - var hasUsableThumbnail bool - if msg.Info.ThumbnailInfo != nil && msg.Info.ThumbnailInfo.Size < mediaViewerMinSize { - file = msg.Info.ThumbnailFile - if file != nil { - url, err = file.URL.Parse() - } else { - url, err = msg.Info.ThumbnailURL.Parse() - } - hasUsableThumbnail = err == nil && !url.IsEmpty() && portal.bridge.IM.Capabilities().SendCaptions - } - if !hasUsableThumbnail { - portal.addDedup(evt.ID, caption) - return portal.bridge.IM.SendMessage(portal.getTargetGUID("media message (+viewer)", evt.ID, ""), caption, messageReplyID, messageReplyPart, nil, metadata) - } - } - - return portal.handleMatrixMediaDirect(url, file, filename, caption, evt, messageReplyID, messageReplyPart, metadata) -} - -func (portal *Portal) handleMatrixMediaDirect(url id.ContentURI, file *event.EncryptedFileInfo, filename, caption string, evt *event.Event, messageReplyID string, messageReplyPart int, metadata imessage.MessageMetadata) (resp *imessage.SendResponse, err error) { - var data []byte - data, err = portal.MainIntent().DownloadBytes(url) - if err != nil { - portal.sendErrorMessage(evt, fmt.Errorf("failed to download attachment: %w", err), "failed to download attachment", true, status.MsgStatusPermFailure, "") - portal.log.Errorfln("Failed to download media in %s: %v", evt.ID, err) - return - } - if file != nil { - err = file.DecryptInPlace(data) - if err != nil { - portal.sendErrorMessage(evt, fmt.Errorf("failed to decrypt attachment: %w", err), "failed to decrypt attachment", true, status.MsgStatusPermFailure, "") - portal.log.Errorfln("Failed to decrypt media in %s: %v", evt.ID, err) - return - } - } - - var dir, filePath string - dir, filePath, err = imessage.SendFilePrepare(filename, data) - if err != nil { - portal.log.Errorfln("failed to prepare to send file: %w", err) - return - } - mimeType := mimetype.Detect(data).String() - isVoiceMemo := false - _, isMSC3245Voice := evt.Content.Raw["org.matrix.msc3245.voice"] - - // Only convert when sending to iMessage. SMS users probably don't want CAF. - if portal.Identifier.Service == "iMessage" && isMSC3245Voice && strings.HasPrefix(mimeType, "audio/") { - filePath, err = ffmpeg.ConvertPath(context.TODO(), filePath, ".caf", []string{}, []string{"-c:a", "libopus"}, false) - mimeType = "audio/x-caf" - isVoiceMemo = true - filename = filepath.Base(filePath) - - if err != nil { - log.Errorfln("Failed to transcode voice message to CAF. Error: %w", err) - return - } - } - - resp, err = portal.bridge.IM.SendFile(portal.getTargetGUID("media message", evt.ID, ""), caption, filename, filePath, messageReplyID, messageReplyPart, mimeType, isVoiceMemo, metadata) - portal.bridge.IM.SendFileCleanup(dir) - return -} - -func (portal *Portal) sendUnsupportedCheckpoint(evt *event.Event, step status.MessageCheckpointStep, err error) { - portal.log.Errorf("Sending unsupported checkpoint for %s: %+v", evt.ID, err) - portal.bridge.SendMessageCheckpoint(evt, step, err, status.MsgStatusUnsupported, 0) - - if portal.bridge.Config.Bridge.MessageStatusEvents { - content := event.BeeperMessageStatusEventContent{ - Network: portal.getBridgeInfoStateKey(), - RelatesTo: event.RelatesTo{ - Type: event.RelReference, - EventID: evt.ID, - }, - Status: event.MessageStatusFail, - Reason: event.MessageStatusUnsupported, - Error: err.Error(), - } - - errorIntent := portal.bridge.Bot - if !portal.Encrypted { - errorIntent = portal.MainIntent() - } - _, sendErr := errorIntent.SendMessageEvent(portal.MXID, event.BeeperMessageStatus, &content) - if sendErr != nil { - portal.log.Warnln("Failed to send message send status event:", sendErr) - } - } -} - -func (portal *Portal) HandleMatrixReadReceipt(user bridge.User, eventID id.EventID, receipt event.ReadReceipt) { - if user.GetMXID() != portal.bridge.user.MXID { - return - } - - if message := portal.bridge.DB.Message.GetByMXID(eventID); message != nil { - portal.log.Debugfln("Marking %s/%s as read", message.GUID, message.MXID) - err := portal.bridge.IM.SendReadReceipt(portal.getTargetGUID("read receipt to message", eventID, message.HandleGUID), message.GUID) - if err != nil { - portal.log.Warnln("Error marking message as read:", err) - } - } else if tapback := portal.bridge.DB.Tapback.GetByMXID(eventID); tapback != nil { - portal.log.Debugfln("Marking %s/%s as read in %s", tapback.GUID, tapback.MXID) - err := portal.bridge.IM.SendReadReceipt(portal.getTargetGUID("read receipt to tapback", eventID, tapback.HandleGUID), tapback.GUID) - if err != nil { - portal.log.Warnln("Error marking tapback as read:", err) - } - } -} - -const typingNotificationsTemporarilyDisabled = false - -func (portal *Portal) HandleMatrixTyping(userIDs []id.UserID) { - if portal.Identifier.Service == "SMS" { - return - } else if typingNotificationsTemporarilyDisabled { - portal.log.Debugfln("Dropping typing notification %v", userIDs) - return - } - portal.typingLock.Lock() - defer portal.typingLock.Unlock() - - isTyping := false - for _, userID := range userIDs { - if userID == portal.bridge.user.MXID { - isTyping = true - break - } - } - if isTyping != portal.userIsTyping { - portal.userIsTyping = isTyping - if !isTyping { - portal.log.Debugfln("Sending typing stop notification") - } else { - portal.log.Debugfln("Sending typing start notification") - } - err := portal.bridge.IM.SendTypingNotification(portal.getTargetGUID("typing notification", "", ""), isTyping) - if err != nil { - portal.log.Warnfln("Failed to bridge typing status change: %v", err) - } else { - portal.log.Debugfln("Typing update sent") - } - } -} - -func (portal *Portal) HandleMatrixReaction(evt *event.Event) { - if !portal.bridge.IM.Capabilities().SendTapbacks { - portal.sendUnsupportedCheckpoint(evt, status.MsgStepRemote, errors.New("reactions are not supported")) - return - } - portal.log.Debugln("Starting handling of Matrix reaction", evt.ID) - - if err := portal.shouldHandleMessage(evt); err != nil { - portal.log.Debug(err) - portal.sendErrorMessage(evt, err, err.Error(), true, status.MsgStatusTimeout, "") - return - } - - doError := func(msg string, args ...any) { - portal.log.Errorfln(msg, args...) - portal.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, fmt.Errorf(msg, args...), true, 0) - } - - if reaction, ok := evt.Content.Parsed.(*event.ReactionEventContent); !ok || reaction.RelatesTo.Type != event.RelAnnotation { - doError("Ignoring reaction %s due to unknown m.relates_to data", evt.ID) - } else if tapbackType := imessage.TapbackFromEmoji(reaction.RelatesTo.Key); tapbackType == 0 { - doError("Unknown reaction type %s in %s", reaction.RelatesTo.Key, reaction.RelatesTo.EventID) - } else if target := portal.bridge.DB.Message.GetByMXID(reaction.RelatesTo.EventID); target == nil { - doError("Unknown reaction target %s", reaction.RelatesTo.EventID) - } else if existing := portal.bridge.DB.Tapback.GetByGUID(portal.GUID, target.GUID, target.Part, ""); existing != nil && existing.Type == tapbackType { - doError("Ignoring outgoing tapback to %s/%s: type is same", reaction.RelatesTo.EventID, target.GUID) - } else { - targetChatGUID := portal.getTargetGUID("reaction", evt.ID, target.HandleGUID) - if resp, err := portal.bridge.IM.SendTapback(targetChatGUID, target.GUID, target.Part, tapbackType, false); err != nil { - doError("Failed to send tapback %d to %s: %v", tapbackType, target.GUID, err) - } else if existing == nil { - // TODO should timestamp be stored? - portal.log.Debugfln("Handled Matrix reaction %s into new iMessage tapback %s", evt.ID, resp.GUID) - if !portal.bridge.IM.Capabilities().MessageStatusCheckpoints { - portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0) - } - tapback := portal.bridge.DB.Tapback.New() - tapback.PortalGUID = portal.GUID - tapback.HandleGUID = resp.ChatGUID - tapback.GUID = resp.GUID - tapback.MessageGUID = target.GUID - tapback.MessagePart = target.Part - tapback.Type = tapbackType - tapback.MXID = evt.ID - tapback.Insert(nil) - } else { - portal.log.Debugfln("Handled Matrix reaction %s into iMessage tapback %s, replacing old %s", evt.ID, resp.GUID, existing.MXID) - if !portal.bridge.IM.Capabilities().MessageStatusCheckpoints { - portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0) - } - _, err = portal.MainIntent().RedactEvent(portal.MXID, existing.MXID) - if err != nil { - portal.log.Warnfln("Failed to redact old tapback %s to %s: %v", existing.MXID, target.MXID, err) - } - existing.GUID = resp.GUID - existing.Type = tapbackType - existing.MXID = evt.ID - existing.Update() - } - } -} - -func (portal *Portal) HandleMatrixRedaction(evt *event.Event) { - if !portal.bridge.IM.Capabilities().SendTapbacks { - portal.sendUnsupportedCheckpoint(evt, status.MsgStepRemote, errors.New("reactions are not supported")) - return - } - - if err := portal.shouldHandleMessage(evt); err != nil { - portal.log.Debug(err) - portal.sendErrorMessage(evt, err, err.Error(), true, status.MsgStatusTimeout, "") - return - } - - redactedTapback := portal.bridge.DB.Tapback.GetByMXID(evt.Redacts) - if redactedTapback != nil { - portal.log.Debugln("Starting handling of Matrix redaction of tapback", evt.ID) - redactedTapback.Delete() - _, err := portal.bridge.IM.SendTapback(portal.getTargetGUID("tapback redaction", evt.ID, redactedTapback.HandleGUID), redactedTapback.MessageGUID, redactedTapback.MessagePart, redactedTapback.Type, true) - if err != nil { - portal.log.Errorfln("Failed to send removal of tapback %d to %s/%d: %v", redactedTapback.Type, redactedTapback.MessageGUID, redactedTapback.MessagePart, err) - portal.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, err, true, 0) - } else { - portal.log.Debugfln("Handled Matrix redaction %s of iMessage tapback %d to %s/%d", evt.ID, redactedTapback.Type, redactedTapback.MessageGUID, redactedTapback.MessagePart) - if !portal.bridge.IM.Capabilities().MessageStatusCheckpoints { - portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0) - } - } - return - } - - if !portal.bridge.IM.Capabilities().UnsendMessages { - portal.sendUnsupportedCheckpoint(evt, status.MsgStepRemote, errors.New("redactions of messages are not supported")) - return - } - - redactedText := portal.bridge.DB.Message.GetByMXID(evt.Redacts) - if redactedText != nil { - portal.log.Debugln("Starting handling of Matrix redaction of text", evt.ID) - redactedText.Delete() - - var err error - if portal.bridge.IM.(imessage.VenturaFeatures) != nil { - _, err = portal.bridge.IM.(imessage.VenturaFeatures).UnsendMessage(portal.getTargetGUID("message redaction", evt.ID, redactedText.HandleGUID), redactedText.GUID, redactedText.Part) - } else { - portal.zlog.Err(errors.ErrUnsupported).Msg("Bridge didn't implment UnsendMessage!") - return - } - - //_, err := portal.bridge.IM.UnsendMessage(portal.getTargetGUID("message redaction", evt.ID, redactedText.HandleGUID), redactedText.GUID, redactedText.Part) - if err != nil { - portal.log.Errorfln("Failed to send unsend of message %s/%d: %v", redactedText.GUID, redactedText.Part, err) - portal.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, err, true, 0) - } else { - portal.log.Debugfln("Handled Matrix redaction %s of iMessage message %s/%d", evt.ID, redactedText.GUID, redactedText.Part) - if !portal.bridge.IM.Capabilities().MessageStatusCheckpoints { - portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0) - } - } - return - } - portal.sendUnsupportedCheckpoint(evt, status.MsgStepRemote, fmt.Errorf("can't redact non-reaction event")) -} - -func (portal *Portal) UpdateAvatar(attachment *imessage.Attachment, intent *appservice.IntentAPI) *id.EventID { - data, err := attachment.Read() - if err != nil { - portal.log.Errorfln("Failed to read avatar attachment: %v", err) - return nil - } - hash := sha256.Sum256(data) - if portal.AvatarHash != nil && hash == *portal.AvatarHash { - portal.log.Debugfln("Not updating avatar: hash matches current avatar") - return nil - } - portal.AvatarHash = &hash - uploadResp, err := intent.UploadBytes(data, attachment.GetMimeType()) - if err != nil { - portal.AvatarHash = nil - portal.log.Errorfln("Failed to upload avatar attachment: %v", err) - return nil - } - portal.AvatarURL = uploadResp.ContentURI - if len(portal.MXID) > 0 { - resp, err := intent.SetRoomAvatar(portal.MXID, portal.AvatarURL) - if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() { - resp, err = portal.MainIntent().SetRoomAvatar(portal.MXID, portal.AvatarURL) - } - if err != nil { - portal.AvatarHash = nil - portal.log.Errorfln("Failed to set room avatar: %v", err) - return nil - } - portal.Update(nil) - portal.UpdateBridgeInfo() - portal.log.Debugfln("Successfully updated room avatar (%s / %s)", portal.AvatarURL, resp.EventID) - return &resp.EventID - } else { - return nil - } -} - -func (portal *Portal) isDuplicate(dbMessage *database.Message, msg *imessage.Message) bool { - if portal.messageDedup == nil { - return false - } - dedupKey := msg.Text - if len(msg.Attachments) == 1 { - dedupKey = msg.Attachments[0].FileName - } - portal.messageDedupLock.Lock() - dedup, isDup := portal.messageDedup[strings.TrimSpace(dedupKey)] - if isDup { - delete(portal.messageDedup, dedupKey) - portal.messageDedupLock.Unlock() - portal.log.Debugfln("Received echo for Matrix message %s -> %s", dedup.EventID, msg.GUID) - if !dedup.Timestamp.Before(msg.Time) { - portal.log.Warnfln("Echo for Matrix message %s has lower timestamp than expected (message: %s, expected: %s)", msg.Time.Unix(), dedup.Timestamp.Unix()) - } - dbMessage.MXID = dedup.EventID - dbMessage.Timestamp = msg.Time.UnixMilli() - dbMessage.Insert(nil) - portal.sendDeliveryReceipt(dbMessage.MXID, msg.Service, msg.ChatGUID, true) - return true - } - portal.messageDedupLock.Unlock() - return false -} - -func (portal *Portal) handleIMAvatarChange(msg *imessage.Message, intent *appservice.IntentAPI) *id.EventID { - if msg.GroupActionType == imessage.GroupActionSetAvatar { - if len(msg.Attachments) == 1 { - return portal.UpdateAvatar(msg.Attachments[0], intent) - } else { - portal.log.Debugfln("Unexpected number of attachments (%d) in set avatar group action", len(msg.Attachments)) - } - } else if msg.GroupActionType == imessage.GroupActionRemoveAvatar { - // TODO - portal.zlog.Warn().Msg("Removing group avatars is not supported at this time") - } else { - portal.log.Warnfln("Unexpected group action type %d in avatar change item", msg.GroupActionType) - } - return nil -} - -func (portal *Portal) setMembership(inviter *appservice.IntentAPI, puppet *Puppet, membership event.Membership, ts int64) *id.EventID { - err := inviter.EnsureInvited(portal.MXID, puppet.MXID) - if err != nil { - if errors.Is(err, mautrix.MForbidden) { - err = portal.MainIntent().EnsureInvited(portal.MXID, puppet.MXID) - } - if err != nil { - portal.log.Warnfln("Failed to ensure %s is invited to %s: %v", puppet.MXID, portal.MXID, err) - } - } - resp, err := puppet.Intent.SendMassagedStateEvent(portal.MXID, event.StateMember, puppet.MXID.String(), &event.MemberEventContent{ - Membership: membership, - AvatarURL: puppet.AvatarURL.CUString(), - Displayname: puppet.Displayname, - }, ts) - if err != nil { - puppet.log.Warnfln("Failed to set membership to %s in %s: %v", membership, portal.MXID, err) - if membership == event.MembershipJoin { - _ = puppet.Intent.EnsureJoined(portal.MXID, appservice.EnsureJoinedParams{IgnoreCache: true}) - } else if membership == event.MembershipLeave { - _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: puppet.MXID}) - } - return nil - } else { - portal.bridge.AS.StateStore.SetMembership(portal.MXID, puppet.MXID, "join") - return &resp.EventID - } -} - -func (portal *Portal) handleIMMemberChange(msg *imessage.Message, dbMessage *database.Message, intent *appservice.IntentAPI) *id.EventID { - if len(msg.Target.LocalID) == 0 { - portal.log.Debugfln("Ignoring member change item with empty target") - return nil - } - puppet := portal.bridge.GetPuppetByLocalID(msg.Target.LocalID) - puppet.Sync() - if msg.GroupActionType == imessage.GroupActionAddUser { - return portal.setMembership(intent, puppet, event.MembershipJoin, dbMessage.Timestamp) - } else if msg.GroupActionType == imessage.GroupActionRemoveUser { - return portal.setMembership(intent, puppet, event.MembershipLeave, dbMessage.Timestamp) - } else { - portal.log.Warnfln("Unexpected group action type %d in member change item", msg.GroupActionType) - } - return nil -} - -func (portal *Portal) convertIMAttachment(msg *imessage.Message, attach *imessage.Attachment, intent *appservice.IntentAPI) (*event.MessageEventContent, map[string]interface{}, error) { - data, err := attach.Read() - if err != nil { - portal.log.Errorfln("Failed to read attachment in %s: %v", msg.GUID, err) - return nil, nil, fmt.Errorf("failed to read attachment: %w", err) - } - if portal.bridge.Config.IMessage.DeleteMediaAfterUpload { - defer func() { - err = attach.Delete() - if err != nil { - portal.log.Warnfln("Failed to delete attachment in %s: %v", msg.GUID, err) - } - }() - } - - mimeType := attach.GetMimeType() - fileName := attach.GetFileName() - extraContent := map[string]interface{}{} - - if msg.IsAudioMessage { - ogg, err := ffmpeg.ConvertBytes(context.TODO(), data, ".ogg", []string{}, []string{"-c:a", "libopus"}, "audio/x-caf") - if err == nil { - extraContent["org.matrix.msc1767.audio"] = map[string]interface{}{} - extraContent["org.matrix.msc3245.voice"] = map[string]interface{}{} - mimeType = "audio/ogg" - fileName = "Voice Message.ogg" - data = ogg - } else { - portal.log.Errorf("Failed to convert audio message to ogg/opus: %v - sending without conversion", err) - } - } - - if CanConvertHEIF && portal.bridge.Config.Bridge.ConvertHEIF && (mimeType == "image/heic" || mimeType == "image/heif") { - convertedData, err := ConvertHEIF(data) - if err == nil { - mimeType = "image/jpeg" - fileName += ".jpg" - data = convertedData - } else { - portal.log.Errorf("Failed to convert heif image to jpeg: %v - sending without conversion", err) - } - } - - if portal.bridge.Config.Bridge.ConvertTIFF && mimeType == "image/tiff" { - convertedData, err := ConvertTIFF(data) - if err == nil { - mimeType = "image/jpeg" - fileName += ".jpg" - data = convertedData - } else { - portal.log.Errorf("Failed to convert tiff image to jpeg: %v - sending without conversion", err) - } - } - - if portal.bridge.Config.Bridge.ConvertVideo.Enabled && mimeType == "video/quicktime" { - conv := portal.bridge.Config.Bridge.ConvertVideo - convertedData, err := ffmpeg.ConvertBytes(context.TODO(), data, "."+conv.Extension, []string{}, conv.FFMPEGArgs, "video/quicktime") - if err == nil { - mimeType = conv.MimeType - fileName += "." + conv.Extension - data = convertedData - } else { - portal.log.Errorf("Failed to convert quicktime video to webm: %v - sending without conversion", err) - } - } - - uploadMime, uploadInfo := portal.encryptFile(data, mimeType) - - req := mautrix.ReqUploadMedia{ - ContentBytes: data, - ContentType: uploadMime, - } - var mxc id.ContentURI - if portal.bridge.Config.Homeserver.AsyncMedia { - uploaded, err := intent.UploadAsync(req) - if err != nil { - portal.log.Errorfln("Failed to asynchronously upload attachment in %s: %v", msg.GUID, err) - return nil, nil, err - } - mxc = uploaded.ContentURI - } else { - uploaded, err := intent.UploadMedia(req) - if err != nil { - portal.log.Errorfln("Failed to upload attachment in %s: %v", msg.GUID, err) - return nil, nil, err - } - mxc = uploaded.ContentURI - } - - var content event.MessageEventContent - if uploadInfo != nil { - uploadInfo.URL = mxc.CUString() - content.File = uploadInfo - } else { - content.URL = mxc.CUString() - } - content.Body = fileName - content.Info = &event.FileInfo{ - MimeType: mimeType, - Size: len(data), - } - switch strings.Split(mimeType, "/")[0] { - case "image": - content.MsgType = event.MsgImage - case "video": - content.MsgType = event.MsgVideo - case "audio": - content.MsgType = event.MsgAudio - default: - content.MsgType = event.MsgFile - } - return &content, extraContent, nil -} - -type ConvertedMessage struct { - Type event.Type - Content *event.MessageEventContent - Extra map[string]any -} - -func (portal *Portal) convertIMAttachments(msg *imessage.Message, intent *appservice.IntentAPI) []*ConvertedMessage { - converted := make([]*ConvertedMessage, len(msg.Attachments)) - for index, attach := range msg.Attachments { - portal.log.Debugfln("Converting iMessage attachment %s.%d", msg.GUID, index) - content, extra, err := portal.convertIMAttachment(msg, attach, intent) - if extra == nil { - extra = map[string]interface{}{} - } - if err != nil { - content = &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: err.Error(), - } - } else if msg.Metadata != nil { - extra["com.beeper.message_metadata"] = msg.Metadata - } - converted[index] = &ConvertedMessage{ - Type: event.EventMessage, - Content: content, - Extra: extra, - } - } - return converted -} - -func (portal *Portal) convertIMText(msg *imessage.Message) *ConvertedMessage { - msg.Text = strings.ReplaceAll(msg.Text, "\ufffc", "") - msg.Subject = strings.ReplaceAll(msg.Subject, "\ufffc", "") - if len(msg.Text) == 0 && len(msg.Subject) == 0 { - return nil - } - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: msg.Text, - } - if len(msg.Subject) > 0 { - content.Format = event.FormatHTML - content.FormattedBody = fmt.Sprintf("<strong>%s</strong><br>%s", event.TextToHTML(msg.Subject), event.TextToHTML(content.Body)) - content.Body = fmt.Sprintf("**%s**\n%s", msg.Subject, msg.Text) - } - extraAttrs := map[string]any{} - if msg.RichLink != nil { - portal.log.Debugfln("Handling rich link in iMessage %s", msg.GUID) - linkPreview := portal.convertRichLinkToBeeper(msg.RichLink) - if linkPreview != nil { - extraAttrs["com.beeper.linkpreviews"] = []*BeeperLinkPreview{linkPreview} - portal.log.Debugfln("Link preview metadata converted for %s", msg.GUID) - } - } - if msg.Metadata != nil { - extraAttrs["com.beeper.message_metadata"] = msg.Metadata - } - return &ConvertedMessage{ - Type: event.EventMessage, - Content: content, - Extra: extraAttrs, - } -} - -func (portal *Portal) GetReplyEvent(msg *imessage.Message) (id.EventID, *event.Event) { - if len(msg.ReplyToGUID) == 0 { - return "", nil - } - message := portal.bridge.DB.Message.GetByGUID(portal.GUID, msg.ReplyToGUID, msg.ReplyToPart) - if message != nil { - evt, err := portal.MainIntent().GetEvent(portal.MXID, message.MXID) - if err != nil { - portal.log.Warnln("Failed to get reply target event:", err) - return message.MXID, nil - } - if evt.Type == event.EventEncrypted { - _ = evt.Content.ParseRaw(evt.Type) - decryptedEvt, err := portal.bridge.Crypto.Decrypt(evt) - if err != nil { - portal.log.Warnln("Failed to decrypt reply target:", err) - } else { - evt = decryptedEvt - } - } - _ = evt.Content.ParseRaw(evt.Type) - return message.MXID, evt - } else if portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry { - portal.log.Debugfln("Using deterministic event ID for unknown reply target %s.%d", msg.ReplyToGUID, msg.ReplyToPart) - return portal.deterministicEventID(msg.ReplyToGUID, msg.ReplyToPart), nil - } else { - portal.log.Debugfln("Unknown reply target %s.%d", msg.ReplyToGUID, msg.ReplyToPart) - } - return "", nil -} - -func (portal *Portal) addSourceMetadata(msg *imessage.Message, to map[string]any) { - if portal.bridge.IM.Capabilities().ContactChatMerging { - to[bridgeInfoService] = msg.Service - to[bridgeInfoHandle] = msg.ChatGUID - } -} - -func (portal *Portal) convertiMessage(msg *imessage.Message, intent *appservice.IntentAPI) []*ConvertedMessage { - attachments := portal.convertIMAttachments(msg, intent) - text := portal.convertIMText(msg) - if text != nil && len(attachments) == 1 && portal.bridge.Config.Bridge.CaptionInMessage { - attach := attachments[0].Content - attach.FileName = attach.Body - attach.Body = text.Content.Body - attach.Format = text.Content.Format - attach.FormattedBody = text.Content.FormattedBody - } else if text != nil { - attachments = append(attachments, text) - } - for _, part := range attachments { - portal.addSourceMetadata(msg, part.Extra) - } - if msg.ReplyToGUID != "" { - replyToMXID, replyToEvt := portal.GetReplyEvent(msg) - if replyToMXID != "" { - msg.ReplyProcessed = true - for _, part := range attachments { - if replyToEvt != nil { - part.Content.SetReply(replyToEvt) - } else { - part.Content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(replyToMXID) - } - } - } - } - return attachments -} - -func (portal *Portal) handleNormaliMessage(msg *imessage.Message, dbMessage *database.Message, intent *appservice.IntentAPI, mxid *id.EventID) { - if msg.Metadata != nil && portal.bridge.Config.HackyStartupTest.Key != "" { - if portal.bridge.Config.HackyStartupTest.EchoMode { - _, ok := msg.Metadata[startupTestKey].(map[string]any) - if ok { - go portal.bridge.receiveStartupTestPing(msg) - } - } else if portal.Identifier.LocalID == portal.bridge.Config.HackyStartupTest.Identifier { - resp, ok := msg.Metadata[startupTestResponseKey].(map[string]any) - if ok { - go portal.bridge.receiveStartupTestPong(resp, msg) - } - } - } - - parts := portal.convertiMessage(msg, intent) - if len(parts) == 0 { - portal.log.Warnfln("iMessage %s doesn't contain any attachments nor text", msg.GUID) - } - for index, converted := range parts { - if mxid != nil { - if len(parts) == 1 { - converted.Content.SetEdit(*mxid) - } - } - portal.log.Debugfln("Sending iMessage attachment %s.%d", msg.GUID, index) - resp, err := portal.sendMessage(intent, converted.Type, converted.Content, converted.Extra, dbMessage.Timestamp) - if err != nil { - portal.log.Errorfln("Failed to send attachment %s.%d: %v", msg.GUID, index, err) - } else { - portal.log.Debugfln("Handled iMessage attachment %s.%d -> %s", msg.GUID, index, resp.EventID) - if mxid == nil { - dbMessage.MXID = resp.EventID - dbMessage.Part = index - dbMessage.Insert(nil) - dbMessage.Part++ - } - } - } -} - -func (portal *Portal) handleIMError(msg *imessage.Message, dbMessage *database.Message, intent *appservice.IntentAPI) { - if len(msg.ErrorNotice) > 0 { - content := &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: msg.ErrorNotice, - } - replyToMXID, replyToEvt := portal.GetReplyEvent(msg) - if replyToEvt != nil { - content.SetReply(replyToEvt) - } else if replyToMXID != "" { - content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(replyToMXID) - } - extra := map[string]any{} - portal.addSourceMetadata(msg, extra) - resp, err := portal.sendMessage(intent, event.EventMessage, content, extra, dbMessage.Timestamp) - if err != nil { - portal.log.Errorfln("Failed to send error notice %s: %v", msg.GUID, err) - return - } - portal.log.Debugfln("Handled iMessage error notice %s.%d -> %s", msg.GUID, dbMessage.Part, resp.EventID) - dbMessage.MXID = resp.EventID - dbMessage.Insert(nil) - dbMessage.Part++ - } -} - -func (portal *Portal) getIntentForMessage(msg *imessage.Message, dbMessage *database.Message) *appservice.IntentAPI { - if msg.IsFromMe { - intent := portal.bridge.user.DoublePuppetIntent - if dbMessage != nil && portal.isDuplicate(dbMessage, msg) { - return nil - } else if intent == nil { - portal.log.Debugfln("Dropping own message in %s as double puppeting is not initialized", msg.ChatGUID) - return nil - } - return intent - } else if len(msg.Sender.LocalID) > 0 { - localID := msg.Sender.LocalID - if portal.bridge.Config.Bridge.ForceUniformDMSenders && portal.IsPrivateChat() && msg.Sender.LocalID != portal.Identifier.LocalID { - portal.log.Debugfln("Message received from %s, which is not the expected sender %s. Forcing the original puppet.", localID, portal.Identifier.LocalID) - localID = portal.Identifier.LocalID - } - puppet := portal.bridge.GetPuppetByLocalID(localID) - if len(puppet.Displayname) == 0 { - portal.log.Debugfln("Displayname of %s is empty, syncing before handling %s", puppet.ID, msg.GUID) - puppet.Sync() - } - return puppet.Intent - } - return portal.MainIntent() -} - -func (portal *Portal) HandleiMessage(msg *imessage.Message) id.EventID { - var dbMessage *database.Message - var overrideSuccess bool - - defer func() { - if err := recover(); err != nil { - portal.log.Errorfln("Panic while handling %s: %v\n%s", msg.GUID, err, string(debug.Stack())) - } - hasMXID := dbMessage != nil && len(dbMessage.MXID) > 0 - var eventID id.EventID - if hasMXID { - eventID = dbMessage.MXID - } - portal.bridge.IM.SendMessageBridgeResult(msg.ChatGUID, msg.GUID, eventID, overrideSuccess || hasMXID) - }() - - // Look up the message in the database - dbMessage = portal.bridge.DB.Message.GetLastByGUID(portal.GUID, msg.GUID) - - if portal.IsPrivateChat() && msg.ChatGUID != portal.LastSeenHandle { - portal.log.Debugfln("Updating last seen handle from %s to %s", portal.LastSeenHandle, msg.ChatGUID) - portal.LastSeenHandle = msg.ChatGUID - portal.Update(nil) - } - - // Handle message tapbacks - if msg.Tapback != nil { - portal.HandleiMessageTapback(msg) - return "" - } - - // If the message exists in the database, handle edits or retractions - if dbMessage != nil && dbMessage.MXID != "" { - // DEVNOTE: It seems sometimes the message is just edited to remove data instead of actually retracting it - - if msg.IsRetracted || - (len(msg.Attachments) == 0 && len(msg.Text) == 0 && len(msg.Subject) == 0) { - - // Retract existing message - if portal.HandleMessageRevoke(*msg) { - portal.zlog.Debug().Str("messageGUID", msg.GUID).Str("chatGUID", msg.ChatGUID).Msg("Revoked message") - } else { - portal.zlog.Warn().Str("messageGUID", msg.GUID).Str("chatGUID", msg.ChatGUID).Msg("Failed to revoke message") - } - - overrideSuccess = true - } else if msg.IsEdited && dbMessage.Part > 0 { - - // Edit existing message - intent := portal.getIntentForMessage(msg, nil) - portal.handleNormaliMessage(msg, dbMessage, intent, &dbMessage.MXID) - - overrideSuccess = true - } else if msg.IsRead && msg.IsFromMe { - - // Send read receipt - err := portal.markRead(portal.MainIntent(), dbMessage.MXID, msg.ReadAt) - if err != nil { - portal.log.Warnfln("Failed to send read receipt for %s: %v", dbMessage.MXID, err) - } - - overrideSuccess = true - } else { - portal.log.Debugln("Ignoring duplicate message", msg.GUID) - // Send a success confirmation since it's a duplicate message - overrideSuccess = true - } - return "" - } - - // If the message is not found in the database, proceed with handling as usual - portal.log.Debugfln("Starting handling of iMessage %s (type: %d, attachments: %d, text: %d)", msg.GUID, msg.ItemType, len(msg.Attachments), len(msg.Text)) - dbMessage = portal.bridge.DB.Message.New() - dbMessage.PortalGUID = portal.GUID - dbMessage.HandleGUID = msg.ChatGUID - dbMessage.SenderGUID = msg.Sender.String() - dbMessage.GUID = msg.GUID - dbMessage.Timestamp = msg.Time.UnixMilli() - - intent := portal.getIntentForMessage(msg, dbMessage) - if intent == nil { - portal.log.Debugln("Handling of iMessage", msg.GUID, "was cancelled (didn't get an intent)") - return dbMessage.MXID - } - - var groupUpdateEventID *id.EventID - - switch msg.ItemType { - case imessage.ItemTypeMessage: - portal.handleNormaliMessage(msg, dbMessage, intent, nil) - case imessage.ItemTypeMember: - groupUpdateEventID = portal.handleIMMemberChange(msg, dbMessage, intent) - case imessage.ItemTypeName: - groupUpdateEventID = portal.UpdateName(msg.NewGroupName, intent) - case imessage.ItemTypeAvatar: - groupUpdateEventID = portal.handleIMAvatarChange(msg, intent) - case imessage.ItemTypeError: - // Handled below - default: - portal.log.Debugfln("Dropping message %s with unknown item type %d", msg.GUID, msg.ItemType) - return "" - } - - portal.handleIMError(msg, dbMessage, intent) - - if groupUpdateEventID != nil { - dbMessage.MXID = *groupUpdateEventID - dbMessage.Insert(nil) - } - - if len(dbMessage.MXID) > 0 { - portal.sendDeliveryReceipt(dbMessage.MXID, "", "", false) - if !msg.IsFromMe && msg.IsRead { - err := portal.markRead(portal.bridge.user.DoublePuppetIntent, dbMessage.MXID, time.Time{}) - if err != nil { - portal.log.Warnln("Failed to mark %s as read after bridging: %v", dbMessage.MXID, err) - } - } - } else { - portal.log.Debugfln("Unhandled message %s", msg.GUID) - } - return dbMessage.MXID -} - -func (portal *Portal) HandleiMessageTapback(msg *imessage.Message) { - portal.log.Debugln("Starting handling of iMessage tapback", msg.GUID, "to", msg.Tapback.TargetGUID) - target := portal.bridge.DB.Message.GetByGUID(portal.GUID, msg.Tapback.TargetGUID, msg.Tapback.TargetPart) - if target == nil { - portal.log.Debugfln("Unknown tapback target %s.%d", msg.Tapback.TargetGUID, msg.Tapback.TargetPart) - return - } - intent := portal.getIntentForMessage(msg, nil) - if intent == nil { - return - } - senderGUID := msg.Sender.String() - - existing := portal.bridge.DB.Tapback.GetByGUID(portal.GUID, target.GUID, target.Part, senderGUID) - if msg.Tapback.Remove { - if existing == nil { - return - } - _, err := intent.RedactEvent(portal.MXID, existing.MXID) - if err != nil { - portal.log.Warnfln("Failed to remove tapback from %s: %v", msg.SenderText(), err) - } - existing.Delete() - return - } else if existing != nil && existing.Type == msg.Tapback.Type { - portal.log.Debugfln("Ignoring tapback from %s to %s: type is same", msg.SenderText(), target.GUID) - return - } - - content := &event.ReactionEventContent{ - RelatesTo: event.RelatesTo{ - EventID: target.MXID, - Type: event.RelAnnotation, - Key: msg.Tapback.Type.Emoji(), - }, - } - - if existing != nil { - if _, err := intent.RedactEvent(portal.MXID, existing.MXID); err != nil { - portal.log.Warnfln("Failed to redact old tapback from %s: %v", msg.SenderText(), err) - } - } - - resp, err := intent.SendMassagedMessageEvent(portal.MXID, event.EventReaction, &content, msg.Time.UnixMilli()) - - if err != nil { - portal.log.Errorfln("Failed to send tapback from %s: %v", msg.SenderText(), err) - return - } - - if existing == nil { - tapback := portal.bridge.DB.Tapback.New() - tapback.PortalGUID = portal.GUID - tapback.MessageGUID = target.GUID - tapback.MessagePart = target.Part - tapback.SenderGUID = senderGUID - tapback.HandleGUID = msg.ChatGUID - tapback.GUID = msg.GUID - tapback.Type = msg.Tapback.Type - tapback.MXID = resp.EventID - tapback.Insert(nil) - } else { - existing.GUID = msg.GUID - existing.Type = msg.Tapback.Type - existing.MXID = resp.EventID - existing.HandleGUID = msg.ChatGUID - existing.Update() - } -} - -func (portal *Portal) HandleMessageRevoke(msg imessage.Message) bool { - dbMessage := portal.bridge.DB.Message.GetLastByGUID(portal.GUID, msg.GUID) - if dbMessage == nil { - return true - } - intent := portal.getIntentForMessage(&msg, nil) - if intent == nil { - return false - } - _, err := intent.RedactEvent(portal.MXID, dbMessage.MXID) - if err != nil { - if errors.Is(err, mautrix.MForbidden) { - _, err = portal.MainIntent().RedactEvent(portal.MXID, dbMessage.MXID) - if err != nil { - portal.log.Errorln("Failed to redact %s: %v", msg.GUID, err) - } - } - } else { - dbMessage.Delete() - } - return true -} - -func (portal *Portal) Delete() { - portal.bridge.portalsLock.Lock() - portal.unlockedDelete() - portal.bridge.portalsLock.Unlock() -} - -func (portal *Portal) unlockedDelete() { - portal.Portal.Delete() - delete(portal.bridge.portalsByGUID, portal.GUID) - for _, guid := range portal.SecondaryGUIDs { - if storedPortal := portal.bridge.portalsByGUID[guid]; storedPortal == portal { - portal.bridge.portalsByGUID[guid] = nil - } - } - if len(portal.MXID) > 0 { - delete(portal.bridge.portalsByMXID, portal.MXID) - } -} - -func (portal *Portal) GetMatrixUsers() ([]id.UserID, error) { - members, err := portal.MainIntent().JoinedMembers(portal.MXID) - if err != nil { - return nil, fmt.Errorf("failed to get member list: %w", err) - } - var users []id.UserID - for userID := range members.Joined { - _, isPuppet := portal.bridge.ParsePuppetMXID(userID) - if !isPuppet && userID != portal.bridge.Bot.UserID { - users = append(users, userID) - } - } - return users, nil -} - -func (portal *Portal) CleanupIfEmpty(deleteIfForbidden bool) bool { - if len(portal.MXID) == 0 { - return false - } - - users, err := portal.GetMatrixUsers() - if err != nil { - if deleteIfForbidden && errors.Is(err, mautrix.MForbidden) { - portal.log.Errorfln("Got %v while checking if portal is empty, assuming it's gone", err) - portal.Delete() - return true - } else { - portal.log.Errorfln("Failed to get Matrix user list to determine if portal needs to be cleaned up: %v", err) - } - return false - } - - if len(users) == 0 { - portal.log.Infoln("Room seems to be empty, cleaning up...") - portal.Delete() - portal.Cleanup(false) - return true - } - return false -} - -func (portal *Portal) Cleanup(puppetsOnly bool) { - if len(portal.MXID) == 0 { - return - } - intent := portal.MainIntent() - if portal.bridge.SpecVersions.UnstableFeatures["com.beeper.room_yeeting"] { - err := intent.BeeperDeleteRoom(portal.MXID) - if err != nil && !errors.Is(err, mautrix.MNotFound) { - portal.zlog.Err(err).Msg("Failed to delete room using hungryserv yeet endpoint") - } - return - } - if portal.IsPrivateChat() { - _, err := intent.LeaveRoom(portal.MXID) - if err != nil { - portal.log.Warnln("Failed to leave private chat portal with main intent:", err) - } - return - } - members, err := intent.JoinedMembers(portal.MXID) - if err != nil { - portal.log.Errorln("Failed to get portal members for cleanup:", err) - return - } - if _, isJoined := members.Joined[portal.bridge.user.MXID]; !puppetsOnly && !isJoined { - // Kick the user even if they're not joined in case they're invited. - _, _ = intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: portal.bridge.user.MXID, Reason: "Deleting portal"}) - } - for member := range members.Joined { - if member == intent.UserID { - continue - } - puppet := portal.bridge.GetPuppetByMXID(member) - if puppet != nil { - _, err = puppet.Intent.LeaveRoom(portal.MXID) - if err != nil { - portal.log.Errorln("Error leaving as puppet while cleaning up portal:", err) - } - } else if !puppetsOnly { - _, err = intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) - if err != nil { - portal.log.Errorln("Error kicking user while cleaning up portal:", err) - } - } - } - _, err = intent.LeaveRoom(portal.MXID) - if err != nil { - portal.log.Errorln("Error leaving with main intent while cleaning up portal:", err) - } -} diff --git a/prompts/research-apple-auth-tokens.md b/prompts/research-apple-auth-tokens.md new file mode 100644 index 00000000..97a949ba --- /dev/null +++ b/prompts/research-apple-auth-tokens.md @@ -0,0 +1,107 @@ +# Research: Apple iCloud Authentication Tokens & Session Lifecycle + +## Objective + +We're building an iMessage bridge that authenticates with Apple's iCloud services (CloudKit, CardDAV contacts, iMessage). We persist credentials across process restarts but our token restoration is broken — we get 401s on CardDAV immediately after a fresh login. We clearly don't understand how Apple's auth tokens work. **Fix that.** + +## What We Need to Understand + +For each token/credential type below, determine: +1. **What is it?** (full name, what service issues it) +2. **How is it obtained?** (what auth flow, what inputs) +3. **What is its lifetime?** (seconds? minutes? hours? days?) +4. **Can it be refreshed without user interaction (2FA)?** +5. **What can it be used for?** +6. **What depends on it?** + +### Token Types to Research + +- **PET (Person/Persistent/Primary? Token)** — `com.apple.gs.idms.pet` in Apple's GSA (Grand Slam Authentication) system. We store this after login. It seems to expire quickly but we don't know the actual lifetime. + +- **MobileMe delegate / mmeAuthToken** — Used for iCloud services (CardDAV contacts, etc.). Obtained by calling `login_apple_delegates()` with a PET token. Has its own expiry. We cache this as JSON. + +- **SPD (Session/Security? Plist Dictionary)** — A plist dictionary containing `adsid` and other session data. Stored after login. Used in `refresh_mme()`. + +- **ADSID** — Apple Directory Services ID? Appears in SPD and is used when fetching delegates. + +- **DSID** — Another Apple service ID used for CardDAV auth headers. + +- **Hashed password** — SRP-derived password hash stored after initial login. Can this be used to get a fresh PET without 2FA? + +- **Anisette data** — Machine-specific headers (X-Apple-I-MD, X-Apple-I-MD-M, etc.). Generated locally. How does staleness affect auth? + +- **CloudKit session tokens** — Are these separate from the MobileMe delegate? Do they have their own auth? + +## Key Questions + +1. **After a successful Apple ID login (with 2FA), what is the longest-lived credential we can store?** Can we store something that lets us get fresh tokens for weeks/months without user interaction? + +2. **Can `login_email_pass()` (SRP auth with stored hashed password) obtain a fresh PET without triggering 2FA?** Our code tries this as a fallback and gets error `-22406`. Is that always the case, or is it a fixable configuration issue? + +3. **What is the actual PET token lifetime?** We've seen it expire within minutes of being issued. Is that normal? Does it depend on how it was obtained? + +4. **What is the MobileMe delegate token lifetime?** If we cache the delegate JSON, how long is it valid? + +5. **How does a real Apple device (iPhone/Mac) maintain persistent access to iCloud services without re-prompting for 2FA?** What token chain does it use? Can we replicate that? + +6. **What is the correct token refresh chain?** i.e., "Use X to refresh Y, use Y to refresh Z, Z is what you actually need for API calls." What's the dependency graph? + +## Our Current (Broken) Flow + +``` +LOGIN (interactive, with 2FA): + 1. User enters Apple ID + password + 2FA code + 2. SRP authentication → get PET token + 3. Use PET to fetch MobileMe delegate (login_apple_delegates) + 4. Store: username, hashed_password, PET, SPD, ADSID, DSID, MobileMe delegate JSON + +RESTORE (on bridge restart, no user interaction): + 1. Create fresh anisette provider + 2. Create AppleAccount with stored username + hashed_password + SPD + 3. Inject stored PET with fake 30-day expiry (!!) + 4. Seed cached MobileMe delegate JSON + 5. Try to use MobileMe delegate for CardDAV → 401 + 6. refresh_mme() tries PET-based auth → UNAUTHORIZED (PET expired server-side) + 7. Fallback: login_email_pass() with hashed_password → -22406 error + 8. Everything fails. Cloud sync blocked forever. +``` + +## Research Approach + +**DO NOT trust our codebase's assumptions.** The code injects a PET with a "30-day expiry" which is clearly wrong. Verify everything independently: + +1. **Search for prior art**: Look at how other open-source Apple auth implementations handle token persistence. Key projects: + - `apple-private-apis` / `icloud-auth` (Rust, the library we use — check its docs/issues) + - `pypush` (Python iMessage implementation) + - `Beeper's original mautrix-imessage` (may have solved this) + - Any iCloud reverse-engineering research/blogs + +2. **Examine the actual token data**: If possible, decode/inspect the PET token and MobileMe delegate to find embedded expiry timestamps. + +3. **Test experimentally**: If you have access to the running bridge, try: + - Call `refresh_mme()` immediately after login (before any delay) — does it work? + - Check how long after login the PET still works + - Check if there's a refresh token alongside the PET that we're not storing + +4. **Check Apple's GSA protocol**: The SRP-based Grand Slam Authentication has been reverse-engineered. Find documentation on the full token lifecycle. + +## Codebase References + +- **Token restoration**: `pkg/rustpushgo/src/lib.rs` line 600 — `restore_token_provider()` +- **MobileMe refresh**: `rustpush/src/auth.rs` line 155 — `refresh_mme()` +- **Login flow**: `pkg/connector/login.go` line 620+ — where tokens are saved after login +- **Token usage on startup**: `pkg/connector/client.go` line 190+ — where tokens are restored +- **icloud-auth library**: `rustpush/apple-private-apis/icloud-auth/src/` — the underlying Apple auth implementation +- **GSA client**: `rustpush/apple-private-apis/icloud-auth/src/client.rs` — SRP login, token fetching +- **CardDAV contacts**: `pkg/connector/cloud_contacts.go` — uses auth headers from TokenProvider + +## Deliverable + +Write a document at `docs/apple-auth-research.md` that: +1. Maps out every token type, its lifetime, and refresh mechanism +2. Shows the correct token dependency graph +3. Explains how a real Apple device maintains persistent auth +4. Recommends the specific changes needed so our bridge can maintain auth across restarts without 2FA +5. If persistent auth without 2FA is impossible, say so clearly and explain why + +Be precise. Cite sources. Don't guess. diff --git a/prompts/research-group-id-prompt.md b/prompts/research-group-id-prompt.md new file mode 100644 index 00000000..ee99dea0 --- /dev/null +++ b/prompts/research-group-id-prompt.md @@ -0,0 +1,129 @@ +# Research Task: iMessage Group Chat Identity Model + +## Objective + +Produce a comprehensive report (`docs/group-id-research.md`) mapping every identifier involved in iMessage group chat identity — across CloudKit sync, real-time APNs delivery, and the local Messages database. The goal is to understand how Apple tracks "the same conversation" across member changes, and architect the correct portal ID strategy for our Matrix-iMessage bridge. + +## The Problem We're Solving + +When a user sends a message to an **existing** group chat in iMessage, the bridge creates a **brand new** Matrix room in Beeper instead of routing the message to the existing room for that group. The conversation stays on the same thread in iMessage — it's only the bridge that splinters it. + +Concretely: CloudKit sync created a portal with `gid:<UUID-A>` for a group. Later, a real-time APNs message arrives for the same group but with `sender_guid = <UUID-B>`. Since `UUID-B != UUID-A`, the bridge creates a new portal `gid:<UUID-B>`. The user now has two rooms in Beeper for one iMessage conversation. + +We need to understand: **Why does the real-time message UUID differ from the CloudKit-synced UUID for the same group?** And what's the correct identifier to use so both paths always resolve to the same portal? + +Note: There are legitimately many different group chats with the same participants (from testing). Those are separate conversations and separate portals — that's correct. The bug is specifically about a single conversation getting a different UUID in real-time vs CloudKit. + +## What to Research + +### 1. CloudKit Chat Records (`chatEncryptedv2` zone) + +Our Rust code decodes these in `rustpush/src/imessage/cloud_messages.rs` (struct `CloudChat`, ~line 231). Examine every field: + +- `cid` → `chat_identifier` — What format? (e.g., `chat368136512547052395`, `iMessage;+;chat...`, etc.) Does it stay stable across member changes? +- `gid` → `group_id` — UUID format. When exactly does it change? Is it per-member-change or something else? +- `ogid` → `original_group_id` — Points to what? The immediately previous `gid`? The very first `gid`? Something else? +- `stl` → `style` — We know 43=group, 45=DM. Any other values? +- `ptcpts` → `participants` — How are participants encoded? URIs like `tel:+1...` or `mailto:...`? +- `guid` — What is this? Same as `gid`? Different? +- `name` / `display_name` — User-set group name vs auto-generated? +- `svc` → `service_name` — Always "iMessage"? Can be "SMS"? +- Any other fields that might relate to conversation identity + +**Key question**: Given 30+ CloudKit chat records that all represent the same group conversation (with different member snapshots), which field(s) are stable across all of them? + +### 2. Real-Time APNs Messages (rustpush) + +When a message arrives via APNs push, examine what identifiers are available: + +- Look at `rustpush/src/imessage/messages.rs` for incoming message structures +- Look at `pkg/rustpushgo/src/lib.rs` for `WrappedMessage` (around line 340) — what is `sender_guid`? +- Is `sender_guid` the same as CloudKit's `gid`? Or something else? +- For group messages: what fields identify which conversation the message belongs to? +- Is there a `chat_identifier` equivalent in real-time messages? +- When group membership changes, does the real-time `sender_guid` change immediately? + +### 3. Local Messages Database (chat.db on macOS) + +While our bridge doesn't use chat.db directly (we use CloudKit), understanding Apple's local model helps: + +- `chat` table: `ROWID`, `chat_identifier`, `group_id`, `display_name` — which stays stable? +- `chat_message_join` table: how messages link to chats +- When members change, does the `chat.ROWID` stay the same? Does `chat_identifier`? +- Is there a concept of chat "continuation" in the local DB? + +### 4. The `original_group_id` Chain + +This is the most critical piece to understand: + +- When Apple creates a new `gid` (member change), does `ogid` on the new record point to the old `gid`? +- Is it always a direct parent link (A → B → C), or can it skip generations? +- Can `ogid` be empty on the very first version of a group? +- Can multiple records share the same `ogid`? (e.g., two member changes from the same base) +- Is the chain always linear, or can it branch/merge? + +### 5. Our Current Data + +Query the live database on the bridge to examine real data: + +```bash +# SSH to the bridge +gcloud compute ssh imessage-bridge-32 --zone=us-west1-b + +# Database location (CWD-relative, the actual DB is here): +sqlite3 ~/imessage/mautrix-imessage.db + +# Cloud chat table schema +.schema cloud_chat + +# Example: find all cloud_chat records for groups involving specific participants +SELECT cloud_chat_id, group_id, portal_id, display_name, participants_json +FROM cloud_chat WHERE portal_id LIKE 'gid:%' ORDER BY group_id; + +# Check for the real-time message that created a duplicate portal +SELECT id, name, mxid FROM portal WHERE id LIKE 'gid:2f787cd8%'; +``` + +Also look at the Rust source to understand what CloudKit fields are available but not yet stored: +- `rustpush/src/imessage/cloud_messages.rs` — `CloudChat` struct +- `pkg/rustpushgo/src/lib.rs` — `WrappedCloudSyncChat` FFI struct +- `pkg/connector/cloud_backfill_store.go` — DB schema and upsert logic +- `pkg/connector/sync_controller.go` — `resolvePortalIDForCloudChat()` — current portal ID resolution logic +- `pkg/connector/client.go` — `makePortalKey()` (~line 2174) — real-time portal ID resolution + +## Critical Instruction: Challenge All Assumptions + +The prompt above contains assumptions made by a previous agent working on this problem. **Do not take any of them as fact.** Specifically, verify or disprove each of these through code inspection and database queries: + +- **"Apple creates a new group UUID when members are added/removed"** — Is this actually true? Or do UUIDs change for other reasons? Or is the real issue something else entirely — like the real-time `sender_guid` being a fundamentally different kind of identifier than CloudKit's `gid`? +- **"`original_group_id` links to the previous UUID forming a chain"** — Does it? Or does `ogid` mean something else entirely? You'll need to expose this field first (it's in the Rust struct but not yet in the FFI/Go layer or DB). Check the actual data once exposed. +- **"30+ different group UUIDs all represent the same conversation"** — This is WRONG. The user confirmed these are legitimately different group chats created during testing. Same participants, different conversations. That's expected. Don't get distracted by this. +- **"`chat_identifier` (cid) is stable across member changes"** — Verify this. Some `cloud_chat_id` values look like `chat368136512547052395` while others look like hex hashes (`367950f3326343d1a93a4798aa98fa8e`). What determines the format? Are the `chat*` ones truly stable? +- **"`sender_guid` in real-time messages is the same as CloudKit's `gid`"** — Confirm by cross-referencing actual values. +- **"Style 43 = group, 45 = DM with no other values"** — Query for all distinct `style` values in the data. + +For each assumption, state whether it is **confirmed**, **disproved**, or **uncertain**, with evidence. + +## Expected Output + +Produce `docs/group-id-research.md` containing: + +1. **Identifier Map**: A table listing every ID field, its source (CloudKit/APNs/chatdb), format, stability characteristics, and whether it changes on member changes +2. **Chain Analysis**: Document the `original_group_id` chain behavior with examples from real data +3. **Stability Analysis**: Which identifier(s) remain constant across the lifetime of a single conversation? +4. **Architecture Recommendation**: Based on findings, what should we use as the canonical portal ID for group chats? How should we handle: + - Initial cloud sync (bootstrap) + - Incremental cloud sync (new chat records arriving) + - Real-time messages (APNs push with possibly-new UUID) + - The transition period (real-time message arrives before CloudKit syncs the new chat record) +5. **Data Model Changes**: What columns/tables/indexes need to change in our bridge DB? + +## Important Context + +- The bridge is a Go application with a Rust FFI layer for Apple protocol handling +- We're bridging iMessage to Matrix via the mautrix framework +- Each Matrix room = one "portal" identified by a portal ID string +- Currently using `gid:<lowercase-uuid>` for groups, `tel:+1234567890` or `mailto:user@example.com` for DMs +- The duplicate portal problem: real-time message for "Ludvig, David, & James" created `gid:2f787cd8-5e31-4ed6-802c-4e1b7ee56eff` but that UUID doesn't match ANY `group_id` in the `cloud_chat` table. The same group conversation exists in cloud_chat under a different UUID. Why? +- There are ~30 group chats with overlapping participants — these are legitimately different conversations from testing, NOT the same group. Don't try to merge them. +- We do NOT have access to the local macOS chat.db — the bridge runs on a Linux VM with only CloudKit + APNs access diff --git a/puppet.go b/puppet.go deleted file mode 100644 index 77c05e03..00000000 --- a/puppet.go +++ /dev/null @@ -1,383 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <https://www.gnu.org/licenses/>. - -package main - -import ( - "crypto/sha256" - "errors" - "fmt" - "io" - "net/http" - "regexp" - "strconv" - "strings" - "time" - - "github.com/gabriel-vasile/mimetype" - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/database" - "go.mau.fi/mautrix-imessage/imessage" - "go.mau.fi/mautrix-imessage/ipc" -) - -var userIDRegex *regexp.Regexp - -func (br *IMBridge) ParsePuppetMXID(mxid id.UserID) (string, bool) { - if userIDRegex == nil { - userIDRegex = br.Config.MakeUserIDRegex("(.+)") - } - match := userIDRegex.FindStringSubmatch(string(mxid)) - if match == nil || len(match) != 2 { - return "", false - } - - localID := match[1] - if number, err := strconv.Atoi(localID); err == nil { - return fmt.Sprintf("+%d", number), true - } else if localpart, err := id.DecodeUserLocalpart(localID); err == nil { - return localpart, true - } else { - br.Log.Debugfln("Failed to decode user localpart '%s': %v", localID, err) - return "", false - } - -} - -func (br *IMBridge) GetPuppetByMXID(mxid id.UserID) *Puppet { - localID, ok := br.ParsePuppetMXID(mxid) - if !ok { - return nil - } - - return br.GetPuppetByLocalID(localID) -} - -func (br *IMBridge) GetPuppetByGUID(guid string) *Puppet { - return br.GetPuppetByLocalID(imessage.ParseIdentifier(guid).LocalID) -} - -func (br *IMBridge) GetPuppetByLocalID(id string) *Puppet { - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - puppet, ok := br.puppets[id] - if !ok { - dbPuppet := br.DB.Puppet.Get(id) - if dbPuppet == nil { - dbPuppet = br.DB.Puppet.New() - dbPuppet.ID = id - dbPuppet.Insert() - } - puppet = br.NewPuppet(dbPuppet) - br.puppets[puppet.ID] = puppet - } - return puppet -} - -func (br *IMBridge) GetAllPuppets() []*Puppet { - return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll()) -} - -func (br *IMBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet { - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - output := make([]*Puppet, len(dbPuppets)) - for index, dbPuppet := range dbPuppets { - if dbPuppet == nil { - continue - } - puppet, ok := br.puppets[dbPuppet.ID] - if !ok { - puppet = br.NewPuppet(dbPuppet) - br.puppets[dbPuppet.ID] = puppet - } - output[index] = puppet - } - return output -} - -func (br *IMBridge) FormatPuppetMXID(guid string) id.UserID { - return id.NewUserID( - br.Config.Bridge.FormatUsername(guid), - br.Config.Homeserver.Domain) -} - -func (br *IMBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet { - mxid := br.FormatPuppetMXID(dbPuppet.ID) - return &Puppet{ - Puppet: dbPuppet, - bridge: br, - log: br.Log.Sub(fmt.Sprintf("Puppet/%s", dbPuppet.ID)), - - MXID: mxid, - Intent: br.AS.Intent(mxid), - } -} - -type Puppet struct { - *database.Puppet - - bridge *IMBridge - log log.Logger - - typingIn id.RoomID - typingAt int64 - - MXID id.UserID - Intent *appservice.IntentAPI -} - -var _ bridge.Ghost = (*Puppet)(nil) -var _ bridge.GhostWithProfile = (*Puppet)(nil) - -func (puppet *Puppet) GetDisplayname() string { - return puppet.Displayname -} - -func (puppet *Puppet) GetAvatarURL() id.ContentURI { - return puppet.AvatarURL -} - -func (puppet *Puppet) CustomIntent() *appservice.IntentAPI { - return nil -} - -func (puppet *Puppet) SwitchCustomMXID(accessToken string, userID id.UserID) error { - panic("Puppet.SwitchCustomMXID is not implemented") -} - -func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI { - return puppet.Intent -} - -func (puppet *Puppet) GetMXID() id.UserID { - return puppet.MXID -} - -func (puppet *Puppet) UpdateName(contact *imessage.Contact) bool { - if puppet.NameOverridden { - // Never replace custom names with contact list names - return false - } else if puppet.Displayname != "" && !contact.HasName() { - // Don't update displayname if there's no contact list name available - return false - } - return puppet.UpdateNameDirect(contact.Name()) -} - -func (puppet *Puppet) UpdateNameDirect(name string) bool { - if len(name) == 0 { - // TODO format if phone numbers - name = puppet.ID - } - newName := puppet.bridge.Config.Bridge.FormatDisplayname(name) - if puppet.Displayname != newName { - err := puppet.Intent.SetDisplayName(newName) - if err == nil { - puppet.Displayname = newName - go puppet.updatePortalName() - return true - } else { - puppet.log.Warnln("Failed to set display name:", err) - } - } - return false -} - -func (puppet *Puppet) UpdateAvatar(contact *imessage.Contact) bool { - if contact == nil { - return false - } - return puppet.UpdateAvatarFromBytes(contact.Avatar) -} - -func (puppet *Puppet) UpdateAvatarFromBytes(avatar []byte) bool { - if avatar == nil { - return false - } - avatarHash := sha256.Sum256(avatar) - if puppet.AvatarHash == nil || *puppet.AvatarHash != avatarHash { - puppet.AvatarHash = &avatarHash - mimeTypeData := mimetype.Detect(avatar) - resp, err := puppet.Intent.UploadBytesWithName(avatar, mimeTypeData.String(), "avatar"+mimeTypeData.Extension()) - if err != nil { - puppet.AvatarHash = nil - puppet.log.Warnln("Failed to upload avatar:", err) - return false - } - return puppet.UpdateAvatarFromMXC(resp.ContentURI) - } - return false -} - -func (puppet *Puppet) UpdateAvatarFromMXC(mxc id.ContentURI) bool { - puppet.AvatarURL = mxc - err := puppet.Intent.SetAvatarURL(puppet.AvatarURL) - if err != nil { - puppet.AvatarHash = nil - puppet.log.Warnln("Failed to set avatar:", err) - return false - } - go puppet.updatePortalAvatar() - return true -} - -func applyMeta(portal *Portal, meta func(portal *Portal)) { - if portal == nil { - return - } - portal.roomCreateLock.Lock() - defer portal.roomCreateLock.Unlock() - meta(portal) -} - -func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) { - imID := imessage.Identifier{Service: "iMessage", LocalID: puppet.ID}.String() - applyMeta(puppet.bridge.GetPortalByGUID(imID), meta) - smsID := imessage.Identifier{Service: "SMS", LocalID: puppet.ID}.String() - applyMeta(puppet.bridge.GetPortalByGUID(smsID), meta) -} - -func (puppet *Puppet) updatePortalAvatar() { - puppet.updatePortalMeta(func(portal *Portal) { - if len(portal.MXID) > 0 && portal.shouldSetDMRoomMetadata() { - _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, puppet.AvatarURL) - if err != nil { - portal.log.Warnln("Failed to set avatar:", err) - } - } - portal.AvatarURL = puppet.AvatarURL - portal.AvatarHash = puppet.AvatarHash - portal.Update(nil) - portal.UpdateBridgeInfo() - }) -} - -func (puppet *Puppet) updatePortalName() { - puppet.updatePortalMeta(func(portal *Portal) { - if len(portal.MXID) > 0 && portal.shouldSetDMRoomMetadata() { - _, err := portal.MainIntent().SetRoomName(portal.MXID, puppet.Displayname) - if err != nil { - portal.log.Warnln("Failed to set name:", err) - } - } - portal.Name = puppet.Displayname - portal.Update(nil) - portal.UpdateBridgeInfo() - }) -} - -func (puppet *Puppet) Sync() { - err := puppet.Intent.EnsureRegistered() - if err != nil { - puppet.log.Errorln("Failed to ensure registered:", err) - } - - contact, err := puppet.bridge.IM.GetContactInfo(puppet.ID) - if err != nil && !errors.Is(err, ipc.ErrUnknownCommand) { - puppet.log.Errorln("Failed to get contact info:", err) - } - - puppet.SyncWithContact(contact) -} - -var avatarDownloadClient = http.Client{ - Timeout: 30 * time.Second, -} - -func (puppet *Puppet) backgroundAvatarUpdate(url string) { - puppet.log.Debugfln("Updating avatar from remote URL in background") - var resp *http.Response - var body []byte - var err error - defer func() { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - }() - if resp, err = avatarDownloadClient.Get(url); err != nil { - puppet.log.Warnfln("Failed to request override avatar from %s: %v", url, err) - } else if body, err = io.ReadAll(resp.Body); err != nil { - puppet.log.Warnfln("Failed to read override avatar from %s: %v", url, err) - } else { - puppet.UpdateAvatarFromBytes(body) - } -} - -func (puppet *Puppet) syncAvatarWithRawURL(rawURL string) { - mxc, err := id.ParseContentURI(rawURL) - if err != nil { - go puppet.backgroundAvatarUpdate(rawURL) - return - } - puppet.UpdateAvatarFromMXC(mxc) -} - -func (puppet *Puppet) SyncWithProfileOverride(override ProfileOverride) { - if len(override.Displayname) > 0 { - puppet.UpdateNameDirect(override.Displayname) - } - if len(override.PhotoURL) > 0 { - puppet.syncAvatarWithRawURL(override.PhotoURL) - } -} - -func (puppet *Puppet) UpdateContactInfo() bool { - if puppet.bridge.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { - return false - } - if !puppet.ContactInfoSet { - contactInfo := map[string]any{ - "com.beeper.bridge.remote_id": puppet.ID, - } - if strings.ContainsRune(puppet.ID, '@') { - contactInfo["com.beeper.bridge.identifiers"] = []string{fmt.Sprintf("mailto:%s", puppet.ID)} - } else { - contactInfo["com.beeper.bridge.identifiers"] = []string{fmt.Sprintf("tel:%s", puppet.ID)} - } - if puppet.bridge.Config.IMessage.Platform == "android" { - contactInfo["com.beeper.bridge.service"] = "androidsms" - contactInfo["com.beeper.bridge.network"] = "androidsms" - } else { - contactInfo["com.beeper.bridge.service"] = "imessagecloud" - contactInfo["com.beeper.bridge.network"] = "imessage" - } - err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo) - if err != nil { - puppet.log.Warnln("Failed to store custom contact info in profile:", err) - return false - } else { - puppet.ContactInfoSet = true - return true - } - } - return false -} - -func (puppet *Puppet) SyncWithContact(contact *imessage.Contact) { - update := false - update = puppet.UpdateName(contact) || update - update = puppet.UpdateAvatar(contact) || update - update = puppet.UpdateContactInfo() || update - if update { - puppet.Update() - } -} diff --git a/rustpush/open-absinthe/Cargo.lock b/rustpush/open-absinthe/Cargo.lock new file mode 100644 index 00000000..45934e04 --- /dev/null +++ b/rustpush/open-absinthe/Cargo.lock @@ -0,0 +1,1356 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4db6758c546e6f81f265638c980e5e84dfbda80cfd8e89e02f83454c8e8124bd" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "open-absinthe" +version = "1.0.0" +dependencies = [ + "base64 0.21.7", + "goblin", + "log", + "native-tls", + "plist", + "rand", + "serde", + "serde_json", + "sha1", + "unicorn-engine", + "ureq", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicorn-engine" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16b5d5186d9400cd3bf9f892ed826cb724f69b906bbe66811527df73eaf7a33" +dependencies = [ + "bindgen", + "cc", + "cmake", + "pkg-config", + "unicorn-engine-sys", +] + +[[package]] +name = "unicorn-engine-sys" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685282714d35a6fbfed0ae14d81bb11c91838bc7a165cac778321679a7bb91d8" +dependencies = [ + "bindgen", + "cc", + "cmake", + "heck", + "pkg-config", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/rustpush/open-absinthe/Cargo.toml b/rustpush/open-absinthe/Cargo.toml new file mode 100644 index 00000000..dd64b27c --- /dev/null +++ b/rustpush/open-absinthe/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "open-absinthe" +version = "1.0.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.219", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +base64 = "0.21" +unicorn-engine = "2.1.5" +goblin = "0.10" +ureq = { version = "2.9", features = ["native-tls", "json"] } +native-tls = "0.2" +sha1 = "0.10" +sha2 = "0.10" +rand = "0.8" +log = "0.4" + +# Optional: native NAC via AAAbsintheContext (macOS 13+). +# When the `native-nac` feature is enabled AND the build target is macOS, +# `ValidationCtx::new()` eagerly computes the validation data via +# `nac_validation::generate_validation_data()` (AppleAccount.framework's +# AAAbsintheContext), caches it, and returns it from `sign()` — bypassing +# the unicorn XNU emulator entirely. `key_establishment()` becomes a no-op. +# +# nac-validation is a standalone workspace member (depends on libc + thiserror +# + cc only — no rustpush, no open-absinthe), so this dep direction does NOT +# create a cycle. The path reaches the repo-root `nac-validation/` from the +# built copy of this crate at `third_party/rustpush-upstream/open-absinthe/`. +[target.'cfg(target_os = "macos")'.dependencies] +nac-validation = { path = "../../../nac-validation", optional = true } + +[dev-dependencies] +plist = "1.7" +ureq = { version = "2.9", features = ["native-tls"] } +native-tls = "0.2" + +[[bin]] +name = "enrich_hw_key" +path = "src/bin/enrich_hw_key.rs" + +[features] +serde = [] +native-nac = ["dep:nac-validation"] diff --git a/rustpush/open-absinthe/README.md b/rustpush/open-absinthe/README.md new file mode 100644 index 00000000..df6aebfd --- /dev/null +++ b/rustpush/open-absinthe/README.md @@ -0,0 +1,5 @@ +# Open Absinthe + +Cross-platform NAC (Network Attestation Check) validation using x86_64 emulation. Runs Apple's `IMDAppleServices` binary inside [unicorn-engine](https://www.unicorn-engine.org/), hooking CoreFoundation, IOKit, and DiskArbitration calls and feeding them hardware data extracted from a real Mac. This lets the iMessage bridge generate valid Apple validation data on Linux without a macOS runtime. + +Based on the approach from [nacserver](https://github.com/JJTech0130/nacserver), ported to Rust. \ No newline at end of file diff --git a/rustpush/open-absinthe/build.rs b/rustpush/open-absinthe/build.rs new file mode 100644 index 00000000..d8021320 --- /dev/null +++ b/rustpush/open-absinthe/build.rs @@ -0,0 +1,40 @@ +use std::env; +use std::process::Command; + +fn main() { + // Declare the custom cfg so cargo doesn't warn about it + println!("cargo:rustc-check-cfg=cfg(has_xnu_encrypt)"); + + let target = env::var("TARGET").unwrap_or_default(); + + // Only compile the XNU encrypt assembly on x86_64 Linux targets + if !target.starts_with("x86_64") || !target.contains("linux") { + return; + } + + let out_dir = env::var("OUT_DIR").unwrap(); + let asm_src = "src/asm/encrypt.s"; + let obj_path = format!("{}/encrypt.o", out_dir); + let lib_path = format!("{}/libxnu_encrypt.a", out_dir); + + // Assemble encrypt.s → encrypt.o + let status = Command::new("cc") + .args(["-c", "-o", &obj_path, asm_src]) + .status() + .expect("Failed to run assembler on encrypt.s"); + assert!(status.success(), "Assembly of encrypt.s failed"); + + // Create static library + let status = Command::new("ar") + .args(["rcs", &lib_path, &obj_path]) + .status() + .expect("Failed to run ar"); + assert!(status.success(), "ar failed to create libxnu_encrypt.a"); + + println!("cargo:rustc-link-search=native={}", out_dir); + println!("cargo:rustc-link-lib=static=xnu_encrypt"); + println!("cargo:rerun-if-changed={}", asm_src); + + // Tell the rest of the crate that the encrypt function is available + println!("cargo:rustc-cfg=has_xnu_encrypt"); +} diff --git a/rustpush/open-absinthe/src/asm/encrypt.s b/rustpush/open-absinthe/src/asm/encrypt.s new file mode 100644 index 00000000..44c5d910 --- /dev/null +++ b/rustpush/open-absinthe/src/asm/encrypt.s @@ -0,0 +1,14958 @@ +.intel_syntax noprefix +.section .data + .align 0x10 + encryption_data: + .byte 0x44,0x48,0x46,0x57,0x00,0x01,0x00,0x00,0x18,0x60,0x00,0x00,0xE5,0x01,0x00,0x00,0x00,0x00,0x11,0x11,0x01,0x01,0x00,0x00,0xA7,0x19,0x79,0x37,0xFC,0xCF,0x07,0xC2,0xA3,0x9C,0xD4,0x92,0x80,0x82,0xC0,0xB8,0x46,0xE9,0x63,0x35,0xEB,0x70,0xE1,0x7C,0xF1,0x3D,0x5F,0xA5,0x30,0xC9,0x62,0x3F,0xDF,0x2B,0x89,0x59,0x9B,0x2E,0xF4,0xDC,0x96,0xB9,0xC7,0x1D,0x4C,0x38,0xA4,0x64,0x83,0x6C,0xC5,0x81,0x74,0xB2,0x61,0x0D,0x7F,0xE6,0x58,0xA9,0x9F,0xC3,0x17,0x76,0xE3,0x88,0x16,0xC4,0xD3,0x0E,0xBF,0x06,0x0C,0x36,0x1F,0xFA,0xC6,0x3E,0xB3,0xB0,0xF7,0x01,0xDD,0x90,0xDB,0x0A,0x1B,0x8C,0x42,0x9A,0x8F,0x24,0xBE,0x20,0xFB,0x18,0x34,0xF6,0xCB,0xB7,0xDA,0xCD,0x08,0xD6,0x86,0x67,0x94,0x3C,0x52,0x09,0xFE,0x14,0xAE,0xCE,0x25,0x5A,0x26,0x05,0x8D,0x56,0x11,0x5B,0x2F,0x78,0xF9,0xC8,0x50,0x75,0xD0,0xB5,0x57,0x2C,0x4F,0xAD,0x73,0xFD,0xE8,0xD7,0x4D,0x6B,0x3B,0x54,0xA6,0x93,0x41,0xDE,0xE2,0x2A,0x40,0x55,0x5C,0xE4,0xF2,0x95,0x6D,0x33,0xD9,0x6A,0xA1,0x99,0xEF,0x4E,0x12,0xB6,0x77,0x47,0xA0,0xF0,0x68,0x9D,0xEA,0x87,0x66,0x2D,0x1A,0xD1,0xFF,0xD5,0x72,0x04,0xF8,0x8B,0x91,0x02,0x97,0xF5,0xAA,0xE5,0x44,0x8E,0x13,0xA2,0xBD,0x39,0xCC,0xBC,0x65,0xEE,0x0B,0x0F,0x98,0x3A,0x21,0xA8,0xF3,0x53,0xD2,0x4A,0x69,0x4B,0x85,0x32,0x1C,0x49,0x27,0x29,0xC1,0xE0,0x22,0xB4,0x84,0x7B,0x00,0xE7,0xBB,0xB1,0x1E,0xCA,0x7E,0x43,0x5E,0xBA,0x15,0xAF,0x51,0xEC,0x48,0x10,0x03,0x7A,0x28,0x23,0x60,0x7D,0x31,0x6E,0xD8,0x5D,0x71,0x8A,0xAB,0x45,0xED,0x6F,0x9E,0xAC,0xA5,0x1F,0xE1,0x5C,0xF8,0xA0,0xB3,0xCA,0x0B,0x01,0xAE,0x7A,0xCE,0xF3,0xEE,0x0A,0xC1,0x3A,0x1B,0xF5,0x5D,0xDF,0x2E,0x1C,0x98,0x93,0xD0,0xCD,0x81,0xDE,0x68,0xED,0x28,0x8A,0x91,0x18,0x43,0xE3,0x62,0xFA,0x0D,0x89,0x7C,0x0C,0xD5,0x5E,0xBB,0xBF,0x71,0x50,0x92,0x04,0x34,0xCB,0xB0,0x57,0xD9,0xFB,0x35,0x82,0xAC,0xF9,0x97,0x99,0xD8,0x2D,0x5A,0x37,0xD6,0x9D,0xAA,0x61,0x5F,0xFE,0xA2,0x06,0xC7,0xF7,0x10,0x40,0x27,0x45,0x1A,0x55,0xF4,0x3E,0xA3,0x12,0x4F,0x65,0xC2,0xB4,0x48,0x3B,0x21,0xB2,0x58,0x67,0xFD,0xDB,0x8B,0xE4,0x16,0x23,0x60,0x05,0xE7,0x9C,0xFF,0x1D,0xC3,0x4D,0x42,0x25,0xDD,0x83,0x69,0xDA,0x11,0x29,0xF1,0x6E,0x52,0x9A,0xF0,0xE5,0xEC,0x54,0x36,0xD7,0x24,0x8C,0xE2,0xB9,0x4E,0xA4,0x84,0x46,0x7B,0x07,0x6A,0x7D,0xB8,0x66,0xA1,0xEB,0x9F,0xC8,0x49,0x78,0xE0,0xC5,0x1E,0x7E,0x95,0xEA,0x96,0xB5,0x3D,0xE6,0xBC,0x86,0xAF,0x4A,0x76,0x8E,0x03,0x00,0x53,0x38,0xA6,0x74,0x63,0xBE,0x0F,0xB6,0xF2,0x2A,0x3F,0x94,0x0E,0x90,0x4B,0xA8,0x47,0xB1,0x6D,0x20,0x6B,0xBA,0xAB,0x3C,0x26,0x09,0x77,0xAD,0xFC,0x88,0x14,0xD4,0x6F,0x9B,0x39,0xE9,0x2B,0x9E,0x44,0x6C,0xCF,0x56,0xE8,0x19,0x2F,0x73,0xA7,0xC6,0x33,0xDC,0x75,0x31,0xC4,0x02,0xD1,0xBD,0x13,0x2C,0x64,0x22,0x30,0x32,0x70,0x08,0x17,0xA9,0xC9,0x87,0x4C,0x7F,0xB7,0x72,0x41,0x8D,0xEF,0x15,0x80,0x79,0xD2,0x8F,0xF6,0x59,0xD3,0x85,0x5B,0xC0,0x51,0xCC,0xEC,0x19,0x6E,0x03,0xE2,0xA9,0x9E,0x55,0x6B,0xCA,0x96,0x32,0xF3,0xC3,0x24,0x74,0x13,0x71,0x2E,0x61,0xC0,0x0A,0x97,0x26,0x7B,0x51,0xF6,0x80,0x7C,0x0F,0x15,0x86,0x6C,0x53,0xC9,0xEF,0xBF,0xD0,0x22,0x17,0x54,0x31,0xD3,0xA8,0xCB,0x29,0xF7,0x79,0x76,0x11,0xE9,0xB7,0x5D,0xEE,0x25,0x1D,0xC5,0x5A,0x66,0xAE,0xC4,0xD1,0xD8,0x60,0x91,0x2B,0xD5,0x68,0xCC,0x94,0x87,0xFE,0x3F,0x35,0x9A,0x4E,0xFA,0xC7,0xDA,0x3E,0xF5,0x0E,0x2F,0xC1,0x69,0xEB,0x1A,0x28,0xAC,0xA7,0xE4,0xF9,0xB5,0xEA,0x5C,0xD9,0x1C,0xBE,0xA5,0x2C,0x77,0xD7,0x56,0xCE,0x39,0xBD,0x48,0x38,0xE1,0x6A,0x8F,0x8B,0x45,0x64,0xA6,0x30,0x00,0xFF,0x84,0x63,0xED,0xCF,0x01,0xB6,0x98,0xCD,0xA3,0xAD,0x12,0x3D,0x43,0x99,0xC8,0xBC,0x20,0xE0,0x5B,0xAF,0x0D,0xDD,0x1F,0xAA,0x70,0x58,0xFB,0x62,0xDC,0x2D,0x1B,0x47,0x93,0xF2,0x07,0xE8,0x41,0x05,0xF0,0x36,0xE5,0x89,0x27,0x18,0x50,0x16,0x04,0x06,0x44,0x3C,0x23,0x9D,0xFD,0xB3,0x78,0x4B,0x83,0x46,0x75,0xB9,0xDB,0x21,0xB4,0x4D,0xE6,0xBB,0xC2,0x6D,0xE7,0xB1,0x6F,0xF4,0x65,0xF8,0x02,0xE3,0x10,0xB8,0xD6,0x8D,0x7A,0x90,0xB0,0x72,0x4F,0x33,0x5E,0x49,0x8C,0x52,0x95,0xDF,0xAB,0xFC,0x7D,0x4C,0xD4,0xF1,0x2A,0x4A,0xA1,0xDE,0xA2,0x81,0x09,0xD2,0x88,0xB2,0x9B,0x7E,0x42,0xBA,0x37,0x34,0x67,0x0C,0x92,0x40,0x57,0x8A,0x3B,0x82,0xC6,0x1E,0x0B,0xA0,0x3A,0xA4,0x7F,0x9C,0x73,0x85,0x59,0x14,0x5F,0x8E,0x9F,0x08,0x85,0x3C,0x8D,0x50,0x47,0x95,0x0B,0x60,0x33,0x30,0xBD,0x45,0x79,0x9C,0xB5,0x8F,0x0F,0x98,0x89,0x58,0x13,0x5E,0x82,0x74,0x9B,0x78,0xA3,0x3D,0xA7,0x0C,0x19,0xC1,0x55,0x8B,0x4E,0x59,0x34,0x48,0x75,0xB7,0x97,0x7D,0x8A,0xD1,0xBF,0x17,0xE4,0x05,0xD5,0x0E,0x86,0xA5,0xD9,0xA6,0x4D,0x2D,0xF6,0xD3,0x4B,0x7A,0xFB,0xAC,0xD8,0x92,0x41,0x84,0x4C,0x7F,0xB4,0xFA,0x9A,0x24,0x3B,0x43,0x01,0x03,0x11,0x57,0x1F,0x20,0xFF,0x62,0xF3,0x68,0xB6,0xE0,0x6A,0xC5,0xBC,0xE1,0x4A,0xB3,0x26,0xDC,0xBE,0x72,0x5F,0x77,0xAD,0x18,0xDA,0x0A,0xA8,0x5C,0xE7,0x27,0xBB,0xCF,0x9E,0x44,0x3A,0x15,0x8E,0xE2,0x31,0xF7,0x02,0x46,0xEF,0x00,0xF5,0x94,0x40,0x1C,0x2A,0xDB,0x65,0xFC,0x8C,0x88,0x6D,0xE6,0x3F,0x4F,0xBA,0x3E,0xC9,0x51,0xD0,0x70,0x2B,0xA2,0xB9,0x1B,0xAA,0xA4,0xCA,0x9F,0xB1,0x06,0xC8,0xEA,0x64,0x83,0xF8,0x07,0x37,0xA1,0x63,0x42,0x39,0xDD,0xC0,0xFD,0x49,0x9D,0x32,0x38,0xF9,0x80,0x93,0xCB,0x6F,0xD2,0x2C,0x96,0xDE,0x5B,0xED,0xB2,0xFE,0xE3,0xA0,0xAB,0x2F,0x1D,0xEC,0x6E,0xC6,0x28,0x09,0xF2,0x7E,0xF0,0x2E,0xCC,0xAF,0xD4,0x36,0x53,0x10,0x25,0xD7,0xB8,0xE8,0xCE,0x54,0x6B,0x67,0xDF,0xD6,0xC3,0xA9,0x61,0x5D,0xC2,0x1A,0x22,0xE9,0x5A,0xB0,0xEE,0x16,0x71,0x73,0x23,0xC4,0xF4,0x35,0x91,0xCD,0x6C,0x52,0x99,0xAE,0xE5,0x04,0x69,0x1E,0xEB,0x81,0x12,0x08,0x7B,0x87,0xF1,0x56,0x7C,0x21,0x90,0x0D,0xC7,0x66,0x29,0x76,0x14,0x50,0x75,0xF9,0xC8,0x2F,0x78,0x11,0x5B,0x8D,0x56,0x26,0x05,0x25,0x5A,0xAE,0xCE,0xFE,0x14,0x52,0x09,0x94,0x3C,0x86,0x67,0x08,0xD6,0xDA,0xCD,0xCB,0xB7,0x34,0xF6,0xFB,0x18,0xBE,0x20,0x8F,0x24,0x42,0x9A,0x1B,0x8C,0xDB,0x0A,0xDD,0x90,0xF7,0x01,0xB3,0xB0,0xC6,0x3E,0x1F,0xFA,0x0C,0x36,0xBF,0x06,0xD3,0x0E,0x16,0xC4,0xE3,0x88,0x17,0x76,0x9F,0xC3,0x58,0xA9,0x7F,0xE6,0x61,0x0D,0x74,0xB2,0xC5,0x81,0x83,0x6C,0xA4,0x64,0x4C,0x38,0xC7,0x1D,0x96,0xB9,0xF4,0xDC,0x9B,0x2E,0x89,0x59,0xDF,0x2B,0x62,0x3F,0x30,0xC9,0x5F,0xA5,0xF1,0x3D,0xE1,0x7C,0xEB,0x70,0x63,0x35,0x46,0xE9,0xC0,0xB8,0x80,0x82,0xD4,0x92,0xA3,0x9C,0x07,0xC2,0xFC,0xCF,0x79,0x37,0xA7,0x19,0x9E,0xAC,0xED,0x6F,0xAB,0x45,0x71,0x8A,0xD8,0x5D,0x31,0x6E,0x60,0x7D,0x28,0x23,0x03,0x7A,0x48,0x10,0x51,0xEC,0x15,0xAF,0x5E,0xBA,0x7E,0x43,0x1E,0xCA,0xBB,0xB1,0x00,0xE7,0x84,0x7B,0x22,0xB4,0xC1,0xE0,0x27,0x29,0x1C,0x49,0x85,0x32,0x69,0x4B,0xD2,0x4A,0xF3,0x53,0x21,0xA8,0x98,0x3A,0x0B,0x0F,0x65,0xEE,0xCC,0xBC,0xBD,0x39,0x13,0xA2,0x44,0x8E,0xAA,0xE5,0x97,0xF5,0x91,0x02,0xF8,0x8B,0x72,0x04,0xFF,0xD5,0x1A,0xD1,0x66,0x2D,0xEA,0x87,0x68,0x9D,0xA0,0xF0,0x77,0x47,0x12,0xB6,0xEF,0x4E,0xA1,0x99,0xD9,0x6A,0x6D,0x33,0xF2,0x95,0x5C,0xE4,0x40,0x55,0xE2,0x2A,0x41,0xDE,0xA6,0x93,0x3B,0x54,0x4D,0x6B,0xE8,0xD7,0x73,0xFD,0x4F,0xAD,0x57,0x2C,0xD0,0xB5,0x0A,0xEE,0xF3,0xCE,0x7A,0xAE,0x01,0x0B,0xCA,0xB3,0xA0,0xF8,0x5C,0xE1,0x1F,0xA5,0xED,0x68,0xDE,0x81,0xCD,0xD0,0x93,0x98,0x1C,0x2E,0xDF,0x5D,0xF5,0x1B,0x3A,0xC1,0xBF,0xBB,0x5E,0xD5,0x0C,0x7C,0x89,0x0D,0xFA,0x62,0xE3,0x43,0x18,0x91,0x8A,0x28,0x99,0x97,0xF9,0xAC,0x82,0x35,0xFB,0xD9,0x57,0xB0,0xCB,0x34,0x04,0x92,0x50,0x71,0x40,0x10,0xF7,0xC7,0x06,0xA2,0xFE,0x5F,0x61,0xAA,0x9D,0xD6,0x37,0x5A,0x2D,0xD8,0xB2,0x21,0x3B,0x48,0xB4,0xC2,0x65,0x4F,0x12,0xA3,0x3E,0xF4,0x55,0x1A,0x45,0x27,0x4D,0xC3,0x1D,0xFF,0x9C,0xE7,0x05,0x60,0x23,0x16,0xE4,0x8B,0xDB,0xFD,0x67,0x58,0x54,0xEC,0xE5,0xF0,0x9A,0x52,0x6E,0xF1,0x29,0x11,0xDA,0x69,0x83,0xDD,0x25,0x42,0x66,0xB8,0x7D,0x6A,0x07,0x7B,0x46,0x84,0xA4,0x4E,0xB9,0xE2,0x8C,0x24,0xD7,0x36,0xE6,0x3D,0xB5,0x96,0xEA,0x95,0x7E,0x1E,0xC5,0xE0,0x78,0x49,0xC8,0x9F,0xEB,0xA1,0xB6,0x0F,0xBE,0x63,0x74,0xA6,0x38,0x53,0x00,0x03,0x8E,0x76,0x4A,0xAF,0x86,0xBC,0x3C,0xAB,0xBA,0x6B,0x20,0x6D,0xB1,0x47,0xA8,0x4B,0x90,0x0E,0x94,0x3F,0x2A,0xF2,0x6C,0x44,0x9E,0x2B,0xE9,0x39,0x9B,0x6F,0xD4,0x14,0x88,0xFC,0xAD,0x77,0x09,0x26,0xBD,0xD1,0x02,0xC4,0x31,0x75,0xDC,0x33,0xC6,0xA7,0x73,0x2F,0x19,0xE8,0x56,0xCF,0x72,0xB7,0x7F,0x4C,0x87,0xC9,0xA9,0x17,0x08,0x70,0x32,0x30,0x22,0x64,0x2C,0x13,0xCC,0x51,0xC0,0x5B,0x85,0xD3,0x59,0xF6,0x8F,0xD2,0x79,0x80,0x15,0xEF,0x8D,0x41,0x56,0xCE,0x77,0xD7,0xA5,0x2C,0x1C,0xBE,0x8F,0x8B,0xE1,0x6A,0x48,0x38,0x39,0xBD,0x84,0x63,0x00,0xFF,0xA6,0x30,0x45,0x64,0xA3,0xAD,0x98,0xCD,0x01,0xB6,0xED,0xCF,0x87,0xFE,0xCC,0x94,0xD5,0x68,0x91,0x2B,0xDA,0x3E,0xFA,0xC7,0x9A,0x4E,0x3F,0x35,0x1A,0x28,0x69,0xEB,0x2F,0xC1,0xF5,0x0E,0x5C,0xD9,0xB5,0xEA,0xE4,0xF9,0xAC,0xA7,0x22,0x17,0xBF,0xD0,0xC9,0xEF,0x6C,0x53,0xF7,0x79,0xCB,0x29,0xD3,0xA8,0x54,0x31,0x25,0x1D,0x5D,0xEE,0xE9,0xB7,0x76,0x11,0xD8,0x60,0xC4,0xD1,0x66,0xAE,0xC5,0x5A,0x9E,0x55,0xE2,0xA9,0x6E,0x03,0xEC,0x19,0x24,0x74,0xF3,0xC3,0x96,0x32,0x6B,0xCA,0x97,0x26,0xC0,0x0A,0x2E,0x61,0x13,0x71,0x15,0x86,0x7C,0x0F,0xF6,0x80,0x7B,0x51,0x37,0x34,0x42,0xBA,0x9B,0x7E,0x88,0xB2,0x3B,0x82,0x57,0x8A,0x92,0x40,0x67,0x0C,0x7F,0x9C,0x3A,0xA4,0x0B,0xA0,0xC6,0x1E,0x9F,0x08,0x5F,0x8E,0x59,0x14,0x73,0x85,0x7A,0x90,0xD6,0x8D,0x10,0xB8,0x02,0xE3,0x8C,0x52,0x5E,0x49,0x4F,0x33,0xB0,0x72,0xD4,0xF1,0x7D,0x4C,0xAB,0xFC,0x95,0xDF,0x09,0xD2,0xA2,0x81,0xA1,0xDE,0x2A,0x4A,0x44,0x3C,0x04,0x06,0x50,0x16,0x27,0x18,0x83,0x46,0x78,0x4B,0xFD,0xB3,0x23,0x9D,0xE6,0xBB,0xB4,0x4D,0xDB,0x21,0x75,0xB9,0x65,0xF8,0x6F,0xF4,0xE7,0xB1,0xC2,0x6D,0x20,0xE0,0xC8,0xBC,0x43,0x99,0x12,0x3D,0x70,0x58,0x1F,0xAA,0x0D,0xDD,0x5B,0xAF,0x93,0xF2,0x1B,0x47,0xDC,0x2D,0xFB,0x62,0xE5,0x89,0xF0,0x36,0x41,0x05,0x07,0xE8,0x85,0x3C,0x8D,0x50,0x47,0x95,0x0B,0x60,0x33,0x30,0xBD,0x45,0x79,0x9C,0xB5,0x8F,0x0F,0x98,0x89,0x58,0x13,0x5E,0x82,0x74,0x9B,0x78,0xA3,0x3D,0xA7,0x0C,0x19,0xC1,0x55,0x8B,0x4E,0x59,0x34,0x48,0x75,0xB7,0x97,0x7D,0x8A,0xD1,0xBF,0x17,0xE4,0x05,0xD5,0x0E,0x86,0xA5,0xD9,0xA6,0x4D,0x2D,0xF6,0xD3,0x4B,0x7A,0xFB,0xAC,0xD8,0x92,0x41,0x84,0x4C,0x7F,0xB4,0xFA,0x9A,0x24,0x3B,0x43,0x01,0x03,0x11,0x57,0x1F,0x20,0xFF,0x62,0xF3,0x68,0xB6,0xE0,0x6A,0xC5,0xBC,0xE1,0x4A,0xB3,0x26,0xDC,0xBE,0x72,0x5F,0x77,0xAD,0x18,0xDA,0x0A,0xA8,0x5C,0xE7,0x27,0xBB,0xCF,0x9E,0x44,0x3A,0x15,0x8E,0xE2,0x31,0xF7,0x02,0x46,0xEF,0x00,0xF5,0x94,0x40,0x1C,0x2A,0xDB,0x65,0xFC,0x8C,0x88,0x6D,0xE6,0x3F,0x4F,0xBA,0x3E,0xC9,0x51,0xD0,0x70,0x2B,0xA2,0xB9,0x1B,0xAA,0xA4,0xCA,0x9F,0xB1,0x06,0xC8,0xEA,0x64,0x83,0xF8,0x07,0x37,0xA1,0x63,0x42,0x39,0xDD,0xC0,0xFD,0x49,0x9D,0x32,0x38,0xF9,0x80,0x93,0xCB,0x6F,0xD2,0x2C,0x96,0xDE,0x5B,0xED,0xB2,0xFE,0xE3,0xA0,0xAB,0x2F,0x1D,0xEC,0x6E,0xC6,0x28,0x09,0xF2,0x7E,0xF0,0x2E,0xCC,0xAF,0xD4,0x36,0x53,0x10,0x25,0xD7,0xB8,0xE8,0xCE,0x54,0x6B,0x67,0xDF,0xD6,0xC3,0xA9,0x61,0x5D,0xC2,0x1A,0x22,0xE9,0x5A,0xB0,0xEE,0x16,0x71,0x73,0x23,0xC4,0xF4,0x35,0x91,0xCD,0x6C,0x52,0x99,0xAE,0xE5,0x04,0x69,0x1E,0xEB,0x81,0x12,0x08,0x7B,0x87,0xF1,0x56,0x7C,0x21,0x90,0x0D,0xC7,0x66,0x29,0x76,0x14,0x84,0x7B,0x00,0xE7,0xC1,0xE0,0x22,0xB4,0x1C,0x49,0x27,0x29,0x69,0x4B,0x85,0x32,0xF3,0x53,0xD2,0x4A,0x98,0x3A,0x21,0xA8,0x65,0xEE,0x0B,0x0F,0xBD,0x39,0xCC,0xBC,0xED,0x6F,0x9E,0xAC,0x71,0x8A,0xAB,0x45,0x31,0x6E,0xD8,0x5D,0x28,0x23,0x60,0x7D,0x48,0x10,0x03,0x7A,0x15,0xAF,0x51,0xEC,0x7E,0x43,0x5E,0xBA,0xBB,0xB1,0x1E,0xCA,0xD9,0x6A,0xA1,0x99,0xF2,0x95,0x6D,0x33,0x40,0x55,0x5C,0xE4,0x41,0xDE,0xE2,0x2A,0x3B,0x54,0xA6,0x93,0xE8,0xD7,0x4D,0x6B,0x4F,0xAD,0x73,0xFD,0xD0,0xB5,0x57,0x2C,0x44,0x8E,0x13,0xA2,0x97,0xF5,0xAA,0xE5,0xF8,0x8B,0x91,0x02,0xFF,0xD5,0x72,0x04,0x66,0x2D,0x1A,0xD1,0x68,0x9D,0xEA,0x87,0x77,0x47,0xA0,0xF0,0xEF,0x4E,0x12,0xB6,0xBE,0x20,0xFB,0x18,0x42,0x9A,0x8F,0x24,0xDB,0x0A,0x1B,0x8C,0xF7,0x01,0xDD,0x90,0xC6,0x3E,0xB3,0xB0,0x0C,0x36,0x1F,0xFA,0xD3,0x0E,0xBF,0x06,0xE3,0x88,0x16,0xC4,0xF9,0xC8,0x50,0x75,0x11,0x5B,0x2F,0x78,0x26,0x05,0x8D,0x56,0xAE,0xCE,0x25,0x5A,0x52,0x09,0xFE,0x14,0x86,0x67,0x94,0x3C,0xDA,0xCD,0x08,0xD6,0x34,0xF6,0xCB,0xB7,0x30,0xC9,0x62,0x3F,0xF1,0x3D,0x5F,0xA5,0xEB,0x70,0xE1,0x7C,0x46,0xE9,0x63,0x35,0x80,0x82,0xC0,0xB8,0xA3,0x9C,0xD4,0x92,0xFC,0xCF,0x07,0xC2,0xA7,0x19,0x79,0x37,0x9F,0xC3,0x17,0x76,0x7F,0xE6,0x58,0xA9,0x74,0xB2,0x61,0x0D,0x83,0x6C,0xC5,0x81,0x4C,0x38,0xA4,0x64,0x96,0xB9,0xC7,0x1D,0x9B,0x2E,0xF4,0xDC,0xDF,0x2B,0x89,0x59,0x14,0xD4,0xFC,0x88,0x77,0xAD,0x26,0x09,0x44,0x6C,0x2B,0x9E,0x39,0xE9,0x6F,0x9B,0xA7,0xC6,0x2F,0x73,0xE8,0x19,0xCF,0x56,0xD1,0xBD,0xC4,0x02,0x75,0x31,0x33,0xDC,0x70,0x08,0x30,0x32,0x64,0x22,0x13,0x2C,0xB7,0x72,0x4C,0x7F,0xC9,0x87,0x17,0xA9,0xD2,0x8F,0x80,0x79,0xEF,0x15,0x41,0x8D,0x51,0xCC,0x5B,0xC0,0xD3,0x85,0xF6,0x59,0x4E,0xA4,0xE2,0xB9,0x24,0x8C,0x36,0xD7,0xB8,0x66,0x6A,0x7D,0x7B,0x07,0x84,0x46,0xE0,0xC5,0x49,0x78,0x9F,0xC8,0xA1,0xEB,0x3D,0xE6,0x96,0xB5,0x95,0xEA,0x1E,0x7E,0x03,0x00,0x76,0x8E,0xAF,0x4A,0xBC,0x86,0x0F,0xB6,0x63,0xBE,0xA6,0x74,0x53,0x38,0x4B,0xA8,0x0E,0x90,0x3F,0x94,0xF2,0x2A,0xAB,0x3C,0x6B,0xBA,0x6D,0x20,0x47,0xB1,0xAA,0x61,0xD6,0x9D,0x5A,0x37,0xD8,0x2D,0x10,0x40,0xC7,0xF7,0xA2,0x06,0x5F,0xFE,0xA3,0x12,0xF4,0x3E,0x1A,0x55,0x27,0x45,0x21,0xB2,0x48,0x3B,0xC2,0xB4,0x4F,0x65,0x16,0x23,0x8B,0xE4,0xFD,0xDB,0x58,0x67,0xC3,0x4D,0xFF,0x1D,0xE7,0x9C,0x60,0x05,0x11,0x29,0x69,0xDA,0xDD,0x83,0x42,0x25,0xEC,0x54,0xF0,0xE5,0x52,0x9A,0xF1,0x6E,0xB3,0xCA,0xF8,0xA0,0xE1,0x5C,0xA5,0x1F,0xEE,0x0A,0xCE,0xF3,0xAE,0x7A,0x0B,0x01,0x2E,0x1C,0x5D,0xDF,0x1B,0xF5,0xC1,0x3A,0x68,0xED,0x81,0xDE,0xD0,0xCD,0x98,0x93,0x62,0xFA,0x43,0xE3,0x91,0x18,0x28,0x8A,0xBB,0xBF,0xD5,0x5E,0x7C,0x0C,0x0D,0x89,0xB0,0x57,0x34,0xCB,0x92,0x04,0x71,0x50,0x97,0x99,0xAC,0xF9,0x35,0x82,0xD9,0xFB,0xFF,0x00,0x63,0x84,0x64,0x45,0x30,0xA6,0xCD,0x98,0xAD,0xA3,0xCF,0xED,0xB6,0x01,0xD7,0x77,0xCE,0x56,0xBE,0x1C,0x2C,0xA5,0x6A,0xE1,0x8B,0x8F,0xBD,0x39,0x38,0x48,0xEB,0x69,0x28,0x1A,0x0E,0xF5,0xC1,0x2F,0xEA,0xB5,0xD9,0x5C,0xA7,0xAC,0xF9,0xE4,0x94,0xCC,0xFE,0x87,0x2B,0x91,0x68,0xD5,0xC7,0xFA,0x3E,0xDA,0x35,0x3F,0x4E,0x9A,0xEE,0x5D,0x1D,0x25,0x11,0x76,0xB7,0xE9,0xD1,0xC4,0x60,0xD8,0x5A,0xC5,0xAE,0x66,0xD0,0xBF,0x17,0x22,0x53,0x6C,0xEF,0xC9,0x29,0xCB,0x79,0xF7,0x31,0x54,0xA8,0xD3,0x0A,0xC0,0x26,0x97,0x71,0x13,0x61,0x2E,0x0F,0x7C,0x86,0x15,0x51,0x7B,0x80,0xF6,0xA9,0xE2,0x55,0x9E,0x19,0xEC,0x03,0x6E,0xC3,0xF3,0x74,0x24,0xCA,0x6B,0x32,0x96,0xA4,0x3A,0x9C,0x7F,0x1E,0xC6,0xA0,0x0B,0x8E,0x5F,0x08,0x9F,0x85,0x73,0x14,0x59,0xBA,0x42,0x34,0x37,0xB2,0x88,0x7E,0x9B,0x8A,0x57,0x82,0x3B,0x0C,0x67,0x40,0x92,0x4C,0x7D,0xF1,0xD4,0xDF,0x95,0xFC,0xAB,0x81,0xA2,0xD2,0x09,0x4A,0x2A,0xDE,0xA1,0x8D,0xD6,0x90,0x7A,0xE3,0x02,0xB8,0x10,0x49,0x5E,0x52,0x8C,0x72,0xB0,0x33,0x4F,0x4D,0xB4,0xBB,0xE6,0xB9,0x75,0x21,0xDB,0xF4,0x6F,0xF8,0x65,0x6D,0xC2,0xB1,0xE7,0x06,0x04,0x3C,0x44,0x18,0x27,0x16,0x50,0x4B,0x78,0x46,0x83,0x9D,0x23,0xB3,0xFD,0x47,0x1B,0xF2,0x93,0x62,0xFB,0x2D,0xDC,0x36,0xF0,0x89,0xE5,0xE8,0x07,0x05,0x41,0xBC,0xC8,0xE0,0x20,0x3D,0x12,0x99,0x43,0xAA,0x1F,0x58,0x70,0xAF,0x5B,0xDD,0x0D,0xE7,0x27,0xBB,0xCF,0x9E,0x44,0x3A,0x15,0x5F,0x77,0xAD,0x18,0xDA,0x0A,0xA8,0x5C,0xF5,0x94,0x40,0x1C,0x2A,0xDB,0x65,0xFC,0x8E,0xE2,0x31,0xF7,0x02,0x46,0xEF,0x00,0x3B,0x43,0x01,0x03,0x11,0x57,0x1F,0x20,0x41,0x84,0x4C,0x7F,0xB4,0xFA,0x9A,0x24,0xBC,0xE1,0x4A,0xB3,0x26,0xDC,0xBE,0x72,0xFF,0x62,0xF3,0x68,0xB6,0xE0,0x6A,0xC5,0x97,0x7D,0x8A,0xD1,0xBF,0x17,0xE4,0x05,0x55,0x8B,0x4E,0x59,0x34,0x48,0x75,0xB7,0xF6,0xD3,0x4B,0x7A,0xFB,0xAC,0xD8,0x92,0xD5,0x0E,0x86,0xA5,0xD9,0xA6,0x4D,0x2D,0x33,0x30,0xBD,0x45,0x79,0x9C,0xB5,0x8F,0x85,0x3C,0x8D,0x50,0x47,0x95,0x0B,0x60,0x9B,0x78,0xA3,0x3D,0xA7,0x0C,0x19,0xC1,0x0F,0x98,0x89,0x58,0x13,0x5E,0x82,0x74,0x52,0x99,0xAE,0xE5,0x04,0x69,0x1E,0xEB,0x73,0x23,0xC4,0xF4,0x35,0x91,0xCD,0x6C,0x21,0x90,0x0D,0xC7,0x66,0x29,0x76,0x14,0x81,0x12,0x08,0x7B,0x87,0xF1,0x56,0x7C,0x10,0x25,0xD7,0xB8,0xE8,0xCE,0x54,0x6B,0x7E,0xF0,0x2E,0xCC,0xAF,0xD4,0x36,0x53,0x1A,0x22,0xE9,0x5A,0xB0,0xEE,0x16,0x71,0x67,0xDF,0xD6,0xC3,0xA9,0x61,0x5D,0xC2,0xF9,0x80,0x93,0xCB,0x6F,0xD2,0x2C,0x96,0x39,0xDD,0xC0,0xFD,0x49,0x9D,0x32,0x38,0x2F,0x1D,0xEC,0x6E,0xC6,0x28,0x09,0xF2,0xDE,0x5B,0xED,0xB2,0xFE,0xE3,0xA0,0xAB,0xC9,0x51,0xD0,0x70,0x2B,0xA2,0xB9,0x1B,0x8C,0x88,0x6D,0xE6,0x3F,0x4F,0xBA,0x3E,0x64,0x83,0xF8,0x07,0x37,0xA1,0x63,0x42,0xAA,0xA4,0xCA,0x9F,0xB1,0x06,0xC8,0xEA,0xE7,0x00,0x7B,0x84,0xB4,0x22,0xE0,0xC1,0x29,0x27,0x49,0x1C,0x32,0x85,0x4B,0x69,0x4A,0xD2,0x53,0xF3,0xA8,0x21,0x3A,0x98,0x0F,0x0B,0xEE,0x65,0xBC,0xCC,0x39,0xBD,0xAC,0x9E,0x6F,0xED,0x45,0xAB,0x8A,0x71,0x5D,0xD8,0x6E,0x31,0x7D,0x60,0x23,0x28,0x7A,0x03,0x10,0x48,0xEC,0x51,0xAF,0x15,0xBA,0x5E,0x43,0x7E,0xCA,0x1E,0xB1,0xBB,0x99,0xA1,0x6A,0xD9,0x33,0x6D,0x95,0xF2,0xE4,0x5C,0x55,0x40,0x2A,0xE2,0xDE,0x41,0x93,0xA6,0x54,0x3B,0x6B,0x4D,0xD7,0xE8,0xFD,0x73,0xAD,0x4F,0x2C,0x57,0xB5,0xD0,0xA2,0x13,0x8E,0x44,0xE5,0xAA,0xF5,0x97,0x02,0x91,0x8B,0xF8,0x04,0x72,0xD5,0xFF,0xD1,0x1A,0x2D,0x66,0x87,0xEA,0x9D,0x68,0xF0,0xA0,0x47,0x77,0xB6,0x12,0x4E,0xEF,0x18,0xFB,0x20,0xBE,0x24,0x8F,0x9A,0x42,0x8C,0x1B,0x0A,0xDB,0x90,0xDD,0x01,0xF7,0xB0,0xB3,0x3E,0xC6,0xFA,0x1F,0x36,0x0C,0x06,0xBF,0x0E,0xD3,0xC4,0x16,0x88,0xE3,0x75,0x50,0xC8,0xF9,0x78,0x2F,0x5B,0x11,0x56,0x8D,0x05,0x26,0x5A,0x25,0xCE,0xAE,0x14,0xFE,0x09,0x52,0x3C,0x94,0x67,0x86,0xD6,0x08,0xCD,0xDA,0xB7,0xCB,0xF6,0x34,0x3F,0x62,0xC9,0x30,0xA5,0x5F,0x3D,0xF1,0x7C,0xE1,0x70,0xEB,0x35,0x63,0xE9,0x46,0xB8,0xC0,0x82,0x80,0x92,0xD4,0x9C,0xA3,0xC2,0x07,0xCF,0xFC,0x37,0x79,0x19,0xA7,0x76,0x17,0xC3,0x9F,0xA9,0x58,0xE6,0x7F,0x0D,0x61,0xB2,0x74,0x81,0xC5,0x6C,0x83,0x64,0xA4,0x38,0x4C,0x1D,0xC7,0xB9,0x96,0xDC,0xF4,0x2E,0x9B,0x59,0x89,0x2B,0xDF,0xC7,0xF7,0x10,0x40,0x5F,0xFE,0xA2,0x06,0xD6,0x9D,0xAA,0x61,0xD8,0x2D,0x5A,0x37,0x48,0x3B,0x21,0xB2,0x4F,0x65,0xC2,0xB4,0xF4,0x3E,0xA3,0x12,0x27,0x45,0x1A,0x55,0xFF,0x1D,0xC3,0x4D,0x60,0x05,0xE7,0x9C,0x8B,0xE4,0x16,0x23,0x58,0x67,0xFD,0xDB,0xF0,0xE5,0xEC,0x54,0xF1,0x6E,0x52,0x9A,0x69,0xDA,0x11,0x29,0x42,0x25,0xDD,0x83,0xCE,0xF3,0xEE,0x0A,0x0B,0x01,0xAE,0x7A,0xF8,0xA0,0xB3,0xCA,0xA5,0x1F,0xE1,0x5C,0x81,0xDE,0x68,0xED,0x98,0x93,0xD0,0xCD,0x5D,0xDF,0x2E,0x1C,0xC1,0x3A,0x1B,0xF5,0xD5,0x5E,0xBB,0xBF,0x0D,0x89,0x7C,0x0C,0x43,0xE3,0x62,0xFA,0x28,0x8A,0x91,0x18,0xAC,0xF9,0x97,0x99,0xD9,0xFB,0x35,0x82,0x34,0xCB,0xB0,0x57,0x71,0x50,0x92,0x04,0x2B,0x9E,0x44,0x6C,0x6F,0x9B,0x39,0xE9,0xFC,0x88,0x14,0xD4,0x26,0x09,0x77,0xAD,0xC4,0x02,0xD1,0xBD,0x33,0xDC,0x75,0x31,0x2F,0x73,0xA7,0xC6,0xCF,0x56,0xE8,0x19,0x4C,0x7F,0xB7,0x72,0x17,0xA9,0xC9,0x87,0x30,0x32,0x70,0x08,0x13,0x2C,0x64,0x22,0x5B,0xC0,0x51,0xCC,0xF6,0x59,0xD3,0x85,0x80,0x79,0xD2,0x8F,0x41,0x8D,0xEF,0x15,0x6A,0x7D,0xB8,0x66,0x84,0x46,0x7B,0x07,0xE2,0xB9,0x4E,0xA4,0x36,0xD7,0x24,0x8C,0x96,0xB5,0x3D,0xE6,0x1E,0x7E,0x95,0xEA,0x49,0x78,0xE0,0xC5,0xA1,0xEB,0x9F,0xC8,0x63,0xBE,0x0F,0xB6,0x53,0x38,0xA6,0x74,0x76,0x8E,0x03,0x00,0xBC,0x86,0xAF,0x4A,0x6B,0xBA,0xAB,0x3C,0x47,0xB1,0x6D,0x20,0x0E,0x90,0x4B,0xA8,0xF2,0x2A,0x3F,0x94,0xBF,0xD0,0x22,0x17,0x6C,0x53,0xC9,0xEF,0xCB,0x29,0xF7,0x79,0x54,0x31,0xD3,0xA8,0x5D,0xEE,0x25,0x1D,0x76,0x11,0xE9,0xB7,0xC4,0xD1,0xD8,0x60,0xC5,0x5A,0x66,0xAE,0xE2,0xA9,0x9E,0x55,0xEC,0x19,0x6E,0x03,0xF3,0xC3,0x24,0x74,0x6B,0xCA,0x96,0x32,0xC0,0x0A,0x97,0x26,0x13,0x71,0x2E,0x61,0x7C,0x0F,0x15,0x86,0x7B,0x51,0xF6,0x80,0x77,0xD7,0x56,0xCE,0x1C,0xBE,0xA5,0x2C,0xE1,0x6A,0x8F,0x8B,0x39,0xBD,0x48,0x38,0x00,0xFF,0x84,0x63,0x45,0x64,0xA6,0x30,0x98,0xCD,0xA3,0xAD,0xED,0xCF,0x01,0xB6,0xCC,0x94,0x87,0xFE,0x91,0x2B,0xD5,0x68,0xFA,0xC7,0xDA,0x3E,0x3F,0x35,0x9A,0x4E,0x69,0xEB,0x1A,0x28,0xF5,0x0E,0x2F,0xC1,0xB5,0xEA,0x5C,0xD9,0xAC,0xA7,0xE4,0xF9,0x04,0x06,0x44,0x3C,0x27,0x18,0x50,0x16,0x78,0x4B,0x83,0x46,0x23,0x9D,0xFD,0xB3,0xB4,0x4D,0xE6,0xBB,0x75,0xB9,0xDB,0x21,0x6F,0xF4,0x65,0xF8,0xC2,0x6D,0xE7,0xB1,0xC8,0xBC,0x20,0xE0,0x12,0x3D,0x43,0x99,0x1F,0xAA,0x70,0x58,0x5B,0xAF,0x0D,0xDD,0x1B,0x47,0x93,0xF2,0xFB,0x62,0xDC,0x2D,0xF0,0x36,0xE5,0x89,0x07,0xE8,0x41,0x05,0x42,0xBA,0x37,0x34,0x88,0xB2,0x9B,0x7E,0x57,0x8A,0x3B,0x82,0x67,0x0C,0x92,0x40,0x3A,0xA4,0x7F,0x9C,0xC6,0x1E,0x0B,0xA0,0x5F,0x8E,0x9F,0x08,0x73,0x85,0x59,0x14,0xD6,0x8D,0x7A,0x90,0x02,0xE3,0x10,0xB8,0x5E,0x49,0x8C,0x52,0xB0,0x72,0x4F,0x33,0x7D,0x4C,0xD4,0xF1,0x95,0xDF,0xAB,0xFC,0xA2,0x81,0x09,0xD2,0x2A,0x4A,0xA1,0xDE,0x50,0x8D,0x3C,0x85,0x60,0x0B,0x95,0x47,0x45,0xBD,0x30,0x33,0x8F,0xB5,0x9C,0x79,0x58,0x89,0x98,0x0F,0x74,0x82,0x5E,0x13,0x3D,0xA3,0x78,0x9B,0xC1,0x19,0x0C,0xA7,0x59,0x4E,0x8B,0x55,0xB7,0x75,0x48,0x34,0xD1,0x8A,0x7D,0x97,0x05,0xE4,0x17,0xBF,0xA5,0x86,0x0E,0xD5,0x2D,0x4D,0xA6,0xD9,0x7A,0x4B,0xD3,0xF6,0x92,0xD8,0xAC,0xFB,0x7F,0x4C,0x84,0x41,0x24,0x9A,0xFA,0xB4,0x03,0x01,0x43,0x3B,0x20,0x1F,0x57,0x11,0x68,0xF3,0x62,0xFF,0xC5,0x6A,0xE0,0xB6,0xB3,0x4A,0xE1,0xBC,0x72,0xBE,0xDC,0x26,0x18,0xAD,0x77,0x5F,0x5C,0xA8,0x0A,0xDA,0xCF,0xBB,0x27,0xE7,0x15,0x3A,0x44,0x9E,0xF7,0x31,0xE2,0x8E,0x00,0xEF,0x46,0x02,0x1C,0x40,0x94,0xF5,0xFC,0x65,0xDB,0x2A,0xE6,0x6D,0x88,0x8C,0x3E,0xBA,0x4F,0x3F,0x70,0xD0,0x51,0xC9,0x1B,0xB9,0xA2,0x2B,0x9F,0xCA,0xA4,0xAA,0xEA,0xC8,0x06,0xB1,0x07,0xF8,0x83,0x64,0x42,0x63,0xA1,0x37,0xFD,0xC0,0xDD,0x39,0x38,0x32,0x9D,0x49,0xCB,0x93,0x80,0xF9,0x96,0x2C,0xD2,0x6F,0xB2,0xED,0x5B,0xDE,0xAB,0xA0,0xE3,0xFE,0x6E,0xEC,0x1D,0x2F,0xF2,0x09,0x28,0xC6,0xCC,0x2E,0xF0,0x7E,0x53,0x36,0xD4,0xAF,0xB8,0xD7,0x25,0x10,0x6B,0x54,0xCE,0xE8,0xC3,0xD6,0xDF,0x67,0xC2,0x5D,0x61,0xA9,0x5A,0xE9,0x22,0x1A,0x71,0x16,0xEE,0xB0,0xF4,0xC4,0x23,0x73,0x6C,0xCD,0x91,0x35,0xE5,0xAE,0x99,0x52,0xEB,0x1E,0x69,0x04,0x7B,0x08,0x12,0x81,0x7C,0x56,0xF1,0x87,0xC7,0x0D,0x90,0x21,0x14,0x76,0x29,0x66,0xC6,0x3E,0xB3,0xB0,0x0C,0x36,0x1F,0xFA,0xD3,0x0E,0xBF,0x06,0xE3,0x88,0x16,0xC4,0xBE,0x20,0xFB,0x18,0x42,0x9A,0x8F,0x24,0xDB,0x0A,0x1B,0x8C,0xF7,0x01,0xDD,0x90,0x52,0x09,0xFE,0x14,0x86,0x67,0x94,0x3C,0xDA,0xCD,0x08,0xD6,0x34,0xF6,0xCB,0xB7,0xF9,0xC8,0x50,0x75,0x11,0x5B,0x2F,0x78,0x26,0x05,0x8D,0x56,0xAE,0xCE,0x25,0x5A,0x80,0x82,0xC0,0xB8,0xA3,0x9C,0xD4,0x92,0xFC,0xCF,0x07,0xC2,0xA7,0x19,0x79,0x37,0x30,0xC9,0x62,0x3F,0xF1,0x3D,0x5F,0xA5,0xEB,0x70,0xE1,0x7C,0x46,0xE9,0x63,0x35,0x4C,0x38,0xA4,0x64,0x96,0xB9,0xC7,0x1D,0x9B,0x2E,0xF4,0xDC,0xDF,0x2B,0x89,0x59,0x9F,0xC3,0x17,0x76,0x7F,0xE6,0x58,0xA9,0x74,0xB2,0x61,0x0D,0x83,0x6C,0xC5,0x81,0xF3,0x53,0xD2,0x4A,0x98,0x3A,0x21,0xA8,0x65,0xEE,0x0B,0x0F,0xBD,0x39,0xCC,0xBC,0x84,0x7B,0x00,0xE7,0xC1,0xE0,0x22,0xB4,0x1C,0x49,0x27,0x29,0x69,0x4B,0x85,0x32,0x48,0x10,0x03,0x7A,0x15,0xAF,0x51,0xEC,0x7E,0x43,0x5E,0xBA,0xBB,0xB1,0x1E,0xCA,0xED,0x6F,0x9E,0xAC,0x71,0x8A,0xAB,0x45,0x31,0x6E,0xD8,0x5D,0x28,0x23,0x60,0x7D,0x3B,0x54,0xA6,0x93,0xE8,0xD7,0x4D,0x6B,0x4F,0xAD,0x73,0xFD,0xD0,0xB5,0x57,0x2C,0xD9,0x6A,0xA1,0x99,0xF2,0x95,0x6D,0x33,0x40,0x55,0x5C,0xE4,0x41,0xDE,0xE2,0x2A,0x66,0x2D,0x1A,0xD1,0x68,0x9D,0xEA,0x87,0x77,0x47,0xA0,0xF0,0xEF,0x4E,0x12,0xB6,0x44,0x8E,0x13,0xA2,0x97,0xF5,0xAA,0xE5,0xF8,0x8B,0x91,0x02,0xFF,0xD5,0x72,0x04,0x5B,0xC0,0x51,0xCC,0xF6,0x59,0xD3,0x85,0x80,0x79,0xD2,0x8F,0x41,0x8D,0xEF,0x15,0x4C,0x7F,0xB7,0x72,0x17,0xA9,0xC9,0x87,0x30,0x32,0x70,0x08,0x13,0x2C,0x64,0x22,0xC4,0x02,0xD1,0xBD,0x33,0xDC,0x75,0x31,0x2F,0x73,0xA7,0xC6,0xCF,0x56,0xE8,0x19,0x2B,0x9E,0x44,0x6C,0x6F,0x9B,0x39,0xE9,0xFC,0x88,0x14,0xD4,0x26,0x09,0x77,0xAD,0x6B,0xBA,0xAB,0x3C,0x47,0xB1,0x6D,0x20,0x0E,0x90,0x4B,0xA8,0xF2,0x2A,0x3F,0x94,0x63,0xBE,0x0F,0xB6,0x53,0x38,0xA6,0x74,0x76,0x8E,0x03,0x00,0xBC,0x86,0xAF,0x4A,0x96,0xB5,0x3D,0xE6,0x1E,0x7E,0x95,0xEA,0x49,0x78,0xE0,0xC5,0xA1,0xEB,0x9F,0xC8,0x6A,0x7D,0xB8,0x66,0x84,0x46,0x7B,0x07,0xE2,0xB9,0x4E,0xA4,0x36,0xD7,0x24,0x8C,0xF0,0xE5,0xEC,0x54,0xF1,0x6E,0x52,0x9A,0x69,0xDA,0x11,0x29,0x42,0x25,0xDD,0x83,0xFF,0x1D,0xC3,0x4D,0x60,0x05,0xE7,0x9C,0x8B,0xE4,0x16,0x23,0x58,0x67,0xFD,0xDB,0x48,0x3B,0x21,0xB2,0x4F,0x65,0xC2,0xB4,0xF4,0x3E,0xA3,0x12,0x27,0x45,0x1A,0x55,0xC7,0xF7,0x10,0x40,0x5F,0xFE,0xA2,0x06,0xD6,0x9D,0xAA,0x61,0xD8,0x2D,0x5A,0x37,0xAC,0xF9,0x97,0x99,0xD9,0xFB,0x35,0x82,0x34,0xCB,0xB0,0x57,0x71,0x50,0x92,0x04,0xD5,0x5E,0xBB,0xBF,0x0D,0x89,0x7C,0x0C,0x43,0xE3,0x62,0xFA,0x28,0x8A,0x91,0x18,0x81,0xDE,0x68,0xED,0x98,0x93,0xD0,0xCD,0x5D,0xDF,0x2E,0x1C,0xC1,0x3A,0x1B,0xF5,0xCE,0xF3,0xEE,0x0A,0x0B,0x01,0xAE,0x7A,0xF8,0xA0,0xB3,0xCA,0xA5,0x1F,0xE1,0x5C,0x60,0xD8,0xD1,0xC4,0xAE,0x66,0x5A,0xC5,0x1D,0x25,0xEE,0x5D,0xB7,0xE9,0x11,0x76,0x79,0xF7,0x29,0xCB,0xA8,0xD3,0x31,0x54,0x17,0x22,0xD0,0xBF,0xEF,0xC9,0x53,0x6C,0x86,0x15,0x0F,0x7C,0x80,0xF6,0x51,0x7B,0x26,0x97,0x0A,0xC0,0x61,0x2E,0x71,0x13,0x74,0x24,0xC3,0xF3,0x32,0x96,0xCA,0x6B,0x55,0x9E,0xA9,0xE2,0x03,0x6E,0x19,0xEC,0xAD,0xA3,0xCD,0x98,0xB6,0x01,0xCF,0xED,0x63,0x84,0xFF,0x00,0x30,0xA6,0x64,0x45,0x8B,0x8F,0x6A,0xE1,0x38,0x48,0xBD,0x39,0xCE,0x56,0xD7,0x77,0x2C,0xA5,0xBE,0x1C,0xD9,0x5C,0xEA,0xB5,0xF9,0xE4,0xA7,0xAC,0x28,0x1A,0xEB,0x69,0xC1,0x2F,0x0E,0xF5,0x3E,0xDA,0xC7,0xFA,0x4E,0x9A,0x35,0x3F,0xFE,0x87,0x94,0xCC,0x68,0xD5,0x2B,0x91,0xF8,0x65,0xF4,0x6F,0xB1,0xE7,0x6D,0xC2,0xBB,0xE6,0x4D,0xB4,0x21,0xDB,0xB9,0x75,0x46,0x83,0x4B,0x78,0xB3,0xFD,0x9D,0x23,0x3C,0x44,0x06,0x04,0x16,0x50,0x18,0x27,0x89,0xE5,0x36,0xF0,0x05,0x41,0xE8,0x07,0xF2,0x93,0x47,0x1B,0x2D,0xDC,0x62,0xFB,0x58,0x70,0xAA,0x1F,0xDD,0x0D,0xAF,0x5B,0xE0,0x20,0xBC,0xC8,0x99,0x43,0x3D,0x12,0x08,0x9F,0x8E,0x5F,0x14,0x59,0x85,0x73,0x9C,0x7F,0xA4,0x3A,0xA0,0x0B,0x1E,0xC6,0x82,0x3B,0x8A,0x57,0x40,0x92,0x0C,0x67,0x34,0x37,0xBA,0x42,0x7E,0x9B,0xB2,0x88,0xD2,0x09,0x81,0xA2,0xDE,0xA1,0x4A,0x2A,0xF1,0xD4,0x4C,0x7D,0xFC,0xAB,0xDF,0x95,0x52,0x8C,0x49,0x5E,0x33,0x4F,0x72,0xB0,0x90,0x7A,0x8D,0xD6,0xB8,0x10,0xE3,0x02,0xC0,0xFD,0x39,0xDD,0x32,0x38,0x49,0x9D,0x93,0xCB,0xF9,0x80,0x2C,0x96,0x6F,0xD2,0xED,0xB2,0xDE,0x5B,0xA0,0xAB,0xFE,0xE3,0xEC,0x6E,0x2F,0x1D,0x09,0xF2,0xC6,0x28,0x6D,0xE6,0x8C,0x88,0xBA,0x3E,0x3F,0x4F,0xD0,0x70,0xC9,0x51,0xB9,0x1B,0x2B,0xA2,0xCA,0x9F,0xAA,0xA4,0xC8,0xEA,0xB1,0x06,0xF8,0x07,0x64,0x83,0x63,0x42,0x37,0xA1,0xC4,0xF4,0x73,0x23,0xCD,0x6C,0x35,0x91,0xAE,0xE5,0x52,0x99,0x1E,0xEB,0x04,0x69,0x08,0x7B,0x81,0x12,0x56,0x7C,0x87,0xF1,0x0D,0xC7,0x21,0x90,0x76,0x14,0x66,0x29,0x2E,0xCC,0x7E,0xF0,0x36,0x53,0xAF,0xD4,0xD7,0xB8,0x10,0x25,0x54,0x6B,0xE8,0xCE,0xD6,0xC3,0x67,0xDF,0x5D,0xC2,0xA9,0x61,0xE9,0x5A,0x1A,0x22,0x16,0x71,0xB0,0xEE,0x4E,0x59,0x55,0x8B,0x75,0xB7,0x34,0x48,0x8A,0xD1,0x97,0x7D,0xE4,0x05,0xBF,0x17,0x86,0xA5,0xD5,0x0E,0x4D,0x2D,0xD9,0xA6,0x4B,0x7A,0xF6,0xD3,0xD8,0x92,0xFB,0xAC,0x8D,0x50,0x85,0x3C,0x0B,0x60,0x47,0x95,0xBD,0x45,0x33,0x30,0xB5,0x8F,0x79,0x9C,0x89,0x58,0x0F,0x98,0x82,0x74,0x13,0x5E,0xA3,0x3D,0x9B,0x78,0x19,0xC1,0xA7,0x0C,0xAD,0x18,0x5F,0x77,0xA8,0x5C,0xDA,0x0A,0xBB,0xCF,0xE7,0x27,0x3A,0x15,0x9E,0x44,0x31,0xF7,0x8E,0xE2,0xEF,0x00,0x02,0x46,0x40,0x1C,0xF5,0x94,0x65,0xFC,0x2A,0xDB,0x4C,0x7F,0x41,0x84,0x9A,0x24,0xB4,0xFA,0x01,0x03,0x3B,0x43,0x1F,0x20,0x11,0x57,0xF3,0x68,0xFF,0x62,0x6A,0xC5,0xB6,0xE0,0x4A,0xB3,0xBC,0xE1,0xBE,0x72,0x26,0xDC,0xD9,0x6A,0xA1,0x99,0xF2,0x95,0x6D,0x33,0x40,0x55,0x5C,0xE4,0x41,0xDE,0xE2,0x2A,0x3B,0x54,0xA6,0x93,0xE8,0xD7,0x4D,0x6B,0x4F,0xAD,0x73,0xFD,0xD0,0xB5,0x57,0x2C,0x44,0x8E,0x13,0xA2,0x97,0xF5,0xAA,0xE5,0xF8,0x8B,0x91,0x02,0xFF,0xD5,0x72,0x04,0x66,0x2D,0x1A,0xD1,0x68,0x9D,0xEA,0x87,0x77,0x47,0xA0,0xF0,0xEF,0x4E,0x12,0xB6,0x84,0x7B,0x00,0xE7,0xC1,0xE0,0x22,0xB4,0x1C,0x49,0x27,0x29,0x69,0x4B,0x85,0x32,0xF3,0x53,0xD2,0x4A,0x98,0x3A,0x21,0xA8,0x65,0xEE,0x0B,0x0F,0xBD,0x39,0xCC,0xBC,0xED,0x6F,0x9E,0xAC,0x71,0x8A,0xAB,0x45,0x31,0x6E,0xD8,0x5D,0x28,0x23,0x60,0x7D,0x48,0x10,0x03,0x7A,0x15,0xAF,0x51,0xEC,0x7E,0x43,0x5E,0xBA,0xBB,0xB1,0x1E,0xCA,0x30,0xC9,0x62,0x3F,0xF1,0x3D,0x5F,0xA5,0xEB,0x70,0xE1,0x7C,0x46,0xE9,0x63,0x35,0x80,0x82,0xC0,0xB8,0xA3,0x9C,0xD4,0x92,0xFC,0xCF,0x07,0xC2,0xA7,0x19,0x79,0x37,0x9F,0xC3,0x17,0x76,0x7F,0xE6,0x58,0xA9,0x74,0xB2,0x61,0x0D,0x83,0x6C,0xC5,0x81,0x4C,0x38,0xA4,0x64,0x96,0xB9,0xC7,0x1D,0x9B,0x2E,0xF4,0xDC,0xDF,0x2B,0x89,0x59,0xBE,0x20,0xFB,0x18,0x42,0x9A,0x8F,0x24,0xDB,0x0A,0x1B,0x8C,0xF7,0x01,0xDD,0x90,0xC6,0x3E,0xB3,0xB0,0x0C,0x36,0x1F,0xFA,0xD3,0x0E,0xBF,0x06,0xE3,0x88,0x16,0xC4,0xF9,0xC8,0x50,0x75,0x11,0x5B,0x2F,0x78,0x26,0x05,0x8D,0x56,0xAE,0xCE,0x25,0x5A,0x52,0x09,0xFE,0x14,0x86,0x67,0x94,0x3C,0xDA,0xCD,0x08,0xD6,0x34,0xF6,0xCB,0xB7,0x09,0x26,0xAD,0x77,0x88,0xFC,0xD4,0x14,0x9B,0x6F,0xE9,0x39,0x9E,0x2B,0x6C,0x44,0x56,0xCF,0x19,0xE8,0x73,0x2F,0xC6,0xA7,0xDC,0x33,0x31,0x75,0x02,0xC4,0xBD,0xD1,0x2C,0x13,0x22,0x64,0x32,0x30,0x08,0x70,0xA9,0x17,0x87,0xC9,0x7F,0x4C,0x72,0xB7,0x8D,0x41,0x15,0xEF,0x79,0x80,0x8F,0xD2,0x59,0xF6,0x85,0xD3,0xC0,0x5B,0xCC,0x51,0xD7,0x36,0x8C,0x24,0xB9,0xE2,0xA4,0x4E,0x46,0x84,0x07,0x7B,0x7D,0x6A,0x66,0xB8,0xEB,0xA1,0xC8,0x9F,0x78,0x49,0xC5,0xE0,0x7E,0x1E,0xEA,0x95,0xB5,0x96,0xE6,0x3D,0x86,0xBC,0x4A,0xAF,0x8E,0x76,0x00,0x03,0x38,0x53,0x74,0xA6,0xBE,0x63,0xB6,0x0F,0x2A,0xF2,0x94,0x3F,0x90,0x0E,0xA8,0x4B,0xB1,0x47,0x20,0x6D,0xBA,0x6B,0x3C,0xAB,0x2D,0xD8,0x37,0x5A,0x9D,0xD6,0x61,0xAA,0xFE,0x5F,0x06,0xA2,0xF7,0xC7,0x40,0x10,0x45,0x27,0x55,0x1A,0x3E,0xF4,0x12,0xA3,0x65,0x4F,0xB4,0xC2,0x3B,0x48,0xB2,0x21,0x67,0x58,0xDB,0xFD,0xE4,0x8B,0x23,0x16,0x05,0x60,0x9C,0xE7,0x1D,0xFF,0x4D,0xC3,0x25,0x42,0x83,0xDD,0xDA,0x69,0x29,0x11,0x6E,0xF1,0x9A,0x52,0xE5,0xF0,0x54,0xEC,0x1F,0xA5,0x5C,0xE1,0xA0,0xF8,0xCA,0xB3,0x01,0x0B,0x7A,0xAE,0xF3,0xCE,0x0A,0xEE,0x3A,0xC1,0xF5,0x1B,0xDF,0x5D,0x1C,0x2E,0x93,0x98,0xCD,0xD0,0xDE,0x81,0xED,0x68,0x8A,0x28,0x18,0x91,0xE3,0x43,0xFA,0x62,0x89,0x0D,0x0C,0x7C,0x5E,0xD5,0xBF,0xBB,0x50,0x71,0x04,0x92,0xCB,0x34,0x57,0xB0,0xFB,0xD9,0x82,0x35,0xF9,0xAC,0x99,0x97,0x4E,0x9A,0x35,0x3F,0x3E,0xDA,0xC7,0xFA,0x68,0xD5,0x2B,0x91,0xFE,0x87,0x94,0xCC,0xF9,0xE4,0xA7,0xAC,0xD9,0x5C,0xEA,0xB5,0xC1,0x2F,0x0E,0xF5,0x28,0x1A,0xEB,0x69,0x38,0x48,0xBD,0x39,0x8B,0x8F,0x6A,0xE1,0x2C,0xA5,0xBE,0x1C,0xCE,0x56,0xD7,0x77,0xB6,0x01,0xCF,0xED,0xAD,0xA3,0xCD,0x98,0x30,0xA6,0x64,0x45,0x63,0x84,0xFF,0x00,0x32,0x96,0xCA,0x6B,0x74,0x24,0xC3,0xF3,0x03,0x6E,0x19,0xEC,0x55,0x9E,0xA9,0xE2,0x80,0xF6,0x51,0x7B,0x86,0x15,0x0F,0x7C,0x61,0x2E,0x71,0x13,0x26,0x97,0x0A,0xC0,0xA8,0xD3,0x31,0x54,0x79,0xF7,0x29,0xCB,0xEF,0xC9,0x53,0x6C,0x17,0x22,0xD0,0xBF,0xAE,0x66,0x5A,0xC5,0x60,0xD8,0xD1,0xC4,0xB7,0xE9,0x11,0x76,0x1D,0x25,0xEE,0x5D,0x33,0x4F,0x72,0xB0,0x52,0x8C,0x49,0x5E,0xB8,0x10,0xE3,0x02,0x90,0x7A,0x8D,0xD6,0xDE,0xA1,0x4A,0x2A,0xD2,0x09,0x81,0xA2,0xFC,0xAB,0xDF,0x95,0xF1,0xD4,0x4C,0x7D,0x40,0x92,0x0C,0x67,0x82,0x3B,0x8A,0x57,0x7E,0x9B,0xB2,0x88,0x34,0x37,0xBA,0x42,0x14,0x59,0x85,0x73,0x08,0x9F,0x8E,0x5F,0xA0,0x0B,0x1E,0xC6,0x9C,0x7F,0xA4,0x3A,0xDD,0x0D,0xAF,0x5B,0x58,0x70,0xAA,0x1F,0x99,0x43,0x3D,0x12,0xE0,0x20,0xBC,0xC8,0x05,0x41,0xE8,0x07,0x89,0xE5,0x36,0xF0,0x2D,0xDC,0x62,0xFB,0xF2,0x93,0x47,0x1B,0xB3,0xFD,0x9D,0x23,0x46,0x83,0x4B,0x78,0x16,0x50,0x18,0x27,0x3C,0x44,0x06,0x04,0xB1,0xE7,0x6D,0xC2,0xF8,0x65,0xF4,0x6F,0x21,0xDB,0xB9,0x75,0xBB,0xE6,0x4D,0xB4,0xA3,0x3D,0x9B,0x78,0x19,0xC1,0xA7,0x0C,0x89,0x58,0x0F,0x98,0x82,0x74,0x13,0x5E,0xBD,0x45,0x33,0x30,0xB5,0x8F,0x79,0x9C,0x8D,0x50,0x85,0x3C,0x0B,0x60,0x47,0x95,0x4B,0x7A,0xF6,0xD3,0xD8,0x92,0xFB,0xAC,0x86,0xA5,0xD5,0x0E,0x4D,0x2D,0xD9,0xA6,0x8A,0xD1,0x97,0x7D,0xE4,0x05,0xBF,0x17,0x4E,0x59,0x55,0x8B,0x75,0xB7,0x34,0x48,0x4A,0xB3,0xBC,0xE1,0xBE,0x72,0x26,0xDC,0xF3,0x68,0xFF,0x62,0x6A,0xC5,0xB6,0xE0,0x01,0x03,0x3B,0x43,0x1F,0x20,0x11,0x57,0x4C,0x7F,0x41,0x84,0x9A,0x24,0xB4,0xFA,0x40,0x1C,0xF5,0x94,0x65,0xFC,0x2A,0xDB,0x31,0xF7,0x8E,0xE2,0xEF,0x00,0x02,0x46,0xBB,0xCF,0xE7,0x27,0x3A,0x15,0x9E,0x44,0xAD,0x18,0x5F,0x77,0xA8,0x5C,0xDA,0x0A,0xF8,0x07,0x64,0x83,0x63,0x42,0x37,0xA1,0xCA,0x9F,0xAA,0xA4,0xC8,0xEA,0xB1,0x06,0xD0,0x70,0xC9,0x51,0xB9,0x1B,0x2B,0xA2,0x6D,0xE6,0x8C,0x88,0xBA,0x3E,0x3F,0x4F,0xEC,0x6E,0x2F,0x1D,0x09,0xF2,0xC6,0x28,0xED,0xB2,0xDE,0x5B,0xA0,0xAB,0xFE,0xE3,0x93,0xCB,0xF9,0x80,0x2C,0x96,0x6F,0xD2,0xC0,0xFD,0x39,0xDD,0x32,0x38,0x49,0x9D,0xE9,0x5A,0x1A,0x22,0x16,0x71,0xB0,0xEE,0xD6,0xC3,0x67,0xDF,0x5D,0xC2,0xA9,0x61,0xD7,0xB8,0x10,0x25,0x54,0x6B,0xE8,0xCE,0x2E,0xCC,0x7E,0xF0,0x36,0x53,0xAF,0xD4,0x0D,0xC7,0x21,0x90,0x76,0x14,0x66,0x29,0x08,0x7B,0x81,0x12,0x56,0x7C,0x87,0xF1,0xAE,0xE5,0x52,0x99,0x1E,0xEB,0x04,0x69,0xC4,0xF4,0x73,0x23,0xCD,0x6C,0x35,0x91,0xD7,0xE8,0x6B,0x4D,0x54,0x3B,0x93,0xA6,0xB5,0xD0,0x2C,0x57,0xAD,0x4F,0xFD,0x73,0x95,0xF2,0x33,0x6D,0x6A,0xD9,0x99,0xA1,0xDE,0x41,0x2A,0xE2,0x55,0x40,0xE4,0x5C,0x9D,0x68,0x87,0xEA,0x2D,0x66,0xD1,0x1A,0x4E,0xEF,0xB6,0x12,0x47,0x77,0xF0,0xA0,0xF5,0x97,0xE5,0xAA,0x8E,0x44,0xA2,0x13,0xD5,0xFF,0x04,0x72,0x8B,0xF8,0x02,0x91,0x3A,0x98,0xA8,0x21,0x53,0xF3,0x4A,0xD2,0x39,0xBD,0xBC,0xCC,0xEE,0x65,0x0F,0x0B,0xE0,0xC1,0xB4,0x22,0x7B,0x84,0xE7,0x00,0x4B,0x69,0x32,0x85,0x49,0x1C,0x29,0x27,0xAF,0x15,0xEC,0x51,0x10,0x48,0x7A,0x03,0xB1,0xBB,0xCA,0x1E,0x43,0x7E,0xBA,0x5E,0x8A,0x71,0x45,0xAB,0x6F,0xED,0xAC,0x9E,0x23,0x28,0x7D,0x60,0x6E,0x31,0x5D,0xD8,0x9C,0xA3,0x92,0xD4,0x82,0x80,0xB8,0xC0,0x19,0xA7,0x37,0x79,0xCF,0xFC,0xC2,0x07,0x3D,0xF1,0xA5,0x5F,0xC9,0x30,0x3F,0x62,0xE9,0x46,0x35,0x63,0x70,0xEB,0x7C,0xE1,0xB9,0x96,0x1D,0xC7,0x38,0x4C,0x64,0xA4,0x2B,0xDF,0x59,0x89,0x2E,0x9B,0xDC,0xF4,0xE6,0x7F,0xA9,0x58,0xC3,0x9F,0x76,0x17,0x6C,0x83,0x81,0xC5,0xB2,0x74,0x0D,0x61,0x36,0x0C,0xFA,0x1F,0x3E,0xC6,0xB0,0xB3,0x88,0xE3,0xC4,0x16,0x0E,0xD3,0x06,0xBF,0x9A,0x42,0x24,0x8F,0x20,0xBE,0x18,0xFB,0x01,0xF7,0x90,0xDD,0x0A,0xDB,0x8C,0x1B,0x67,0x86,0x3C,0x94,0x09,0x52,0x14,0xFE,0xF6,0x34,0xB7,0xCB,0xCD,0xDA,0xD6,0x08,0x5B,0x11,0x78,0x2F,0xC8,0xF9,0x75,0x50,0xCE,0xAE,0x5A,0x25,0x05,0x26,0x56,0x8D,0xD6,0x9D,0xAA,0x61,0xD8,0x2D,0x5A,0x37,0xC7,0xF7,0x10,0x40,0x5F,0xFE,0xA2,0x06,0xF4,0x3E,0xA3,0x12,0x27,0x45,0x1A,0x55,0x48,0x3B,0x21,0xB2,0x4F,0x65,0xC2,0xB4,0x8B,0xE4,0x16,0x23,0x58,0x67,0xFD,0xDB,0xFF,0x1D,0xC3,0x4D,0x60,0x05,0xE7,0x9C,0x69,0xDA,0x11,0x29,0x42,0x25,0xDD,0x83,0xF0,0xE5,0xEC,0x54,0xF1,0x6E,0x52,0x9A,0xF8,0xA0,0xB3,0xCA,0xA5,0x1F,0xE1,0x5C,0xCE,0xF3,0xEE,0x0A,0x0B,0x01,0xAE,0x7A,0x5D,0xDF,0x2E,0x1C,0xC1,0x3A,0x1B,0xF5,0x81,0xDE,0x68,0xED,0x98,0x93,0xD0,0xCD,0x43,0xE3,0x62,0xFA,0x28,0x8A,0x91,0x18,0xD5,0x5E,0xBB,0xBF,0x0D,0x89,0x7C,0x0C,0x34,0xCB,0xB0,0x57,0x71,0x50,0x92,0x04,0xAC,0xF9,0x97,0x99,0xD9,0xFB,0x35,0x82,0xFC,0x88,0x14,0xD4,0x26,0x09,0x77,0xAD,0x2B,0x9E,0x44,0x6C,0x6F,0x9B,0x39,0xE9,0x2F,0x73,0xA7,0xC6,0xCF,0x56,0xE8,0x19,0xC4,0x02,0xD1,0xBD,0x33,0xDC,0x75,0x31,0x30,0x32,0x70,0x08,0x13,0x2C,0x64,0x22,0x4C,0x7F,0xB7,0x72,0x17,0xA9,0xC9,0x87,0x80,0x79,0xD2,0x8F,0x41,0x8D,0xEF,0x15,0x5B,0xC0,0x51,0xCC,0xF6,0x59,0xD3,0x85,0xE2,0xB9,0x4E,0xA4,0x36,0xD7,0x24,0x8C,0x6A,0x7D,0xB8,0x66,0x84,0x46,0x7B,0x07,0x49,0x78,0xE0,0xC5,0xA1,0xEB,0x9F,0xC8,0x96,0xB5,0x3D,0xE6,0x1E,0x7E,0x95,0xEA,0x76,0x8E,0x03,0x00,0xBC,0x86,0xAF,0x4A,0x63,0xBE,0x0F,0xB6,0x53,0x38,0xA6,0x74,0x0E,0x90,0x4B,0xA8,0xF2,0x2A,0x3F,0x94,0x6B,0xBA,0xAB,0x3C,0x47,0xB1,0x6D,0x20,0x90,0x7A,0x8D,0xD6,0xB8,0x10,0xE3,0x02,0x52,0x8C,0x49,0x5E,0x33,0x4F,0x72,0xB0,0xF1,0xD4,0x4C,0x7D,0xFC,0xAB,0xDF,0x95,0xD2,0x09,0x81,0xA2,0xDE,0xA1,0x4A,0x2A,0x34,0x37,0xBA,0x42,0x7E,0x9B,0xB2,0x88,0x82,0x3B,0x8A,0x57,0x40,0x92,0x0C,0x67,0x9C,0x7F,0xA4,0x3A,0xA0,0x0B,0x1E,0xC6,0x08,0x9F,0x8E,0x5F,0x14,0x59,0x85,0x73,0xE0,0x20,0xBC,0xC8,0x99,0x43,0x3D,0x12,0x58,0x70,0xAA,0x1F,0xDD,0x0D,0xAF,0x5B,0xF2,0x93,0x47,0x1B,0x2D,0xDC,0x62,0xFB,0x89,0xE5,0x36,0xF0,0x05,0x41,0xE8,0x07,0x3C,0x44,0x06,0x04,0x16,0x50,0x18,0x27,0x46,0x83,0x4B,0x78,0xB3,0xFD,0x9D,0x23,0xBB,0xE6,0x4D,0xB4,0x21,0xDB,0xB9,0x75,0xF8,0x65,0xF4,0x6F,0xB1,0xE7,0x6D,0xC2,0xFE,0x87,0x94,0xCC,0x68,0xD5,0x2B,0x91,0x3E,0xDA,0xC7,0xFA,0x4E,0x9A,0x35,0x3F,0x28,0x1A,0xEB,0x69,0xC1,0x2F,0x0E,0xF5,0xD9,0x5C,0xEA,0xB5,0xF9,0xE4,0xA7,0xAC,0xCE,0x56,0xD7,0x77,0x2C,0xA5,0xBE,0x1C,0x8B,0x8F,0x6A,0xE1,0x38,0x48,0xBD,0x39,0x63,0x84,0xFF,0x00,0x30,0xA6,0x64,0x45,0xAD,0xA3,0xCD,0x98,0xB6,0x01,0xCF,0xED,0x55,0x9E,0xA9,0xE2,0x03,0x6E,0x19,0xEC,0x74,0x24,0xC3,0xF3,0x32,0x96,0xCA,0x6B,0x26,0x97,0x0A,0xC0,0x61,0x2E,0x71,0x13,0x86,0x15,0x0F,0x7C,0x80,0xF6,0x51,0x7B,0x17,0x22,0xD0,0xBF,0xEF,0xC9,0x53,0x6C,0x79,0xF7,0x29,0xCB,0xA8,0xD3,0x31,0x54,0x1D,0x25,0xEE,0x5D,0xB7,0xE9,0x11,0x76,0x60,0xD8,0xD1,0xC4,0xAE,0x66,0x5A,0xC5,0x98,0x0F,0x58,0x89,0x5E,0x13,0x74,0x82,0x78,0x9B,0x3D,0xA3,0x0C,0xA7,0xC1,0x19,0x3C,0x85,0x50,0x8D,0x95,0x47,0x60,0x0B,0x30,0x33,0x45,0xBD,0x9C,0x79,0x8F,0xB5,0x0E,0xD5,0xA5,0x86,0xA6,0xD9,0x2D,0x4D,0xD3,0xF6,0x7A,0x4B,0xAC,0xFB,0x92,0xD8,0x8B,0x55,0x59,0x4E,0x48,0x34,0xB7,0x75,0x7D,0x97,0xD1,0x8A,0x17,0xBF,0x05,0xE4,0x62,0xFF,0x68,0xF3,0xE0,0xB6,0xC5,0x6A,0xE1,0xBC,0xB3,0x4A,0xDC,0x26,0x72,0xBE,0x84,0x41,0x7F,0x4C,0xFA,0xB4,0x24,0x9A,0x43,0x3B,0x03,0x01,0x57,0x11,0x20,0x1F,0xE2,0x8E,0xF7,0x31,0x46,0x02,0x00,0xEF,0x94,0xF5,0x1C,0x40,0xDB,0x2A,0xFC,0x65,0x77,0x5F,0x18,0xAD,0x0A,0xDA,0x5C,0xA8,0x27,0xE7,0xCF,0xBB,0x44,0x9E,0x15,0x3A,0xA4,0xAA,0x9F,0xCA,0x06,0xB1,0xEA,0xC8,0x83,0x64,0x07,0xF8,0xA1,0x37,0x42,0x63,0x88,0x8C,0xE6,0x6D,0x4F,0x3F,0x3E,0xBA,0x51,0xC9,0x70,0xD0,0xA2,0x2B,0x1B,0xB9,0x5B,0xDE,0xB2,0xED,0xE3,0xFE,0xAB,0xA0,0x1D,0x2F,0x6E,0xEC,0x28,0xC6,0xF2,0x09,0xDD,0x39,0xFD,0xC0,0x9D,0x49,0x38,0x32,0x80,0xF9,0xCB,0x93,0xD2,0x6F,0x96,0x2C,0xDF,0x67,0xC3,0xD6,0x61,0xA9,0xC2,0x5D,0x22,0x1A,0x5A,0xE9,0xEE,0xB0,0x71,0x16,0xF0,0x7E,0xCC,0x2E,0xD4,0xAF,0x53,0x36,0x25,0x10,0xB8,0xD7,0xCE,0xE8,0x6B,0x54,0x12,0x81,0x7B,0x08,0xF1,0x87,0x7C,0x56,0x90,0x21,0xC7,0x0D,0x29,0x66,0x14,0x76,0x23,0x73,0xF4,0xC4,0x91,0x35,0x6C,0xCD,0x99,0x52,0xE5,0xAE,0x69,0x04,0xEB,0x1E,0x77,0x47,0xA0,0xF0,0xEF,0x4E,0x12,0xB6,0x66,0x2D,0x1A,0xD1,0x68,0x9D,0xEA,0x87,0xF8,0x8B,0x91,0x02,0xFF,0xD5,0x72,0x04,0x44,0x8E,0x13,0xA2,0x97,0xF5,0xAA,0xE5,0x4F,0xAD,0x73,0xFD,0xD0,0xB5,0x57,0x2C,0x3B,0x54,0xA6,0x93,0xE8,0xD7,0x4D,0x6B,0x40,0x55,0x5C,0xE4,0x41,0xDE,0xE2,0x2A,0xD9,0x6A,0xA1,0x99,0xF2,0x95,0x6D,0x33,0x7E,0x43,0x5E,0xBA,0xBB,0xB1,0x1E,0xCA,0x48,0x10,0x03,0x7A,0x15,0xAF,0x51,0xEC,0x31,0x6E,0xD8,0x5D,0x28,0x23,0x60,0x7D,0xED,0x6F,0x9E,0xAC,0x71,0x8A,0xAB,0x45,0x65,0xEE,0x0B,0x0F,0xBD,0x39,0xCC,0xBC,0xF3,0x53,0xD2,0x4A,0x98,0x3A,0x21,0xA8,0x1C,0x49,0x27,0x29,0x69,0x4B,0x85,0x32,0x84,0x7B,0x00,0xE7,0xC1,0xE0,0x22,0xB4,0x9B,0x2E,0xF4,0xDC,0xDF,0x2B,0x89,0x59,0x4C,0x38,0xA4,0x64,0x96,0xB9,0xC7,0x1D,0x74,0xB2,0x61,0x0D,0x83,0x6C,0xC5,0x81,0x9F,0xC3,0x17,0x76,0x7F,0xE6,0x58,0xA9,0xFC,0xCF,0x07,0xC2,0xA7,0x19,0x79,0x37,0x80,0x82,0xC0,0xB8,0xA3,0x9C,0xD4,0x92,0xEB,0x70,0xE1,0x7C,0x46,0xE9,0x63,0x35,0x30,0xC9,0x62,0x3F,0xF1,0x3D,0x5F,0xA5,0xDA,0xCD,0x08,0xD6,0x34,0xF6,0xCB,0xB7,0x52,0x09,0xFE,0x14,0x86,0x67,0x94,0x3C,0x26,0x05,0x8D,0x56,0xAE,0xCE,0x25,0x5A,0xF9,0xC8,0x50,0x75,0x11,0x5B,0x2F,0x78,0xD3,0x0E,0xBF,0x06,0xE3,0x88,0x16,0xC4,0xC6,0x3E,0xB3,0xB0,0x0C,0x36,0x1F,0xFA,0xDB,0x0A,0x1B,0x8C,0xF7,0x01,0xDD,0x90,0xBE,0x20,0xFB,0x18,0x42,0x9A,0x8F,0x24,0x01,0x0B,0x7A,0xAE,0xF3,0xCE,0x0A,0xEE,0x1F,0xA5,0x5C,0xE1,0xA0,0xF8,0xCA,0xB3,0x93,0x98,0xCD,0xD0,0xDE,0x81,0xED,0x68,0x3A,0xC1,0xF5,0x1B,0xDF,0x5D,0x1C,0x2E,0x89,0x0D,0x0C,0x7C,0x5E,0xD5,0xBF,0xBB,0x8A,0x28,0x18,0x91,0xE3,0x43,0xFA,0x62,0xFB,0xD9,0x82,0x35,0xF9,0xAC,0x99,0x97,0x50,0x71,0x04,0x92,0xCB,0x34,0x57,0xB0,0xFE,0x5F,0x06,0xA2,0xF7,0xC7,0x40,0x10,0x2D,0xD8,0x37,0x5A,0x9D,0xD6,0x61,0xAA,0x65,0x4F,0xB4,0xC2,0x3B,0x48,0xB2,0x21,0x45,0x27,0x55,0x1A,0x3E,0xF4,0x12,0xA3,0x05,0x60,0x9C,0xE7,0x1D,0xFF,0x4D,0xC3,0x67,0x58,0xDB,0xFD,0xE4,0x8B,0x23,0x16,0x6E,0xF1,0x9A,0x52,0xE5,0xF0,0x54,0xEC,0x25,0x42,0x83,0xDD,0xDA,0x69,0x29,0x11,0x46,0x84,0x07,0x7B,0x7D,0x6A,0x66,0xB8,0xD7,0x36,0x8C,0x24,0xB9,0xE2,0xA4,0x4E,0x7E,0x1E,0xEA,0x95,0xB5,0x96,0xE6,0x3D,0xEB,0xA1,0xC8,0x9F,0x78,0x49,0xC5,0xE0,0x38,0x53,0x74,0xA6,0xBE,0x63,0xB6,0x0F,0x86,0xBC,0x4A,0xAF,0x8E,0x76,0x00,0x03,0xB1,0x47,0x20,0x6D,0xBA,0x6B,0x3C,0xAB,0x2A,0xF2,0x94,0x3F,0x90,0x0E,0xA8,0x4B,0x9B,0x6F,0xE9,0x39,0x9E,0x2B,0x6C,0x44,0x09,0x26,0xAD,0x77,0x88,0xFC,0xD4,0x14,0xDC,0x33,0x31,0x75,0x02,0xC4,0xBD,0xD1,0x56,0xCF,0x19,0xE8,0x73,0x2F,0xC6,0xA7,0xA9,0x17,0x87,0xC9,0x7F,0x4C,0x72,0xB7,0x2C,0x13,0x22,0x64,0x32,0x30,0x08,0x70,0x59,0xF6,0x85,0xD3,0xC0,0x5B,0xCC,0x51,0x8D,0x41,0x15,0xEF,0x79,0x80,0x8F,0xD2,0x93,0xF2,0x1B,0x47,0xDC,0x2D,0xFB,0x62,0xE5,0x89,0xF0,0x36,0x41,0x05,0x07,0xE8,0x20,0xE0,0xC8,0xBC,0x43,0x99,0x12,0x3D,0x70,0x58,0x1F,0xAA,0x0D,0xDD,0x5B,0xAF,0xE6,0xBB,0xB4,0x4D,0xDB,0x21,0x75,0xB9,0x65,0xF8,0x6F,0xF4,0xE7,0xB1,0xC2,0x6D,0x44,0x3C,0x04,0x06,0x50,0x16,0x27,0x18,0x83,0x46,0x78,0x4B,0xFD,0xB3,0x23,0x9D,0xD4,0xF1,0x7D,0x4C,0xAB,0xFC,0x95,0xDF,0x09,0xD2,0xA2,0x81,0xA1,0xDE,0x2A,0x4A,0x7A,0x90,0xD6,0x8D,0x10,0xB8,0x02,0xE3,0x8C,0x52,0x5E,0x49,0x4F,0x33,0xB0,0x72,0x7F,0x9C,0x3A,0xA4,0x0B,0xA0,0xC6,0x1E,0x9F,0x08,0x5F,0x8E,0x59,0x14,0x73,0x85,0x37,0x34,0x42,0xBA,0x9B,0x7E,0x88,0xB2,0x3B,0x82,0x57,0x8A,0x92,0x40,0x67,0x0C,0x97,0x26,0xC0,0x0A,0x2E,0x61,0x13,0x71,0x15,0x86,0x7C,0x0F,0xF6,0x80,0x7B,0x51,0x9E,0x55,0xE2,0xA9,0x6E,0x03,0xEC,0x19,0x24,0x74,0xF3,0xC3,0x96,0x32,0x6B,0xCA,0x25,0x1D,0x5D,0xEE,0xE9,0xB7,0x76,0x11,0xD8,0x60,0xC4,0xD1,0x66,0xAE,0xC5,0x5A,0x22,0x17,0xBF,0xD0,0xC9,0xEF,0x6C,0x53,0xF7,0x79,0xCB,0x29,0xD3,0xA8,0x54,0x31,0x1A,0x28,0x69,0xEB,0x2F,0xC1,0xF5,0x0E,0x5C,0xD9,0xB5,0xEA,0xE4,0xF9,0xAC,0xA7,0x87,0xFE,0xCC,0x94,0xD5,0x68,0x91,0x2B,0xDA,0x3E,0xFA,0xC7,0x9A,0x4E,0x3F,0x35,0x84,0x63,0x00,0xFF,0xA6,0x30,0x45,0x64,0xA3,0xAD,0x98,0xCD,0x01,0xB6,0xED,0xCF,0x56,0xCE,0x77,0xD7,0xA5,0x2C,0x1C,0xBE,0x8F,0x8B,0xE1,0x6A,0x48,0x38,0x39,0xBD,0x8F,0xB5,0x9C,0x79,0x45,0xBD,0x30,0x33,0x60,0x0B,0x95,0x47,0x50,0x8D,0x3C,0x85,0xC1,0x19,0x0C,0xA7,0x3D,0xA3,0x78,0x9B,0x74,0x82,0x5E,0x13,0x58,0x89,0x98,0x0F,0x05,0xE4,0x17,0xBF,0xD1,0x8A,0x7D,0x97,0xB7,0x75,0x48,0x34,0x59,0x4E,0x8B,0x55,0x92,0xD8,0xAC,0xFB,0x7A,0x4B,0xD3,0xF6,0x2D,0x4D,0xA6,0xD9,0xA5,0x86,0x0E,0xD5,0x20,0x1F,0x57,0x11,0x03,0x01,0x43,0x3B,0x24,0x9A,0xFA,0xB4,0x7F,0x4C,0x84,0x41,0x72,0xBE,0xDC,0x26,0xB3,0x4A,0xE1,0xBC,0xC5,0x6A,0xE0,0xB6,0x68,0xF3,0x62,0xFF,0x15,0x3A,0x44,0x9E,0xCF,0xBB,0x27,0xE7,0x5C,0xA8,0x0A,0xDA,0x18,0xAD,0x77,0x5F,0xFC,0x65,0xDB,0x2A,0x1C,0x40,0x94,0xF5,0x00,0xEF,0x46,0x02,0xF7,0x31,0xE2,0x8E,0x1B,0xB9,0xA2,0x2B,0x70,0xD0,0x51,0xC9,0x3E,0xBA,0x4F,0x3F,0xE6,0x6D,0x88,0x8C,0x42,0x63,0xA1,0x37,0x07,0xF8,0x83,0x64,0xEA,0xC8,0x06,0xB1,0x9F,0xCA,0xA4,0xAA,0x96,0x2C,0xD2,0x6F,0xCB,0x93,0x80,0xF9,0x38,0x32,0x9D,0x49,0xFD,0xC0,0xDD,0x39,0xF2,0x09,0x28,0xC6,0x6E,0xEC,0x1D,0x2F,0xAB,0xA0,0xE3,0xFE,0xB2,0xED,0x5B,0xDE,0x6B,0x54,0xCE,0xE8,0xB8,0xD7,0x25,0x10,0x53,0x36,0xD4,0xAF,0xCC,0x2E,0xF0,0x7E,0x71,0x16,0xEE,0xB0,0x5A,0xE9,0x22,0x1A,0xC2,0x5D,0x61,0xA9,0xC3,0xD6,0xDF,0x67,0xEB,0x1E,0x69,0x04,0xE5,0xAE,0x99,0x52,0x6C,0xCD,0x91,0x35,0xF4,0xC4,0x23,0x73,0x14,0x76,0x29,0x66,0xC7,0x0D,0x90,0x21,0x7C,0x56,0xF1,0x87,0x7B,0x08,0x12,0x81,0x80,0x82,0xC0,0xB8,0xA3,0x9C,0xD4,0x92,0xFC,0xCF,0x07,0xC2,0xA7,0x19,0x79,0x37,0x30,0xC9,0x62,0x3F,0xF1,0x3D,0x5F,0xA5,0xEB,0x70,0xE1,0x7C,0x46,0xE9,0x63,0x35,0x4C,0x38,0xA4,0x64,0x96,0xB9,0xC7,0x1D,0x9B,0x2E,0xF4,0xDC,0xDF,0x2B,0x89,0x59,0x9F,0xC3,0x17,0x76,0x7F,0xE6,0x58,0xA9,0x74,0xB2,0x61,0x0D,0x83,0x6C,0xC5,0x81,0xC6,0x3E,0xB3,0xB0,0x0C,0x36,0x1F,0xFA,0xD3,0x0E,0xBF,0x06,0xE3,0x88,0x16,0xC4,0xBE,0x20,0xFB,0x18,0x42,0x9A,0x8F,0x24,0xDB,0x0A,0x1B,0x8C,0xF7,0x01,0xDD,0x90,0x52,0x09,0xFE,0x14,0x86,0x67,0x94,0x3C,0xDA,0xCD,0x08,0xD6,0x34,0xF6,0xCB,0xB7,0xF9,0xC8,0x50,0x75,0x11,0x5B,0x2F,0x78,0x26,0x05,0x8D,0x56,0xAE,0xCE,0x25,0x5A,0x3B,0x54,0xA6,0x93,0xE8,0xD7,0x4D,0x6B,0x4F,0xAD,0x73,0xFD,0xD0,0xB5,0x57,0x2C,0xD9,0x6A,0xA1,0x99,0xF2,0x95,0x6D,0x33,0x40,0x55,0x5C,0xE4,0x41,0xDE,0xE2,0x2A,0x66,0x2D,0x1A,0xD1,0x68,0x9D,0xEA,0x87,0x77,0x47,0xA0,0xF0,0xEF,0x4E,0x12,0xB6,0x44,0x8E,0x13,0xA2,0x97,0xF5,0xAA,0xE5,0xF8,0x8B,0x91,0x02,0xFF,0xD5,0x72,0x04,0xF3,0x53,0xD2,0x4A,0x98,0x3A,0x21,0xA8,0x65,0xEE,0x0B,0x0F,0xBD,0x39,0xCC,0xBC,0x84,0x7B,0x00,0xE7,0xC1,0xE0,0x22,0xB4,0x1C,0x49,0x27,0x29,0x69,0x4B,0x85,0x32,0x48,0x10,0x03,0x7A,0x15,0xAF,0x51,0xEC,0x7E,0x43,0x5E,0xBA,0xBB,0xB1,0x1E,0xCA,0xED,0x6F,0x9E,0xAC,0x71,0x8A,0xAB,0x45,0x31,0x6E,0xD8,0x5D,0x28,0x23,0x60,0x7D,0x68,0xED,0x81,0xDE,0xD0,0xCD,0x98,0x93,0x2E,0x1C,0x5D,0xDF,0x1B,0xF5,0xC1,0x3A,0xEE,0x0A,0xCE,0xF3,0xAE,0x7A,0x0B,0x01,0xB3,0xCA,0xF8,0xA0,0xE1,0x5C,0xA5,0x1F,0x97,0x99,0xAC,0xF9,0x35,0x82,0xD9,0xFB,0xB0,0x57,0x34,0xCB,0x92,0x04,0x71,0x50,0xBB,0xBF,0xD5,0x5E,0x7C,0x0C,0x0D,0x89,0x62,0xFA,0x43,0xE3,0x91,0x18,0x28,0x8A,0x21,0xB2,0x48,0x3B,0xC2,0xB4,0x4F,0x65,0xA3,0x12,0xF4,0x3E,0x1A,0x55,0x27,0x45,0x10,0x40,0xC7,0xF7,0xA2,0x06,0x5F,0xFE,0xAA,0x61,0xD6,0x9D,0x5A,0x37,0xD8,0x2D,0xEC,0x54,0xF0,0xE5,0x52,0x9A,0xF1,0x6E,0x11,0x29,0x69,0xDA,0xDD,0x83,0x42,0x25,0xC3,0x4D,0xFF,0x1D,0xE7,0x9C,0x60,0x05,0x16,0x23,0x8B,0xE4,0xFD,0xDB,0x58,0x67,0x3D,0xE6,0x96,0xB5,0x95,0xEA,0x1E,0x7E,0xE0,0xC5,0x49,0x78,0x9F,0xC8,0xA1,0xEB,0xB8,0x66,0x6A,0x7D,0x7B,0x07,0x84,0x46,0x4E,0xA4,0xE2,0xB9,0x24,0x8C,0x36,0xD7,0xAB,0x3C,0x6B,0xBA,0x6D,0x20,0x47,0xB1,0x4B,0xA8,0x0E,0x90,0x3F,0x94,0xF2,0x2A,0x0F,0xB6,0x63,0xBE,0xA6,0x74,0x53,0x38,0x03,0x00,0x76,0x8E,0xAF,0x4A,0xBC,0x86,0xD1,0xBD,0xC4,0x02,0x75,0x31,0x33,0xDC,0xA7,0xC6,0x2F,0x73,0xE8,0x19,0xCF,0x56,0x44,0x6C,0x2B,0x9E,0x39,0xE9,0x6F,0x9B,0x14,0xD4,0xFC,0x88,0x77,0xAD,0x26,0x09,0x51,0xCC,0x5B,0xC0,0xD3,0x85,0xF6,0x59,0xD2,0x8F,0x80,0x79,0xEF,0x15,0x41,0x8D,0xB7,0x72,0x4C,0x7F,0xC9,0x87,0x17,0xA9,0x70,0x08,0x30,0x32,0x64,0x22,0x13,0x2C,0xC6,0x1E,0x0B,0xA0,0x3A,0xA4,0x7F,0x9C,0x73,0x85,0x59,0x14,0x5F,0x8E,0x9F,0x08,0x88,0xB2,0x9B,0x7E,0x42,0xBA,0x37,0x34,0x67,0x0C,0x92,0x40,0x57,0x8A,0x3B,0x82,0x95,0xDF,0xAB,0xFC,0x7D,0x4C,0xD4,0xF1,0x2A,0x4A,0xA1,0xDE,0xA2,0x81,0x09,0xD2,0x02,0xE3,0x10,0xB8,0xD6,0x8D,0x7A,0x90,0xB0,0x72,0x4F,0x33,0x5E,0x49,0x8C,0x52,0x75,0xB9,0xDB,0x21,0xB4,0x4D,0xE6,0xBB,0xC2,0x6D,0xE7,0xB1,0x6F,0xF4,0x65,0xF8,0x27,0x18,0x50,0x16,0x04,0x06,0x44,0x3C,0x23,0x9D,0xFD,0xB3,0x78,0x4B,0x83,0x46,0xFB,0x62,0xDC,0x2D,0x1B,0x47,0x93,0xF2,0x07,0xE8,0x41,0x05,0xF0,0x36,0xE5,0x89,0x12,0x3D,0x43,0x99,0xC8,0xBC,0x20,0xE0,0x5B,0xAF,0x0D,0xDD,0x1F,0xAA,0x70,0x58,0x45,0x64,0xA6,0x30,0x00,0xFF,0x84,0x63,0xED,0xCF,0x01,0xB6,0x98,0xCD,0xA3,0xAD,0x1C,0xBE,0xA5,0x2C,0x77,0xD7,0x56,0xCE,0x39,0xBD,0x48,0x38,0xE1,0x6A,0x8F,0x8B,0xF5,0x0E,0x2F,0xC1,0x69,0xEB,0x1A,0x28,0xAC,0xA7,0xE4,0xF9,0xB5,0xEA,0x5C,0xD9,0x91,0x2B,0xD5,0x68,0xCC,0x94,0x87,0xFE,0x3F,0x35,0x9A,0x4E,0xFA,0xC7,0xDA,0x3E,0x76,0x11,0xE9,0xB7,0x5D,0xEE,0x25,0x1D,0xC5,0x5A,0x66,0xAE,0xC4,0xD1,0xD8,0x60,0x6C,0x53,0xC9,0xEF,0xBF,0xD0,0x22,0x17,0x54,0x31,0xD3,0xA8,0xCB,0x29,0xF7,0x79,0x13,0x71,0x2E,0x61,0xC0,0x0A,0x97,0x26,0x7B,0x51,0xF6,0x80,0x7C,0x0F,0x15,0x86,0xEC,0x19,0x6E,0x03,0xE2,0xA9,0x9E,0x55,0x6B,0xCA,0x96,0x32,0xF3,0xC3,0x24,0x74,0x15,0x3A,0x44,0x9E,0xCF,0xBB,0x27,0xE7,0x5C,0xA8,0x0A,0xDA,0x18,0xAD,0x77,0x5F,0xFC,0x65,0xDB,0x2A,0x1C,0x40,0x94,0xF5,0x00,0xEF,0x46,0x02,0xF7,0x31,0xE2,0x8E,0x20,0x1F,0x57,0x11,0x03,0x01,0x43,0x3B,0x24,0x9A,0xFA,0xB4,0x7F,0x4C,0x84,0x41,0x72,0xBE,0xDC,0x26,0xB3,0x4A,0xE1,0xBC,0xC5,0x6A,0xE0,0xB6,0x68,0xF3,0x62,0xFF,0x05,0xE4,0x17,0xBF,0xD1,0x8A,0x7D,0x97,0xB7,0x75,0x48,0x34,0x59,0x4E,0x8B,0x55,0x92,0xD8,0xAC,0xFB,0x7A,0x4B,0xD3,0xF6,0x2D,0x4D,0xA6,0xD9,0xA5,0x86,0x0E,0xD5,0x8F,0xB5,0x9C,0x79,0x45,0xBD,0x30,0x33,0x60,0x0B,0x95,0x47,0x50,0x8D,0x3C,0x85,0xC1,0x19,0x0C,0xA7,0x3D,0xA3,0x78,0x9B,0x74,0x82,0x5E,0x13,0x58,0x89,0x98,0x0F,0xEB,0x1E,0x69,0x04,0xE5,0xAE,0x99,0x52,0x6C,0xCD,0x91,0x35,0xF4,0xC4,0x23,0x73,0x14,0x76,0x29,0x66,0xC7,0x0D,0x90,0x21,0x7C,0x56,0xF1,0x87,0x7B,0x08,0x12,0x81,0x6B,0x54,0xCE,0xE8,0xB8,0xD7,0x25,0x10,0x53,0x36,0xD4,0xAF,0xCC,0x2E,0xF0,0x7E,0x71,0x16,0xEE,0xB0,0x5A,0xE9,0x22,0x1A,0xC2,0x5D,0x61,0xA9,0xC3,0xD6,0xDF,0x67,0x96,0x2C,0xD2,0x6F,0xCB,0x93,0x80,0xF9,0x38,0x32,0x9D,0x49,0xFD,0xC0,0xDD,0x39,0xF2,0x09,0x28,0xC6,0x6E,0xEC,0x1D,0x2F,0xAB,0xA0,0xE3,0xFE,0xB2,0xED,0x5B,0xDE,0x1B,0xB9,0xA2,0x2B,0x70,0xD0,0x51,0xC9,0x3E,0xBA,0x4F,0x3F,0xE6,0x6D,0x88,0x8C,0x42,0x63,0xA1,0x37,0x07,0xF8,0x83,0x64,0xEA,0xC8,0x06,0xB1,0x9F,0xCA,0xA4,0xAA,0x8F,0x24,0x42,0x9A,0xFB,0x18,0xBE,0x20,0xDD,0x90,0xF7,0x01,0x1B,0x8C,0xDB,0x0A,0x1F,0xFA,0x0C,0x36,0xB3,0xB0,0xC6,0x3E,0x16,0xC4,0xE3,0x88,0xBF,0x06,0xD3,0x0E,0x2F,0x78,0x11,0x5B,0x50,0x75,0xF9,0xC8,0x25,0x5A,0xAE,0xCE,0x8D,0x56,0x26,0x05,0x94,0x3C,0x86,0x67,0xFE,0x14,0x52,0x09,0xCB,0xB7,0x34,0xF6,0x08,0xD6,0xDA,0xCD,0x5F,0xA5,0xF1,0x3D,0x62,0x3F,0x30,0xC9,0x63,0x35,0x46,0xE9,0xE1,0x7C,0xEB,0x70,0xD4,0x92,0xA3,0x9C,0xC0,0xB8,0x80,0x82,0x79,0x37,0xA7,0x19,0x07,0xC2,0xFC,0xCF,0x58,0xA9,0x7F,0xE6,0x17,0x76,0x9F,0xC3,0xC5,0x81,0x83,0x6C,0x61,0x0D,0x74,0xB2,0xC7,0x1D,0x96,0xB9,0xA4,0x64,0x4C,0x38,0x89,0x59,0xDF,0x2B,0xF4,0xDC,0x9B,0x2E,0x22,0xB4,0xC1,0xE0,0x00,0xE7,0x84,0x7B,0x85,0x32,0x69,0x4B,0x27,0x29,0x1C,0x49,0x21,0xA8,0x98,0x3A,0xD2,0x4A,0xF3,0x53,0xCC,0xBC,0xBD,0x39,0x0B,0x0F,0x65,0xEE,0xAB,0x45,0x71,0x8A,0x9E,0xAC,0xED,0x6F,0x60,0x7D,0x28,0x23,0xD8,0x5D,0x31,0x6E,0x51,0xEC,0x15,0xAF,0x03,0x7A,0x48,0x10,0x1E,0xCA,0xBB,0xB1,0x5E,0xBA,0x7E,0x43,0x6D,0x33,0xF2,0x95,0xA1,0x99,0xD9,0x6A,0xE2,0x2A,0x41,0xDE,0x5C,0xE4,0x40,0x55,0x4D,0x6B,0xE8,0xD7,0xA6,0x93,0x3B,0x54,0x57,0x2C,0xD0,0xB5,0x73,0xFD,0x4F,0xAD,0xAA,0xE5,0x97,0xF5,0x13,0xA2,0x44,0x8E,0x72,0x04,0xFF,0xD5,0x91,0x02,0xF8,0x8B,0xEA,0x87,0x68,0x9D,0x1A,0xD1,0x66,0x2D,0x12,0xB6,0xEF,0x4E,0xA0,0xF0,0x77,0x47,0xBD,0xD1,0x02,0xC4,0x31,0x75,0xDC,0x33,0xC6,0xA7,0x73,0x2F,0x19,0xE8,0x56,0xCF,0x6C,0x44,0x9E,0x2B,0xE9,0x39,0x9B,0x6F,0xD4,0x14,0x88,0xFC,0xAD,0x77,0x09,0x26,0xCC,0x51,0xC0,0x5B,0x85,0xD3,0x59,0xF6,0x8F,0xD2,0x79,0x80,0x15,0xEF,0x8D,0x41,0x72,0xB7,0x7F,0x4C,0x87,0xC9,0xA9,0x17,0x08,0x70,0x32,0x30,0x22,0x64,0x2C,0x13,0xE6,0x3D,0xB5,0x96,0xEA,0x95,0x7E,0x1E,0xC5,0xE0,0x78,0x49,0xC8,0x9F,0xEB,0xA1,0x66,0xB8,0x7D,0x6A,0x07,0x7B,0x46,0x84,0xA4,0x4E,0xB9,0xE2,0x8C,0x24,0xD7,0x36,0x3C,0xAB,0xBA,0x6B,0x20,0x6D,0xB1,0x47,0xA8,0x4B,0x90,0x0E,0x94,0x3F,0x2A,0xF2,0xB6,0x0F,0xBE,0x63,0x74,0xA6,0x38,0x53,0x00,0x03,0x8E,0x76,0x4A,0xAF,0x86,0xBC,0xB2,0x21,0x3B,0x48,0xB4,0xC2,0x65,0x4F,0x12,0xA3,0x3E,0xF4,0x55,0x1A,0x45,0x27,0x40,0x10,0xF7,0xC7,0x06,0xA2,0xFE,0x5F,0x61,0xAA,0x9D,0xD6,0x37,0x5A,0x2D,0xD8,0x54,0xEC,0xE5,0xF0,0x9A,0x52,0x6E,0xF1,0x29,0x11,0xDA,0x69,0x83,0xDD,0x25,0x42,0x4D,0xC3,0x1D,0xFF,0x9C,0xE7,0x05,0x60,0x23,0x16,0xE4,0x8B,0xDB,0xFD,0x67,0x58,0xED,0x68,0xDE,0x81,0xCD,0xD0,0x93,0x98,0x1C,0x2E,0xDF,0x5D,0xF5,0x1B,0x3A,0xC1,0x0A,0xEE,0xF3,0xCE,0x7A,0xAE,0x01,0x0B,0xCA,0xB3,0xA0,0xF8,0x5C,0xE1,0x1F,0xA5,0x99,0x97,0xF9,0xAC,0x82,0x35,0xFB,0xD9,0x57,0xB0,0xCB,0x34,0x04,0x92,0x50,0x71,0xBF,0xBB,0x5E,0xD5,0x0C,0x7C,0x89,0x0D,0xFA,0x62,0xE3,0x43,0x18,0x91,0x8A,0x28,0x54,0x31,0xD3,0xA8,0xCB,0x29,0xF7,0x79,0x6C,0x53,0xC9,0xEF,0xBF,0xD0,0x22,0x17,0xC5,0x5A,0x66,0xAE,0xC4,0xD1,0xD8,0x60,0x76,0x11,0xE9,0xB7,0x5D,0xEE,0x25,0x1D,0x6B,0xCA,0x96,0x32,0xF3,0xC3,0x24,0x74,0xEC,0x19,0x6E,0x03,0xE2,0xA9,0x9E,0x55,0x7B,0x51,0xF6,0x80,0x7C,0x0F,0x15,0x86,0x13,0x71,0x2E,0x61,0xC0,0x0A,0x97,0x26,0x39,0xBD,0x48,0x38,0xE1,0x6A,0x8F,0x8B,0x1C,0xBE,0xA5,0x2C,0x77,0xD7,0x56,0xCE,0xED,0xCF,0x01,0xB6,0x98,0xCD,0xA3,0xAD,0x45,0x64,0xA6,0x30,0x00,0xFF,0x84,0x63,0x3F,0x35,0x9A,0x4E,0xFA,0xC7,0xDA,0x3E,0x91,0x2B,0xD5,0x68,0xCC,0x94,0x87,0xFE,0xAC,0xA7,0xE4,0xF9,0xB5,0xEA,0x5C,0xD9,0xF5,0x0E,0x2F,0xC1,0x69,0xEB,0x1A,0x28,0x23,0x9D,0xFD,0xB3,0x78,0x4B,0x83,0x46,0x27,0x18,0x50,0x16,0x04,0x06,0x44,0x3C,0xC2,0x6D,0xE7,0xB1,0x6F,0xF4,0x65,0xF8,0x75,0xB9,0xDB,0x21,0xB4,0x4D,0xE6,0xBB,0x5B,0xAF,0x0D,0xDD,0x1F,0xAA,0x70,0x58,0x12,0x3D,0x43,0x99,0xC8,0xBC,0x20,0xE0,0x07,0xE8,0x41,0x05,0xF0,0x36,0xE5,0x89,0xFB,0x62,0xDC,0x2D,0x1B,0x47,0x93,0xF2,0x67,0x0C,0x92,0x40,0x57,0x8A,0x3B,0x82,0x88,0xB2,0x9B,0x7E,0x42,0xBA,0x37,0x34,0x73,0x85,0x59,0x14,0x5F,0x8E,0x9F,0x08,0xC6,0x1E,0x0B,0xA0,0x3A,0xA4,0x7F,0x9C,0xB0,0x72,0x4F,0x33,0x5E,0x49,0x8C,0x52,0x02,0xE3,0x10,0xB8,0xD6,0x8D,0x7A,0x90,0x2A,0x4A,0xA1,0xDE,0xA2,0x81,0x09,0xD2,0x95,0xDF,0xAB,0xFC,0x7D,0x4C,0xD4,0xF1,0x58,0x89,0x98,0x0F,0x74,0x82,0x5E,0x13,0x3D,0xA3,0x78,0x9B,0xC1,0x19,0x0C,0xA7,0x50,0x8D,0x3C,0x85,0x60,0x0B,0x95,0x47,0x45,0xBD,0x30,0x33,0x8F,0xB5,0x9C,0x79,0xA5,0x86,0x0E,0xD5,0x2D,0x4D,0xA6,0xD9,0x7A,0x4B,0xD3,0xF6,0x92,0xD8,0xAC,0xFB,0x59,0x4E,0x8B,0x55,0xB7,0x75,0x48,0x34,0xD1,0x8A,0x7D,0x97,0x05,0xE4,0x17,0xBF,0x68,0xF3,0x62,0xFF,0xC5,0x6A,0xE0,0xB6,0xB3,0x4A,0xE1,0xBC,0x72,0xBE,0xDC,0x26,0x7F,0x4C,0x84,0x41,0x24,0x9A,0xFA,0xB4,0x03,0x01,0x43,0x3B,0x20,0x1F,0x57,0x11,0xF7,0x31,0xE2,0x8E,0x00,0xEF,0x46,0x02,0x1C,0x40,0x94,0xF5,0xFC,0x65,0xDB,0x2A,0x18,0xAD,0x77,0x5F,0x5C,0xA8,0x0A,0xDA,0xCF,0xBB,0x27,0xE7,0x15,0x3A,0x44,0x9E,0x9F,0xCA,0xA4,0xAA,0xEA,0xC8,0x06,0xB1,0x07,0xF8,0x83,0x64,0x42,0x63,0xA1,0x37,0xE6,0x6D,0x88,0x8C,0x3E,0xBA,0x4F,0x3F,0x70,0xD0,0x51,0xC9,0x1B,0xB9,0xA2,0x2B,0xB2,0xED,0x5B,0xDE,0xAB,0xA0,0xE3,0xFE,0x6E,0xEC,0x1D,0x2F,0xF2,0x09,0x28,0xC6,0xFD,0xC0,0xDD,0x39,0x38,0x32,0x9D,0x49,0xCB,0x93,0x80,0xF9,0x96,0x2C,0xD2,0x6F,0xC3,0xD6,0xDF,0x67,0xC2,0x5D,0x61,0xA9,0x5A,0xE9,0x22,0x1A,0x71,0x16,0xEE,0xB0,0xCC,0x2E,0xF0,0x7E,0x53,0x36,0xD4,0xAF,0xB8,0xD7,0x25,0x10,0x6B,0x54,0xCE,0xE8,0x7B,0x08,0x12,0x81,0x7C,0x56,0xF1,0x87,0xC7,0x0D,0x90,0x21,0x14,0x76,0x29,0x66,0xF4,0xC4,0x23,0x73,0x6C,0xCD,0x91,0x35,0xE5,0xAE,0x99,0x52,0xEB,0x1E,0x69,0x04,0x31,0x6E,0xD8,0x5D,0x28,0x23,0x60,0x7D,0xED,0x6F,0x9E,0xAC,0x71,0x8A,0xAB,0x45,0x7E,0x43,0x5E,0xBA,0xBB,0xB1,0x1E,0xCA,0x48,0x10,0x03,0x7A,0x15,0xAF,0x51,0xEC,0x1C,0x49,0x27,0x29,0x69,0x4B,0x85,0x32,0x84,0x7B,0x00,0xE7,0xC1,0xE0,0x22,0xB4,0x65,0xEE,0x0B,0x0F,0xBD,0x39,0xCC,0xBC,0xF3,0x53,0xD2,0x4A,0x98,0x3A,0x21,0xA8,0xF8,0x8B,0x91,0x02,0xFF,0xD5,0x72,0x04,0x44,0x8E,0x13,0xA2,0x97,0xF5,0xAA,0xE5,0x77,0x47,0xA0,0xF0,0xEF,0x4E,0x12,0xB6,0x66,0x2D,0x1A,0xD1,0x68,0x9D,0xEA,0x87,0x40,0x55,0x5C,0xE4,0x41,0xDE,0xE2,0x2A,0xD9,0x6A,0xA1,0x99,0xF2,0x95,0x6D,0x33,0x4F,0xAD,0x73,0xFD,0xD0,0xB5,0x57,0x2C,0x3B,0x54,0xA6,0x93,0xE8,0xD7,0x4D,0x6B,0x26,0x05,0x8D,0x56,0xAE,0xCE,0x25,0x5A,0xF9,0xC8,0x50,0x75,0x11,0x5B,0x2F,0x78,0xDA,0xCD,0x08,0xD6,0x34,0xF6,0xCB,0xB7,0x52,0x09,0xFE,0x14,0x86,0x67,0x94,0x3C,0xDB,0x0A,0x1B,0x8C,0xF7,0x01,0xDD,0x90,0xBE,0x20,0xFB,0x18,0x42,0x9A,0x8F,0x24,0xD3,0x0E,0xBF,0x06,0xE3,0x88,0x16,0xC4,0xC6,0x3E,0xB3,0xB0,0x0C,0x36,0x1F,0xFA,0x74,0xB2,0x61,0x0D,0x83,0x6C,0xC5,0x81,0x9F,0xC3,0x17,0x76,0x7F,0xE6,0x58,0xA9,0x9B,0x2E,0xF4,0xDC,0xDF,0x2B,0x89,0x59,0x4C,0x38,0xA4,0x64,0x96,0xB9,0xC7,0x1D,0xEB,0x70,0xE1,0x7C,0x46,0xE9,0x63,0x35,0x30,0xC9,0x62,0x3F,0xF1,0x3D,0x5F,0xA5,0xFC,0xCF,0x07,0xC2,0xA7,0x19,0x79,0x37,0x80,0x82,0xC0,0xB8,0xA3,0x9C,0xD4,0x92,0x48,0x3B,0x21,0xB2,0x4F,0x65,0xC2,0xB4,0xF4,0x3E,0xA3,0x12,0x27,0x45,0x1A,0x55,0xC7,0xF7,0x10,0x40,0x5F,0xFE,0xA2,0x06,0xD6,0x9D,0xAA,0x61,0xD8,0x2D,0x5A,0x37,0xF0,0xE5,0xEC,0x54,0xF1,0x6E,0x52,0x9A,0x69,0xDA,0x11,0x29,0x42,0x25,0xDD,0x83,0xFF,0x1D,0xC3,0x4D,0x60,0x05,0xE7,0x9C,0x8B,0xE4,0x16,0x23,0x58,0x67,0xFD,0xDB,0x81,0xDE,0x68,0xED,0x98,0x93,0xD0,0xCD,0x5D,0xDF,0x2E,0x1C,0xC1,0x3A,0x1B,0xF5,0xCE,0xF3,0xEE,0x0A,0x0B,0x01,0xAE,0x7A,0xF8,0xA0,0xB3,0xCA,0xA5,0x1F,0xE1,0x5C,0xAC,0xF9,0x97,0x99,0xD9,0xFB,0x35,0x82,0x34,0xCB,0xB0,0x57,0x71,0x50,0x92,0x04,0xD5,0x5E,0xBB,0xBF,0x0D,0x89,0x7C,0x0C,0x43,0xE3,0x62,0xFA,0x28,0x8A,0x91,0x18,0xC4,0x02,0xD1,0xBD,0x33,0xDC,0x75,0x31,0x2F,0x73,0xA7,0xC6,0xCF,0x56,0xE8,0x19,0x2B,0x9E,0x44,0x6C,0x6F,0x9B,0x39,0xE9,0xFC,0x88,0x14,0xD4,0x26,0x09,0x77,0xAD,0x5B,0xC0,0x51,0xCC,0xF6,0x59,0xD3,0x85,0x80,0x79,0xD2,0x8F,0x41,0x8D,0xEF,0x15,0x4C,0x7F,0xB7,0x72,0x17,0xA9,0xC9,0x87,0x30,0x32,0x70,0x08,0x13,0x2C,0x64,0x22,0x96,0xB5,0x3D,0xE6,0x1E,0x7E,0x95,0xEA,0x49,0x78,0xE0,0xC5,0xA1,0xEB,0x9F,0xC8,0x6A,0x7D,0xB8,0x66,0x84,0x46,0x7B,0x07,0xE2,0xB9,0x4E,0xA4,0x36,0xD7,0x24,0x8C,0x6B,0xBA,0xAB,0x3C,0x47,0xB1,0x6D,0x20,0x0E,0x90,0x4B,0xA8,0xF2,0x2A,0x3F,0x94,0x63,0xBE,0x0F,0xB6,0x53,0x38,0xA6,0x74,0x76,0x8E,0x03,0x00,0xBC,0x86,0xAF,0x4A,0x99,0x43,0x3D,0x12,0xE0,0x20,0xBC,0xC8,0xDD,0x0D,0xAF,0x5B,0x58,0x70,0xAA,0x1F,0x2D,0xDC,0x62,0xFB,0xF2,0x93,0x47,0x1B,0x05,0x41,0xE8,0x07,0x89,0xE5,0x36,0xF0,0x16,0x50,0x18,0x27,0x3C,0x44,0x06,0x04,0xB3,0xFD,0x9D,0x23,0x46,0x83,0x4B,0x78,0x21,0xDB,0xB9,0x75,0xBB,0xE6,0x4D,0xB4,0xB1,0xE7,0x6D,0xC2,0xF8,0x65,0xF4,0x6F,0xB8,0x10,0xE3,0x02,0x90,0x7A,0x8D,0xD6,0x33,0x4F,0x72,0xB0,0x52,0x8C,0x49,0x5E,0xFC,0xAB,0xDF,0x95,0xF1,0xD4,0x4C,0x7D,0xDE,0xA1,0x4A,0x2A,0xD2,0x09,0x81,0xA2,0x7E,0x9B,0xB2,0x88,0x34,0x37,0xBA,0x42,0x40,0x92,0x0C,0x67,0x82,0x3B,0x8A,0x57,0xA0,0x0B,0x1E,0xC6,0x9C,0x7F,0xA4,0x3A,0x14,0x59,0x85,0x73,0x08,0x9F,0x8E,0x5F,0x03,0x6E,0x19,0xEC,0x55,0x9E,0xA9,0xE2,0x32,0x96,0xCA,0x6B,0x74,0x24,0xC3,0xF3,0x61,0x2E,0x71,0x13,0x26,0x97,0x0A,0xC0,0x80,0xF6,0x51,0x7B,0x86,0x15,0x0F,0x7C,0xEF,0xC9,0x53,0x6C,0x17,0x22,0xD0,0xBF,0xA8,0xD3,0x31,0x54,0x79,0xF7,0x29,0xCB,0xB7,0xE9,0x11,0x76,0x1D,0x25,0xEE,0x5D,0xAE,0x66,0x5A,0xC5,0x60,0xD8,0xD1,0xC4,0x68,0xD5,0x2B,0x91,0xFE,0x87,0x94,0xCC,0x4E,0x9A,0x35,0x3F,0x3E,0xDA,0xC7,0xFA,0xC1,0x2F,0x0E,0xF5,0x28,0x1A,0xEB,0x69,0xF9,0xE4,0xA7,0xAC,0xD9,0x5C,0xEA,0xB5,0x2C,0xA5,0xBE,0x1C,0xCE,0x56,0xD7,0x77,0x38,0x48,0xBD,0x39,0x8B,0x8F,0x6A,0xE1,0x30,0xA6,0x64,0x45,0x63,0x84,0xFF,0x00,0xB6,0x01,0xCF,0xED,0xAD,0xA3,0xCD,0x98,0xA0,0xAB,0xFE,0xE3,0xED,0xB2,0xDE,0x5B,0x09,0xF2,0xC6,0x28,0xEC,0x6E,0x2F,0x1D,0x32,0x38,0x49,0x9D,0xC0,0xFD,0x39,0xDD,0x2C,0x96,0x6F,0xD2,0x93,0xCB,0xF9,0x80,0xC8,0xEA,0xB1,0x06,0xCA,0x9F,0xAA,0xA4,0x63,0x42,0x37,0xA1,0xF8,0x07,0x64,0x83,0xBA,0x3E,0x3F,0x4F,0x6D,0xE6,0x8C,0x88,0xB9,0x1B,0x2B,0xA2,0xD0,0x70,0xC9,0x51,0x56,0x7C,0x87,0xF1,0x08,0x7B,0x81,0x12,0x76,0x14,0x66,0x29,0x0D,0xC7,0x21,0x90,0xCD,0x6C,0x35,0x91,0xC4,0xF4,0x73,0x23,0x1E,0xEB,0x04,0x69,0xAE,0xE5,0x52,0x99,0x5D,0xC2,0xA9,0x61,0xD6,0xC3,0x67,0xDF,0x16,0x71,0xB0,0xEE,0xE9,0x5A,0x1A,0x22,0x36,0x53,0xAF,0xD4,0x2E,0xCC,0x7E,0xF0,0x54,0x6B,0xE8,0xCE,0xD7,0xB8,0x10,0x25,0x4D,0x2D,0xD9,0xA6,0x86,0xA5,0xD5,0x0E,0xD8,0x92,0xFB,0xAC,0x4B,0x7A,0xF6,0xD3,0x75,0xB7,0x34,0x48,0x4E,0x59,0x55,0x8B,0xE4,0x05,0xBF,0x17,0x8A,0xD1,0x97,0x7D,0x82,0x74,0x13,0x5E,0x89,0x58,0x0F,0x98,0x19,0xC1,0xA7,0x0C,0xA3,0x3D,0x9B,0x78,0x0B,0x60,0x47,0x95,0x8D,0x50,0x85,0x3C,0xB5,0x8F,0x79,0x9C,0xBD,0x45,0x33,0x30,0xEF,0x00,0x02,0x46,0x31,0xF7,0x8E,0xE2,0x65,0xFC,0x2A,0xDB,0x40,0x1C,0xF5,0x94,0xA8,0x5C,0xDA,0x0A,0xAD,0x18,0x5F,0x77,0x3A,0x15,0x9E,0x44,0xBB,0xCF,0xE7,0x27,0x6A,0xC5,0xB6,0xE0,0xF3,0x68,0xFF,0x62,0xBE,0x72,0x26,0xDC,0x4A,0xB3,0xBC,0xE1,0x9A,0x24,0xB4,0xFA,0x4C,0x7F,0x41,0x84,0x1F,0x20,0x11,0x57,0x01,0x03,0x3B,0x43,0x1E,0xCA,0xBB,0xB1,0x5E,0xBA,0x7E,0x43,0x51,0xEC,0x15,0xAF,0x03,0x7A,0x48,0x10,0x60,0x7D,0x28,0x23,0xD8,0x5D,0x31,0x6E,0xAB,0x45,0x71,0x8A,0x9E,0xAC,0xED,0x6F,0xCC,0xBC,0xBD,0x39,0x0B,0x0F,0x65,0xEE,0x21,0xA8,0x98,0x3A,0xD2,0x4A,0xF3,0x53,0x85,0x32,0x69,0x4B,0x27,0x29,0x1C,0x49,0x22,0xB4,0xC1,0xE0,0x00,0xE7,0x84,0x7B,0x12,0xB6,0xEF,0x4E,0xA0,0xF0,0x77,0x47,0xEA,0x87,0x68,0x9D,0x1A,0xD1,0x66,0x2D,0x72,0x04,0xFF,0xD5,0x91,0x02,0xF8,0x8B,0xAA,0xE5,0x97,0xF5,0x13,0xA2,0x44,0x8E,0x57,0x2C,0xD0,0xB5,0x73,0xFD,0x4F,0xAD,0x4D,0x6B,0xE8,0xD7,0xA6,0x93,0x3B,0x54,0xE2,0x2A,0x41,0xDE,0x5C,0xE4,0x40,0x55,0x6D,0x33,0xF2,0x95,0xA1,0x99,0xD9,0x6A,0xCB,0xB7,0x34,0xF6,0x08,0xD6,0xDA,0xCD,0x94,0x3C,0x86,0x67,0xFE,0x14,0x52,0x09,0x25,0x5A,0xAE,0xCE,0x8D,0x56,0x26,0x05,0x2F,0x78,0x11,0x5B,0x50,0x75,0xF9,0xC8,0x16,0xC4,0xE3,0x88,0xBF,0x06,0xD3,0x0E,0x1F,0xFA,0x0C,0x36,0xB3,0xB0,0xC6,0x3E,0xDD,0x90,0xF7,0x01,0x1B,0x8C,0xDB,0x0A,0x8F,0x24,0x42,0x9A,0xFB,0x18,0xBE,0x20,0x89,0x59,0xDF,0x2B,0xF4,0xDC,0x9B,0x2E,0xC7,0x1D,0x96,0xB9,0xA4,0x64,0x4C,0x38,0xC5,0x81,0x83,0x6C,0x61,0x0D,0x74,0xB2,0x58,0xA9,0x7F,0xE6,0x17,0x76,0x9F,0xC3,0x79,0x37,0xA7,0x19,0x07,0xC2,0xFC,0xCF,0xD4,0x92,0xA3,0x9C,0xC0,0xB8,0x80,0x82,0x63,0x35,0x46,0xE9,0xE1,0x7C,0xEB,0x70,0x5F,0xA5,0xF1,0x3D,0x62,0x3F,0x30,0xC9,0x20,0x6D,0xB1,0x47,0x3C,0xAB,0xBA,0x6B,0x94,0x3F,0x2A,0xF2,0xA8,0x4B,0x90,0x0E,0x74,0xA6,0x38,0x53,0xB6,0x0F,0xBE,0x63,0x4A,0xAF,0x86,0xBC,0x00,0x03,0x8E,0x76,0xEA,0x95,0x7E,0x1E,0xE6,0x3D,0xB5,0x96,0xC8,0x9F,0xEB,0xA1,0xC5,0xE0,0x78,0x49,0x07,0x7B,0x46,0x84,0x66,0xB8,0x7D,0x6A,0x8C,0x24,0xD7,0x36,0xA4,0x4E,0xB9,0xE2,0x85,0xD3,0x59,0xF6,0xCC,0x51,0xC0,0x5B,0x15,0xEF,0x8D,0x41,0x8F,0xD2,0x79,0x80,0x87,0xC9,0xA9,0x17,0x72,0xB7,0x7F,0x4C,0x22,0x64,0x2C,0x13,0x08,0x70,0x32,0x30,0x31,0x75,0xDC,0x33,0xBD,0xD1,0x02,0xC4,0x19,0xE8,0x56,0xCF,0xC6,0xA7,0x73,0x2F,0xE9,0x39,0x9B,0x6F,0x6C,0x44,0x9E,0x2B,0xAD,0x77,0x09,0x26,0xD4,0x14,0x88,0xFC,0x82,0x35,0xFB,0xD9,0x99,0x97,0xF9,0xAC,0x04,0x92,0x50,0x71,0x57,0xB0,0xCB,0x34,0x0C,0x7C,0x89,0x0D,0xBF,0xBB,0x5E,0xD5,0x18,0x91,0x8A,0x28,0xFA,0x62,0xE3,0x43,0xCD,0xD0,0x93,0x98,0xED,0x68,0xDE,0x81,0xF5,0x1B,0x3A,0xC1,0x1C,0x2E,0xDF,0x5D,0x7A,0xAE,0x01,0x0B,0x0A,0xEE,0xF3,0xCE,0x5C,0xE1,0x1F,0xA5,0xCA,0xB3,0xA0,0xF8,0x9A,0x52,0x6E,0xF1,0x54,0xEC,0xE5,0xF0,0x83,0xDD,0x25,0x42,0x29,0x11,0xDA,0x69,0x9C,0xE7,0x05,0x60,0x4D,0xC3,0x1D,0xFF,0xDB,0xFD,0x67,0x58,0x23,0x16,0xE4,0x8B,0xB4,0xC2,0x65,0x4F,0xB2,0x21,0x3B,0x48,0x55,0x1A,0x45,0x27,0x12,0xA3,0x3E,0xF4,0x06,0xA2,0xFE,0x5F,0x40,0x10,0xF7,0xC7,0x37,0x5A,0x2D,0xD8,0x61,0xAA,0x9D,0xD6,0x6E,0x03,0xEC,0x19,0x9E,0x55,0xE2,0xA9,0x96,0x32,0x6B,0xCA,0x24,0x74,0xF3,0xC3,0x2E,0x61,0x13,0x71,0x97,0x26,0xC0,0x0A,0xF6,0x80,0x7B,0x51,0x15,0x86,0x7C,0x0F,0xC9,0xEF,0x6C,0x53,0x22,0x17,0xBF,0xD0,0xD3,0xA8,0x54,0x31,0xF7,0x79,0xCB,0x29,0xE9,0xB7,0x76,0x11,0x25,0x1D,0x5D,0xEE,0x66,0xAE,0xC5,0x5A,0xD8,0x60,0xC4,0xD1,0xD5,0x68,0x91,0x2B,0x87,0xFE,0xCC,0x94,0x9A,0x4E,0x3F,0x35,0xDA,0x3E,0xFA,0xC7,0x2F,0xC1,0xF5,0x0E,0x1A,0x28,0x69,0xEB,0xE4,0xF9,0xAC,0xA7,0x5C,0xD9,0xB5,0xEA,0xA5,0x2C,0x1C,0xBE,0x56,0xCE,0x77,0xD7,0x48,0x38,0x39,0xBD,0x8F,0x8B,0xE1,0x6A,0xA6,0x30,0x45,0x64,0x84,0x63,0x00,0xFF,0x01,0xB6,0xED,0xCF,0xA3,0xAD,0x98,0xCD,0x43,0x99,0x12,0x3D,0x20,0xE0,0xC8,0xBC,0x0D,0xDD,0x5B,0xAF,0x70,0x58,0x1F,0xAA,0xDC,0x2D,0xFB,0x62,0x93,0xF2,0x1B,0x47,0x41,0x05,0x07,0xE8,0xE5,0x89,0xF0,0x36,0x50,0x16,0x27,0x18,0x44,0x3C,0x04,0x06,0xFD,0xB3,0x23,0x9D,0x83,0x46,0x78,0x4B,0xDB,0x21,0x75,0xB9,0xE6,0xBB,0xB4,0x4D,0xE7,0xB1,0xC2,0x6D,0x65,0xF8,0x6F,0xF4,0x10,0xB8,0x02,0xE3,0x7A,0x90,0xD6,0x8D,0x4F,0x33,0xB0,0x72,0x8C,0x52,0x5E,0x49,0xAB,0xFC,0x95,0xDF,0xD4,0xF1,0x7D,0x4C,0xA1,0xDE,0x2A,0x4A,0x09,0xD2,0xA2,0x81,0x9B,0x7E,0x88,0xB2,0x37,0x34,0x42,0xBA,0x92,0x40,0x67,0x0C,0x3B,0x82,0x57,0x8A,0x0B,0xA0,0xC6,0x1E,0x7F,0x9C,0x3A,0xA4,0x59,0x14,0x73,0x85,0x9F,0x08,0x5F,0x8E,0xFD,0xC0,0xDD,0x39,0x38,0x32,0x9D,0x49,0xCB,0x93,0x80,0xF9,0x96,0x2C,0xD2,0x6F,0xB2,0xED,0x5B,0xDE,0xAB,0xA0,0xE3,0xFE,0x6E,0xEC,0x1D,0x2F,0xF2,0x09,0x28,0xC6,0xE6,0x6D,0x88,0x8C,0x3E,0xBA,0x4F,0x3F,0x70,0xD0,0x51,0xC9,0x1B,0xB9,0xA2,0x2B,0x9F,0xCA,0xA4,0xAA,0xEA,0xC8,0x06,0xB1,0x07,0xF8,0x83,0x64,0x42,0x63,0xA1,0x37,0xF4,0xC4,0x23,0x73,0x6C,0xCD,0x91,0x35,0xE5,0xAE,0x99,0x52,0xEB,0x1E,0x69,0x04,0x7B,0x08,0x12,0x81,0x7C,0x56,0xF1,0x87,0xC7,0x0D,0x90,0x21,0x14,0x76,0x29,0x66,0xCC,0x2E,0xF0,0x7E,0x53,0x36,0xD4,0xAF,0xB8,0xD7,0x25,0x10,0x6B,0x54,0xCE,0xE8,0xC3,0xD6,0xDF,0x67,0xC2,0x5D,0x61,0xA9,0x5A,0xE9,0x22,0x1A,0x71,0x16,0xEE,0xB0,0x59,0x4E,0x8B,0x55,0xB7,0x75,0x48,0x34,0xD1,0x8A,0x7D,0x97,0x05,0xE4,0x17,0xBF,0xA5,0x86,0x0E,0xD5,0x2D,0x4D,0xA6,0xD9,0x7A,0x4B,0xD3,0xF6,0x92,0xD8,0xAC,0xFB,0x50,0x8D,0x3C,0x85,0x60,0x0B,0x95,0x47,0x45,0xBD,0x30,0x33,0x8F,0xB5,0x9C,0x79,0x58,0x89,0x98,0x0F,0x74,0x82,0x5E,0x13,0x3D,0xA3,0x78,0x9B,0xC1,0x19,0x0C,0xA7,0x18,0xAD,0x77,0x5F,0x5C,0xA8,0x0A,0xDA,0xCF,0xBB,0x27,0xE7,0x15,0x3A,0x44,0x9E,0xF7,0x31,0xE2,0x8E,0x00,0xEF,0x46,0x02,0x1C,0x40,0x94,0xF5,0xFC,0x65,0xDB,0x2A,0x7F,0x4C,0x84,0x41,0x24,0x9A,0xFA,0xB4,0x03,0x01,0x43,0x3B,0x20,0x1F,0x57,0x11,0x68,0xF3,0x62,0xFF,0xC5,0x6A,0xE0,0xB6,0xB3,0x4A,0xE1,0xBC,0x72,0xBE,0xDC,0x26,0x3E,0xC6,0xB0,0xB3,0x36,0x0C,0xFA,0x1F,0x0E,0xD3,0x06,0xBF,0x88,0xE3,0xC4,0x16,0x20,0xBE,0x18,0xFB,0x9A,0x42,0x24,0x8F,0x0A,0xDB,0x8C,0x1B,0x01,0xF7,0x90,0xDD,0x09,0x52,0x14,0xFE,0x67,0x86,0x3C,0x94,0xCD,0xDA,0xD6,0x08,0xF6,0x34,0xB7,0xCB,0xC8,0xF9,0x75,0x50,0x5B,0x11,0x78,0x2F,0x05,0x26,0x56,0x8D,0xCE,0xAE,0x5A,0x25,0x82,0x80,0xB8,0xC0,0x9C,0xA3,0x92,0xD4,0xCF,0xFC,0xC2,0x07,0x19,0xA7,0x37,0x79,0xC9,0x30,0x3F,0x62,0x3D,0xF1,0xA5,0x5F,0x70,0xEB,0x7C,0xE1,0xE9,0x46,0x35,0x63,0x38,0x4C,0x64,0xA4,0xB9,0x96,0x1D,0xC7,0x2E,0x9B,0xDC,0xF4,0x2B,0xDF,0x59,0x89,0xC3,0x9F,0x76,0x17,0xE6,0x7F,0xA9,0x58,0xB2,0x74,0x0D,0x61,0x6C,0x83,0x81,0xC5,0x53,0xF3,0x4A,0xD2,0x3A,0x98,0xA8,0x21,0xEE,0x65,0x0F,0x0B,0x39,0xBD,0xBC,0xCC,0x7B,0x84,0xE7,0x00,0xE0,0xC1,0xB4,0x22,0x49,0x1C,0x29,0x27,0x4B,0x69,0x32,0x85,0x10,0x48,0x7A,0x03,0xAF,0x15,0xEC,0x51,0x43,0x7E,0xBA,0x5E,0xB1,0xBB,0xCA,0x1E,0x6F,0xED,0xAC,0x9E,0x8A,0x71,0x45,0xAB,0x6E,0x31,0x5D,0xD8,0x23,0x28,0x7D,0x60,0x54,0x3B,0x93,0xA6,0xD7,0xE8,0x6B,0x4D,0xAD,0x4F,0xFD,0x73,0xB5,0xD0,0x2C,0x57,0x6A,0xD9,0x99,0xA1,0x95,0xF2,0x33,0x6D,0x55,0x40,0xE4,0x5C,0xDE,0x41,0x2A,0xE2,0x2D,0x66,0xD1,0x1A,0x9D,0x68,0x87,0xEA,0x47,0x77,0xF0,0xA0,0x4E,0xEF,0xB6,0x12,0x8E,0x44,0xA2,0x13,0xF5,0x97,0xE5,0xAA,0x8B,0xF8,0x02,0x91,0xD5,0xFF,0x04,0x72,0xA2,0x06,0x5F,0xFE,0x10,0x40,0xC7,0xF7,0x5A,0x37,0xD8,0x2D,0xAA,0x61,0xD6,0x9D,0xC2,0xB4,0x4F,0x65,0x21,0xB2,0x48,0x3B,0x1A,0x55,0x27,0x45,0xA3,0x12,0xF4,0x3E,0xE7,0x9C,0x60,0x05,0xC3,0x4D,0xFF,0x1D,0xFD,0xDB,0x58,0x67,0x16,0x23,0x8B,0xE4,0x52,0x9A,0xF1,0x6E,0xEC,0x54,0xF0,0xE5,0xDD,0x83,0x42,0x25,0x11,0x29,0x69,0xDA,0xAE,0x7A,0x0B,0x01,0xEE,0x0A,0xCE,0xF3,0xE1,0x5C,0xA5,0x1F,0xB3,0xCA,0xF8,0xA0,0xD0,0xCD,0x98,0x93,0x68,0xED,0x81,0xDE,0x1B,0xF5,0xC1,0x3A,0x2E,0x1C,0x5D,0xDF,0x7C,0x0C,0x0D,0x89,0xBB,0xBF,0xD5,0x5E,0x91,0x18,0x28,0x8A,0x62,0xFA,0x43,0xE3,0x35,0x82,0xD9,0xFB,0x97,0x99,0xAC,0xF9,0x92,0x04,0x71,0x50,0xB0,0x57,0x34,0xCB,0x39,0xE9,0x6F,0x9B,0x44,0x6C,0x2B,0x9E,0x77,0xAD,0x26,0x09,0x14,0xD4,0xFC,0x88,0x75,0x31,0x33,0xDC,0xD1,0xBD,0xC4,0x02,0xE8,0x19,0xCF,0x56,0xA7,0xC6,0x2F,0x73,0xC9,0x87,0x17,0xA9,0xB7,0x72,0x4C,0x7F,0x64,0x22,0x13,0x2C,0x70,0x08,0x30,0x32,0xD3,0x85,0xF6,0x59,0x51,0xCC,0x5B,0xC0,0xEF,0x15,0x41,0x8D,0xD2,0x8F,0x80,0x79,0x7B,0x07,0x84,0x46,0xB8,0x66,0x6A,0x7D,0x24,0x8C,0x36,0xD7,0x4E,0xA4,0xE2,0xB9,0x95,0xEA,0x1E,0x7E,0x3D,0xE6,0x96,0xB5,0x9F,0xC8,0xA1,0xEB,0xE0,0xC5,0x49,0x78,0xA6,0x74,0x53,0x38,0x0F,0xB6,0x63,0xBE,0xAF,0x4A,0xBC,0x86,0x03,0x00,0x76,0x8E,0x6D,0x20,0x47,0xB1,0xAB,0x3C,0x6B,0xBA,0x3F,0x94,0xF2,0x2A,0x4B,0xA8,0x0E,0x90,0xDB,0x21,0x75,0xB9,0xE6,0xBB,0xB4,0x4D,0xE7,0xB1,0xC2,0x6D,0x65,0xF8,0x6F,0xF4,0x50,0x16,0x27,0x18,0x44,0x3C,0x04,0x06,0xFD,0xB3,0x23,0x9D,0x83,0x46,0x78,0x4B,0xDC,0x2D,0xFB,0x62,0x93,0xF2,0x1B,0x47,0x41,0x05,0x07,0xE8,0xE5,0x89,0xF0,0x36,0x43,0x99,0x12,0x3D,0x20,0xE0,0xC8,0xBC,0x0D,0xDD,0x5B,0xAF,0x70,0x58,0x1F,0xAA,0x0B,0xA0,0xC6,0x1E,0x7F,0x9C,0x3A,0xA4,0x59,0x14,0x73,0x85,0x9F,0x08,0x5F,0x8E,0x9B,0x7E,0x88,0xB2,0x37,0x34,0x42,0xBA,0x92,0x40,0x67,0x0C,0x3B,0x82,0x57,0x8A,0xAB,0xFC,0x95,0xDF,0xD4,0xF1,0x7D,0x4C,0xA1,0xDE,0x2A,0x4A,0x09,0xD2,0xA2,0x81,0x10,0xB8,0x02,0xE3,0x7A,0x90,0xD6,0x8D,0x4F,0x33,0xB0,0x72,0x8C,0x52,0x5E,0x49,0xE9,0xB7,0x76,0x11,0x25,0x1D,0x5D,0xEE,0x66,0xAE,0xC5,0x5A,0xD8,0x60,0xC4,0xD1,0xC9,0xEF,0x6C,0x53,0x22,0x17,0xBF,0xD0,0xD3,0xA8,0x54,0x31,0xF7,0x79,0xCB,0x29,0x2E,0x61,0x13,0x71,0x97,0x26,0xC0,0x0A,0xF6,0x80,0x7B,0x51,0x15,0x86,0x7C,0x0F,0x6E,0x03,0xEC,0x19,0x9E,0x55,0xE2,0xA9,0x96,0x32,0x6B,0xCA,0x24,0x74,0xF3,0xC3,0xA6,0x30,0x45,0x64,0x84,0x63,0x00,0xFF,0x01,0xB6,0xED,0xCF,0xA3,0xAD,0x98,0xCD,0xA5,0x2C,0x1C,0xBE,0x56,0xCE,0x77,0xD7,0x48,0x38,0x39,0xBD,0x8F,0x8B,0xE1,0x6A,0x2F,0xC1,0xF5,0x0E,0x1A,0x28,0x69,0xEB,0xE4,0xF9,0xAC,0xA7,0x5C,0xD9,0xB5,0xEA,0xD5,0x68,0x91,0x2B,0x87,0xFE,0xCC,0x94,0x9A,0x4E,0x3F,0x35,0xDA,0x3E,0xFA,0xC7,0xD4,0xAF,0x53,0x36,0xF0,0x7E,0xCC,0x2E,0xCE,0xE8,0x6B,0x54,0x25,0x10,0xB8,0xD7,0x61,0xA9,0xC2,0x5D,0xDF,0x67,0xC3,0xD6,0xEE,0xB0,0x71,0x16,0x22,0x1A,0x5A,0xE9,0x91,0x35,0x6C,0xCD,0x23,0x73,0xF4,0xC4,0x69,0x04,0xEB,0x1E,0x99,0x52,0xE5,0xAE,0xF1,0x87,0x7C,0x56,0x12,0x81,0x7B,0x08,0x29,0x66,0x14,0x76,0x90,0x21,0xC7,0x0D,0x4F,0x3F,0x3E,0xBA,0x88,0x8C,0xE6,0x6D,0xA2,0x2B,0x1B,0xB9,0x51,0xC9,0x70,0xD0,0x06,0xB1,0xEA,0xC8,0xA4,0xAA,0x9F,0xCA,0xA1,0x37,0x42,0x63,0x83,0x64,0x07,0xF8,0x9D,0x49,0x38,0x32,0xDD,0x39,0xFD,0xC0,0xD2,0x6F,0x96,0x2C,0x80,0xF9,0xCB,0x93,0xE3,0xFE,0xAB,0xA0,0x5B,0xDE,0xB2,0xED,0x28,0xC6,0xF2,0x09,0x1D,0x2F,0x6E,0xEC,0xFA,0xB4,0x24,0x9A,0x84,0x41,0x7F,0x4C,0x57,0x11,0x20,0x1F,0x43,0x3B,0x03,0x01,0xE0,0xB6,0xC5,0x6A,0x62,0xFF,0x68,0xF3,0xDC,0x26,0x72,0xBE,0xE1,0xBC,0xB3,0x4A,0x0A,0xDA,0x5C,0xA8,0x77,0x5F,0x18,0xAD,0x44,0x9E,0x15,0x3A,0x27,0xE7,0xCF,0xBB,0x46,0x02,0x00,0xEF,0xE2,0x8E,0xF7,0x31,0xDB,0x2A,0xFC,0x65,0x94,0xF5,0x1C,0x40,0x95,0x47,0x60,0x0B,0x3C,0x85,0x50,0x8D,0x9C,0x79,0x8F,0xB5,0x30,0x33,0x45,0xBD,0x5E,0x13,0x74,0x82,0x98,0x0F,0x58,0x89,0x0C,0xA7,0xC1,0x19,0x78,0x9B,0x3D,0xA3,0x48,0x34,0xB7,0x75,0x8B,0x55,0x59,0x4E,0x17,0xBF,0x05,0xE4,0x7D,0x97,0xD1,0x8A,0xA6,0xD9,0x2D,0x4D,0x0E,0xD5,0xA5,0x86,0xAC,0xFB,0x92,0xD8,0xD3,0xF6,0x7A,0x4B,0x27,0x29,0x1C,0x49,0x85,0x32,0x69,0x4B,0x00,0xE7,0x84,0x7B,0x22,0xB4,0xC1,0xE0,0x0B,0x0F,0x65,0xEE,0xCC,0xBC,0xBD,0x39,0xD2,0x4A,0xF3,0x53,0x21,0xA8,0x98,0x3A,0xD8,0x5D,0x31,0x6E,0x60,0x7D,0x28,0x23,0x9E,0xAC,0xED,0x6F,0xAB,0x45,0x71,0x8A,0x5E,0xBA,0x7E,0x43,0x1E,0xCA,0xBB,0xB1,0x03,0x7A,0x48,0x10,0x51,0xEC,0x15,0xAF,0x5C,0xE4,0x40,0x55,0xE2,0x2A,0x41,0xDE,0xA1,0x99,0xD9,0x6A,0x6D,0x33,0xF2,0x95,0x73,0xFD,0x4F,0xAD,0x57,0x2C,0xD0,0xB5,0xA6,0x93,0x3B,0x54,0x4D,0x6B,0xE8,0xD7,0x91,0x02,0xF8,0x8B,0x72,0x04,0xFF,0xD5,0x13,0xA2,0x44,0x8E,0xAA,0xE5,0x97,0xF5,0xA0,0xF0,0x77,0x47,0x12,0xB6,0xEF,0x4E,0x1A,0xD1,0x66,0x2D,0xEA,0x87,0x68,0x9D,0x1B,0x8C,0xDB,0x0A,0xDD,0x90,0xF7,0x01,0xFB,0x18,0xBE,0x20,0x8F,0x24,0x42,0x9A,0xBF,0x06,0xD3,0x0E,0x16,0xC4,0xE3,0x88,0xB3,0xB0,0xC6,0x3E,0x1F,0xFA,0x0C,0x36,0x8D,0x56,0x26,0x05,0x25,0x5A,0xAE,0xCE,0x50,0x75,0xF9,0xC8,0x2F,0x78,0x11,0x5B,0x08,0xD6,0xDA,0xCD,0xCB,0xB7,0x34,0xF6,0xFE,0x14,0x52,0x09,0x94,0x3C,0x86,0x67,0xE1,0x7C,0xEB,0x70,0x63,0x35,0x46,0xE9,0x62,0x3F,0x30,0xC9,0x5F,0xA5,0xF1,0x3D,0x07,0xC2,0xFC,0xCF,0x79,0x37,0xA7,0x19,0xC0,0xB8,0x80,0x82,0xD4,0x92,0xA3,0x9C,0x61,0x0D,0x74,0xB2,0xC5,0x81,0x83,0x6C,0x17,0x76,0x9F,0xC3,0x58,0xA9,0x7F,0xE6,0xF4,0xDC,0x9B,0x2E,0x89,0x59,0xDF,0x2B,0xA4,0x64,0x4C,0x38,0xC7,0x1D,0x96,0xB9,0x04,0x92,0x50,0x71,0x57,0xB0,0xCB,0x34,0x82,0x35,0xFB,0xD9,0x99,0x97,0xF9,0xAC,0x18,0x91,0x8A,0x28,0xFA,0x62,0xE3,0x43,0x0C,0x7C,0x89,0x0D,0xBF,0xBB,0x5E,0xD5,0xF5,0x1B,0x3A,0xC1,0x1C,0x2E,0xDF,0x5D,0xCD,0xD0,0x93,0x98,0xED,0x68,0xDE,0x81,0x5C,0xE1,0x1F,0xA5,0xCA,0xB3,0xA0,0xF8,0x7A,0xAE,0x01,0x0B,0x0A,0xEE,0xF3,0xCE,0x83,0xDD,0x25,0x42,0x29,0x11,0xDA,0x69,0x9A,0x52,0x6E,0xF1,0x54,0xEC,0xE5,0xF0,0xDB,0xFD,0x67,0x58,0x23,0x16,0xE4,0x8B,0x9C,0xE7,0x05,0x60,0x4D,0xC3,0x1D,0xFF,0x55,0x1A,0x45,0x27,0x12,0xA3,0x3E,0xF4,0xB4,0xC2,0x65,0x4F,0xB2,0x21,0x3B,0x48,0x37,0x5A,0x2D,0xD8,0x61,0xAA,0x9D,0xD6,0x06,0xA2,0xFE,0x5F,0x40,0x10,0xF7,0xC7,0x94,0x3F,0x2A,0xF2,0xA8,0x4B,0x90,0x0E,0x20,0x6D,0xB1,0x47,0x3C,0xAB,0xBA,0x6B,0x4A,0xAF,0x86,0xBC,0x00,0x03,0x8E,0x76,0x74,0xA6,0x38,0x53,0xB6,0x0F,0xBE,0x63,0xC8,0x9F,0xEB,0xA1,0xC5,0xE0,0x78,0x49,0xEA,0x95,0x7E,0x1E,0xE6,0x3D,0xB5,0x96,0x8C,0x24,0xD7,0x36,0xA4,0x4E,0xB9,0xE2,0x07,0x7B,0x46,0x84,0x66,0xB8,0x7D,0x6A,0x15,0xEF,0x8D,0x41,0x8F,0xD2,0x79,0x80,0x85,0xD3,0x59,0xF6,0xCC,0x51,0xC0,0x5B,0x22,0x64,0x2C,0x13,0x08,0x70,0x32,0x30,0x87,0xC9,0xA9,0x17,0x72,0xB7,0x7F,0x4C,0x19,0xE8,0x56,0xCF,0xC6,0xA7,0x73,0x2F,0x31,0x75,0xDC,0x33,0xBD,0xD1,0x02,0xC4,0xAD,0x77,0x09,0x26,0xD4,0x14,0x88,0xFC,0xE9,0x39,0x9B,0x6F,0x6C,0x44,0x9E,0x2B,0x99,0x43,0x3D,0x12,0xE0,0x20,0xBC,0xC8,0xDD,0x0D,0xAF,0x5B,0x58,0x70,0xAA,0x1F,0x2D,0xDC,0x62,0xFB,0xF2,0x93,0x47,0x1B,0x05,0x41,0xE8,0x07,0x89,0xE5,0x36,0xF0,0x16,0x50,0x18,0x27,0x3C,0x44,0x06,0x04,0xB3,0xFD,0x9D,0x23,0x46,0x83,0x4B,0x78,0x21,0xDB,0xB9,0x75,0xBB,0xE6,0x4D,0xB4,0xB1,0xE7,0x6D,0xC2,0xF8,0x65,0xF4,0x6F,0xB8,0x10,0xE3,0x02,0x90,0x7A,0x8D,0xD6,0x33,0x4F,0x72,0xB0,0x52,0x8C,0x49,0x5E,0xFC,0xAB,0xDF,0x95,0xF1,0xD4,0x4C,0x7D,0xDE,0xA1,0x4A,0x2A,0xD2,0x09,0x81,0xA2,0x7E,0x9B,0xB2,0x88,0x34,0x37,0xBA,0x42,0x40,0x92,0x0C,0x67,0x82,0x3B,0x8A,0x57,0xA0,0x0B,0x1E,0xC6,0x9C,0x7F,0xA4,0x3A,0x14,0x59,0x85,0x73,0x08,0x9F,0x8E,0x5F,0x03,0x6E,0x19,0xEC,0x55,0x9E,0xA9,0xE2,0x32,0x96,0xCA,0x6B,0x74,0x24,0xC3,0xF3,0x61,0x2E,0x71,0x13,0x26,0x97,0x0A,0xC0,0x80,0xF6,0x51,0x7B,0x86,0x15,0x0F,0x7C,0xEF,0xC9,0x53,0x6C,0x17,0x22,0xD0,0xBF,0xA8,0xD3,0x31,0x54,0x79,0xF7,0x29,0xCB,0xB7,0xE9,0x11,0x76,0x1D,0x25,0xEE,0x5D,0xAE,0x66,0x5A,0xC5,0x60,0xD8,0xD1,0xC4,0x68,0xD5,0x2B,0x91,0xFE,0x87,0x94,0xCC,0x4E,0x9A,0x35,0x3F,0x3E,0xDA,0xC7,0xFA,0xC1,0x2F,0x0E,0xF5,0x28,0x1A,0xEB,0x69,0xF9,0xE4,0xA7,0xAC,0xD9,0x5C,0xEA,0xB5,0x2C,0xA5,0xBE,0x1C,0xCE,0x56,0xD7,0x77,0x38,0x48,0xBD,0x39,0x8B,0x8F,0x6A,0xE1,0x30,0xA6,0x64,0x45,0x63,0x84,0xFF,0x00,0xB6,0x01,0xCF,0xED,0xAD,0xA3,0xCD,0x98,0xEE,0xB0,0x71,0x16,0x22,0x1A,0x5A,0xE9,0x61,0xA9,0xC2,0x5D,0xDF,0x67,0xC3,0xD6,0xCE,0xE8,0x6B,0x54,0x25,0x10,0xB8,0xD7,0xD4,0xAF,0x53,0x36,0xF0,0x7E,0xCC,0x2E,0x29,0x66,0x14,0x76,0x90,0x21,0xC7,0x0D,0xF1,0x87,0x7C,0x56,0x12,0x81,0x7B,0x08,0x69,0x04,0xEB,0x1E,0x99,0x52,0xE5,0xAE,0x91,0x35,0x6C,0xCD,0x23,0x73,0xF4,0xC4,0xA1,0x37,0x42,0x63,0x83,0x64,0x07,0xF8,0x06,0xB1,0xEA,0xC8,0xA4,0xAA,0x9F,0xCA,0xA2,0x2B,0x1B,0xB9,0x51,0xC9,0x70,0xD0,0x4F,0x3F,0x3E,0xBA,0x88,0x8C,0xE6,0x6D,0x28,0xC6,0xF2,0x09,0x1D,0x2F,0x6E,0xEC,0xE3,0xFE,0xAB,0xA0,0x5B,0xDE,0xB2,0xED,0xD2,0x6F,0x96,0x2C,0x80,0xF9,0xCB,0x93,0x9D,0x49,0x38,0x32,0xDD,0x39,0xFD,0xC0,0xDC,0x26,0x72,0xBE,0xE1,0xBC,0xB3,0x4A,0xE0,0xB6,0xC5,0x6A,0x62,0xFF,0x68,0xF3,0x57,0x11,0x20,0x1F,0x43,0x3B,0x03,0x01,0xFA,0xB4,0x24,0x9A,0x84,0x41,0x7F,0x4C,0xDB,0x2A,0xFC,0x65,0x94,0xF5,0x1C,0x40,0x46,0x02,0x00,0xEF,0xE2,0x8E,0xF7,0x31,0x44,0x9E,0x15,0x3A,0x27,0xE7,0xCF,0xBB,0x0A,0xDA,0x5C,0xA8,0x77,0x5F,0x18,0xAD,0x0C,0xA7,0xC1,0x19,0x78,0x9B,0x3D,0xA3,0x5E,0x13,0x74,0x82,0x98,0x0F,0x58,0x89,0x9C,0x79,0x8F,0xB5,0x30,0x33,0x45,0xBD,0x95,0x47,0x60,0x0B,0x3C,0x85,0x50,0x8D,0xAC,0xFB,0x92,0xD8,0xD3,0xF6,0x7A,0x4B,0xA6,0xD9,0x2D,0x4D,0x0E,0xD5,0xA5,0x86,0x17,0xBF,0x05,0xE4,0x7D,0x97,0xD1,0x8A,0x48,0x34,0xB7,0x75,0x8B,0x55,0x59,0x4E,0x75,0x50,0xC8,0xF9,0x78,0x2F,0x5B,0x11,0x56,0x8D,0x05,0x26,0x5A,0x25,0xCE,0xAE,0x14,0xFE,0x09,0x52,0x3C,0x94,0x67,0x86,0xD6,0x08,0xCD,0xDA,0xB7,0xCB,0xF6,0x34,0x18,0xFB,0x20,0xBE,0x24,0x8F,0x9A,0x42,0x8C,0x1B,0x0A,0xDB,0x90,0xDD,0x01,0xF7,0xB0,0xB3,0x3E,0xC6,0xFA,0x1F,0x36,0x0C,0x06,0xBF,0x0E,0xD3,0xC4,0x16,0x88,0xE3,0x76,0x17,0xC3,0x9F,0xA9,0x58,0xE6,0x7F,0x0D,0x61,0xB2,0x74,0x81,0xC5,0x6C,0x83,0x64,0xA4,0x38,0x4C,0x1D,0xC7,0xB9,0x96,0xDC,0xF4,0x2E,0x9B,0x59,0x89,0x2B,0xDF,0x3F,0x62,0xC9,0x30,0xA5,0x5F,0x3D,0xF1,0x7C,0xE1,0x70,0xEB,0x35,0x63,0xE9,0x46,0xB8,0xC0,0x82,0x80,0x92,0xD4,0x9C,0xA3,0xC2,0x07,0xCF,0xFC,0x37,0x79,0x19,0xA7,0xAC,0x9E,0x6F,0xED,0x45,0xAB,0x8A,0x71,0x5D,0xD8,0x6E,0x31,0x7D,0x60,0x23,0x28,0x7A,0x03,0x10,0x48,0xEC,0x51,0xAF,0x15,0xBA,0x5E,0x43,0x7E,0xCA,0x1E,0xB1,0xBB,0xE7,0x00,0x7B,0x84,0xB4,0x22,0xE0,0xC1,0x29,0x27,0x49,0x1C,0x32,0x85,0x4B,0x69,0x4A,0xD2,0x53,0xF3,0xA8,0x21,0x3A,0x98,0x0F,0x0B,0xEE,0x65,0xBC,0xCC,0x39,0xBD,0xA2,0x13,0x8E,0x44,0xE5,0xAA,0xF5,0x97,0x02,0x91,0x8B,0xF8,0x04,0x72,0xD5,0xFF,0xD1,0x1A,0x2D,0x66,0x87,0xEA,0x9D,0x68,0xF0,0xA0,0x47,0x77,0xB6,0x12,0x4E,0xEF,0x99,0xA1,0x6A,0xD9,0x33,0x6D,0x95,0xF2,0xE4,0x5C,0x55,0x40,0x2A,0xE2,0xDE,0x41,0x93,0xA6,0x54,0x3B,0x6B,0x4D,0xD7,0xE8,0xFD,0x73,0xAD,0x4F,0x2C,0x57,0xB5,0xD0,0x50,0x71,0x04,0x92,0xCB,0x34,0x57,0xB0,0xFB,0xD9,0x82,0x35,0xF9,0xAC,0x99,0x97,0x8A,0x28,0x18,0x91,0xE3,0x43,0xFA,0x62,0x89,0x0D,0x0C,0x7C,0x5E,0xD5,0xBF,0xBB,0x3A,0xC1,0xF5,0x1B,0xDF,0x5D,0x1C,0x2E,0x93,0x98,0xCD,0xD0,0xDE,0x81,0xED,0x68,0x1F,0xA5,0x5C,0xE1,0xA0,0xF8,0xCA,0xB3,0x01,0x0B,0x7A,0xAE,0xF3,0xCE,0x0A,0xEE,0x25,0x42,0x83,0xDD,0xDA,0x69,0x29,0x11,0x6E,0xF1,0x9A,0x52,0xE5,0xF0,0x54,0xEC,0x67,0x58,0xDB,0xFD,0xE4,0x8B,0x23,0x16,0x05,0x60,0x9C,0xE7,0x1D,0xFF,0x4D,0xC3,0x45,0x27,0x55,0x1A,0x3E,0xF4,0x12,0xA3,0x65,0x4F,0xB4,0xC2,0x3B,0x48,0xB2,0x21,0x2D,0xD8,0x37,0x5A,0x9D,0xD6,0x61,0xAA,0xFE,0x5F,0x06,0xA2,0xF7,0xC7,0x40,0x10,0x2A,0xF2,0x94,0x3F,0x90,0x0E,0xA8,0x4B,0xB1,0x47,0x20,0x6D,0xBA,0x6B,0x3C,0xAB,0x86,0xBC,0x4A,0xAF,0x8E,0x76,0x00,0x03,0x38,0x53,0x74,0xA6,0xBE,0x63,0xB6,0x0F,0xEB,0xA1,0xC8,0x9F,0x78,0x49,0xC5,0xE0,0x7E,0x1E,0xEA,0x95,0xB5,0x96,0xE6,0x3D,0xD7,0x36,0x8C,0x24,0xB9,0xE2,0xA4,0x4E,0x46,0x84,0x07,0x7B,0x7D,0x6A,0x66,0xB8,0x8D,0x41,0x15,0xEF,0x79,0x80,0x8F,0xD2,0x59,0xF6,0x85,0xD3,0xC0,0x5B,0xCC,0x51,0x2C,0x13,0x22,0x64,0x32,0x30,0x08,0x70,0xA9,0x17,0x87,0xC9,0x7F,0x4C,0x72,0xB7,0x56,0xCF,0x19,0xE8,0x73,0x2F,0xC6,0xA7,0xDC,0x33,0x31,0x75,0x02,0xC4,0xBD,0xD1,0x09,0x26,0xAD,0x77,0x88,0xFC,0xD4,0x14,0x9B,0x6F,0xE9,0x39,0x9E,0x2B,0x6C,0x44,0x79,0xF7,0x29,0xCB,0xA8,0xD3,0x31,0x54,0x17,0x22,0xD0,0xBF,0xEF,0xC9,0x53,0x6C,0x60,0xD8,0xD1,0xC4,0xAE,0x66,0x5A,0xC5,0x1D,0x25,0xEE,0x5D,0xB7,0xE9,0x11,0x76,0x74,0x24,0xC3,0xF3,0x32,0x96,0xCA,0x6B,0x55,0x9E,0xA9,0xE2,0x03,0x6E,0x19,0xEC,0x86,0x15,0x0F,0x7C,0x80,0xF6,0x51,0x7B,0x26,0x97,0x0A,0xC0,0x61,0x2E,0x71,0x13,0x8B,0x8F,0x6A,0xE1,0x38,0x48,0xBD,0x39,0xCE,0x56,0xD7,0x77,0x2C,0xA5,0xBE,0x1C,0xAD,0xA3,0xCD,0x98,0xB6,0x01,0xCF,0xED,0x63,0x84,0xFF,0x00,0x30,0xA6,0x64,0x45,0x3E,0xDA,0xC7,0xFA,0x4E,0x9A,0x35,0x3F,0xFE,0x87,0x94,0xCC,0x68,0xD5,0x2B,0x91,0xD9,0x5C,0xEA,0xB5,0xF9,0xE4,0xA7,0xAC,0x28,0x1A,0xEB,0x69,0xC1,0x2F,0x0E,0xF5,0x46,0x83,0x4B,0x78,0xB3,0xFD,0x9D,0x23,0x3C,0x44,0x06,0x04,0x16,0x50,0x18,0x27,0xF8,0x65,0xF4,0x6F,0xB1,0xE7,0x6D,0xC2,0xBB,0xE6,0x4D,0xB4,0x21,0xDB,0xB9,0x75,0x58,0x70,0xAA,0x1F,0xDD,0x0D,0xAF,0x5B,0xE0,0x20,0xBC,0xC8,0x99,0x43,0x3D,0x12,0x89,0xE5,0x36,0xF0,0x05,0x41,0xE8,0x07,0xF2,0x93,0x47,0x1B,0x2D,0xDC,0x62,0xFB,0x82,0x3B,0x8A,0x57,0x40,0x92,0x0C,0x67,0x34,0x37,0xBA,0x42,0x7E,0x9B,0xB2,0x88,0x08,0x9F,0x8E,0x5F,0x14,0x59,0x85,0x73,0x9C,0x7F,0xA4,0x3A,0xA0,0x0B,0x1E,0xC6,0x52,0x8C,0x49,0x5E,0x33,0x4F,0x72,0xB0,0x90,0x7A,0x8D,0xD6,0xB8,0x10,0xE3,0x02,0xD2,0x09,0x81,0xA2,0xDE,0xA1,0x4A,0x2A,0xF1,0xD4,0x4C,0x7D,0xFC,0xAB,0xDF,0x95,0xAD,0x18,0x5F,0x77,0xA8,0x5C,0xDA,0x0A,0xBB,0xCF,0xE7,0x27,0x3A,0x15,0x9E,0x44,0x31,0xF7,0x8E,0xE2,0xEF,0x00,0x02,0x46,0x40,0x1C,0xF5,0x94,0x65,0xFC,0x2A,0xDB,0x4C,0x7F,0x41,0x84,0x9A,0x24,0xB4,0xFA,0x01,0x03,0x3B,0x43,0x1F,0x20,0x11,0x57,0xF3,0x68,0xFF,0x62,0x6A,0xC5,0xB6,0xE0,0x4A,0xB3,0xBC,0xE1,0xBE,0x72,0x26,0xDC,0x4E,0x59,0x55,0x8B,0x75,0xB7,0x34,0x48,0x8A,0xD1,0x97,0x7D,0xE4,0x05,0xBF,0x17,0x86,0xA5,0xD5,0x0E,0x4D,0x2D,0xD9,0xA6,0x4B,0x7A,0xF6,0xD3,0xD8,0x92,0xFB,0xAC,0x8D,0x50,0x85,0x3C,0x0B,0x60,0x47,0x95,0xBD,0x45,0x33,0x30,0xB5,0x8F,0x79,0x9C,0x89,0x58,0x0F,0x98,0x82,0x74,0x13,0x5E,0xA3,0x3D,0x9B,0x78,0x19,0xC1,0xA7,0x0C,0xC4,0xF4,0x73,0x23,0xCD,0x6C,0x35,0x91,0xAE,0xE5,0x52,0x99,0x1E,0xEB,0x04,0x69,0x08,0x7B,0x81,0x12,0x56,0x7C,0x87,0xF1,0x0D,0xC7,0x21,0x90,0x76,0x14,0x66,0x29,0x2E,0xCC,0x7E,0xF0,0x36,0x53,0xAF,0xD4,0xD7,0xB8,0x10,0x25,0x54,0x6B,0xE8,0xCE,0xD6,0xC3,0x67,0xDF,0x5D,0xC2,0xA9,0x61,0xE9,0x5A,0x1A,0x22,0x16,0x71,0xB0,0xEE,0xC0,0xFD,0x39,0xDD,0x32,0x38,0x49,0x9D,0x93,0xCB,0xF9,0x80,0x2C,0x96,0x6F,0xD2,0xED,0xB2,0xDE,0x5B,0xA0,0xAB,0xFE,0xE3,0xEC,0x6E,0x2F,0x1D,0x09,0xF2,0xC6,0x28,0x6D,0xE6,0x8C,0x88,0xBA,0x3E,0x3F,0x4F,0xD0,0x70,0xC9,0x51,0xB9,0x1B,0x2B,0xA2,0xCA,0x9F,0xAA,0xA4,0xC8,0xEA,0xB1,0x06,0xF8,0x07,0x64,0x83,0x63,0x42,0x37,0xA1,0x9B,0x2E,0xF4,0xDC,0xDF,0x2B,0x89,0x59,0x4C,0x38,0xA4,0x64,0x96,0xB9,0xC7,0x1D,0x74,0xB2,0x61,0x0D,0x83,0x6C,0xC5,0x81,0x9F,0xC3,0x17,0x76,0x7F,0xE6,0x58,0xA9,0xFC,0xCF,0x07,0xC2,0xA7,0x19,0x79,0x37,0x80,0x82,0xC0,0xB8,0xA3,0x9C,0xD4,0x92,0xEB,0x70,0xE1,0x7C,0x46,0xE9,0x63,0x35,0x30,0xC9,0x62,0x3F,0xF1,0x3D,0x5F,0xA5,0xDA,0xCD,0x08,0xD6,0x34,0xF6,0xCB,0xB7,0x52,0x09,0xFE,0x14,0x86,0x67,0x94,0x3C,0x26,0x05,0x8D,0x56,0xAE,0xCE,0x25,0x5A,0xF9,0xC8,0x50,0x75,0x11,0x5B,0x2F,0x78,0xD3,0x0E,0xBF,0x06,0xE3,0x88,0x16,0xC4,0xC6,0x3E,0xB3,0xB0,0x0C,0x36,0x1F,0xFA,0xDB,0x0A,0x1B,0x8C,0xF7,0x01,0xDD,0x90,0xBE,0x20,0xFB,0x18,0x42,0x9A,0x8F,0x24,0x77,0x47,0xA0,0xF0,0xEF,0x4E,0x12,0xB6,0x66,0x2D,0x1A,0xD1,0x68,0x9D,0xEA,0x87,0xF8,0x8B,0x91,0x02,0xFF,0xD5,0x72,0x04,0x44,0x8E,0x13,0xA2,0x97,0xF5,0xAA,0xE5,0x4F,0xAD,0x73,0xFD,0xD0,0xB5,0x57,0x2C,0x3B,0x54,0xA6,0x93,0xE8,0xD7,0x4D,0x6B,0x40,0x55,0x5C,0xE4,0x41,0xDE,0xE2,0x2A,0xD9,0x6A,0xA1,0x99,0xF2,0x95,0x6D,0x33,0x7E,0x43,0x5E,0xBA,0xBB,0xB1,0x1E,0xCA,0x48,0x10,0x03,0x7A,0x15,0xAF,0x51,0xEC,0x31,0x6E,0xD8,0x5D,0x28,0x23,0x60,0x7D,0xED,0x6F,0x9E,0xAC,0x71,0x8A,0xAB,0x45,0x65,0xEE,0x0B,0x0F,0xBD,0x39,0xCC,0xBC,0xF3,0x53,0xD2,0x4A,0x98,0x3A,0x21,0xA8,0x1C,0x49,0x27,0x29,0x69,0x4B,0x85,0x32,0x84,0x7B,0x00,0xE7,0xC1,0xE0,0x22,0xB4,0xD9,0xFB,0x35,0x82,0xAC,0xF9,0x97,0x99,0x71,0x50,0x92,0x04,0x34,0xCB,0xB0,0x57,0x0D,0x89,0x7C,0x0C,0xD5,0x5E,0xBB,0xBF,0x28,0x8A,0x91,0x18,0x43,0xE3,0x62,0xFA,0x98,0x93,0xD0,0xCD,0x81,0xDE,0x68,0xED,0xC1,0x3A,0x1B,0xF5,0x5D,0xDF,0x2E,0x1C,0x0B,0x01,0xAE,0x7A,0xCE,0xF3,0xEE,0x0A,0xA5,0x1F,0xE1,0x5C,0xF8,0xA0,0xB3,0xCA,0xF1,0x6E,0x52,0x9A,0xF0,0xE5,0xEC,0x54,0x42,0x25,0xDD,0x83,0x69,0xDA,0x11,0x29,0x60,0x05,0xE7,0x9C,0xFF,0x1D,0xC3,0x4D,0x58,0x67,0xFD,0xDB,0x8B,0xE4,0x16,0x23,0x4F,0x65,0xC2,0xB4,0x48,0x3B,0x21,0xB2,0x27,0x45,0x1A,0x55,0xF4,0x3E,0xA3,0x12,0x5F,0xFE,0xA2,0x06,0xC7,0xF7,0x10,0x40,0xD8,0x2D,0x5A,0x37,0xD6,0x9D,0xAA,0x61,0x47,0xB1,0x6D,0x20,0x6B,0xBA,0xAB,0x3C,0xF2,0x2A,0x3F,0x94,0x0E,0x90,0x4B,0xA8,0x53,0x38,0xA6,0x74,0x63,0xBE,0x0F,0xB6,0xBC,0x86,0xAF,0x4A,0x76,0x8E,0x03,0x00,0x1E,0x7E,0x95,0xEA,0x96,0xB5,0x3D,0xE6,0xA1,0xEB,0x9F,0xC8,0x49,0x78,0xE0,0xC5,0x84,0x46,0x7B,0x07,0x6A,0x7D,0xB8,0x66,0x36,0xD7,0x24,0x8C,0xE2,0xB9,0x4E,0xA4,0xF6,0x59,0xD3,0x85,0x5B,0xC0,0x51,0xCC,0x41,0x8D,0xEF,0x15,0x80,0x79,0xD2,0x8F,0x17,0xA9,0xC9,0x87,0x4C,0x7F,0xB7,0x72,0x13,0x2C,0x64,0x22,0x30,0x32,0x70,0x08,0x33,0xDC,0x75,0x31,0xC4,0x02,0xD1,0xBD,0xCF,0x56,0xE8,0x19,0x2F,0x73,0xA7,0xC6,0x6F,0x9B,0x39,0xE9,0x2B,0x9E,0x44,0x6C,0x26,0x09,0x77,0xAD,0xFC,0x88,0x14,0xD4,0x31,0x54,0xA8,0xD3,0x29,0xCB,0x79,0xF7,0x53,0x6C,0xEF,0xC9,0xD0,0xBF,0x17,0x22,0x5A,0xC5,0xAE,0x66,0xD1,0xC4,0x60,0xD8,0x11,0x76,0xB7,0xE9,0xEE,0x5D,0x1D,0x25,0xCA,0x6B,0x32,0x96,0xC3,0xF3,0x74,0x24,0x19,0xEC,0x03,0x6E,0xA9,0xE2,0x55,0x9E,0x51,0x7B,0x80,0xF6,0x0F,0x7C,0x86,0x15,0x71,0x13,0x61,0x2E,0x0A,0xC0,0x26,0x97,0xBD,0x39,0x38,0x48,0x6A,0xE1,0x8B,0x8F,0xBE,0x1C,0x2C,0xA5,0xD7,0x77,0xCE,0x56,0xCF,0xED,0xB6,0x01,0xCD,0x98,0xAD,0xA3,0x64,0x45,0x30,0xA6,0xFF,0x00,0x63,0x84,0x35,0x3F,0x4E,0x9A,0xC7,0xFA,0x3E,0xDA,0x2B,0x91,0x68,0xD5,0x94,0xCC,0xFE,0x87,0xA7,0xAC,0xF9,0xE4,0xEA,0xB5,0xD9,0x5C,0x0E,0xF5,0xC1,0x2F,0xEB,0x69,0x28,0x1A,0x9D,0x23,0xB3,0xFD,0x4B,0x78,0x46,0x83,0x18,0x27,0x16,0x50,0x06,0x04,0x3C,0x44,0x6D,0xC2,0xB1,0xE7,0xF4,0x6F,0xF8,0x65,0xB9,0x75,0x21,0xDB,0x4D,0xB4,0xBB,0xE6,0xAF,0x5B,0xDD,0x0D,0xAA,0x1F,0x58,0x70,0x3D,0x12,0x99,0x43,0xBC,0xC8,0xE0,0x20,0xE8,0x07,0x05,0x41,0x36,0xF0,0x89,0xE5,0x62,0xFB,0x2D,0xDC,0x47,0x1B,0xF2,0x93,0x0C,0x67,0x40,0x92,0x8A,0x57,0x82,0x3B,0xB2,0x88,0x7E,0x9B,0xBA,0x42,0x34,0x37,0x85,0x73,0x14,0x59,0x8E,0x5F,0x08,0x9F,0x1E,0xC6,0xA0,0x0B,0xA4,0x3A,0x9C,0x7F,0x72,0xB0,0x33,0x4F,0x49,0x5E,0x52,0x8C,0xE3,0x02,0xB8,0x10,0x8D,0xD6,0x90,0x7A,0x4A,0x2A,0xDE,0xA1,0x81,0xA2,0xD2,0x09,0xDF,0x95,0xFC,0xAB,0x4C,0x7D,0xF1,0xD4,0x1F,0x20,0x11,0x57,0x01,0x03,0x3B,0x43,0x9A,0x24,0xB4,0xFA,0x4C,0x7F,0x41,0x84,0xBE,0x72,0x26,0xDC,0x4A,0xB3,0xBC,0xE1,0x6A,0xC5,0xB6,0xE0,0xF3,0x68,0xFF,0x62,0x3A,0x15,0x9E,0x44,0xBB,0xCF,0xE7,0x27,0xA8,0x5C,0xDA,0x0A,0xAD,0x18,0x5F,0x77,0x65,0xFC,0x2A,0xDB,0x40,0x1C,0xF5,0x94,0xEF,0x00,0x02,0x46,0x31,0xF7,0x8E,0xE2,0xB5,0x8F,0x79,0x9C,0xBD,0x45,0x33,0x30,0x0B,0x60,0x47,0x95,0x8D,0x50,0x85,0x3C,0x19,0xC1,0xA7,0x0C,0xA3,0x3D,0x9B,0x78,0x82,0x74,0x13,0x5E,0x89,0x58,0x0F,0x98,0xE4,0x05,0xBF,0x17,0x8A,0xD1,0x97,0x7D,0x75,0xB7,0x34,0x48,0x4E,0x59,0x55,0x8B,0xD8,0x92,0xFB,0xAC,0x4B,0x7A,0xF6,0xD3,0x4D,0x2D,0xD9,0xA6,0x86,0xA5,0xD5,0x0E,0x54,0x6B,0xE8,0xCE,0xD7,0xB8,0x10,0x25,0x36,0x53,0xAF,0xD4,0x2E,0xCC,0x7E,0xF0,0x16,0x71,0xB0,0xEE,0xE9,0x5A,0x1A,0x22,0x5D,0xC2,0xA9,0x61,0xD6,0xC3,0x67,0xDF,0x1E,0xEB,0x04,0x69,0xAE,0xE5,0x52,0x99,0xCD,0x6C,0x35,0x91,0xC4,0xF4,0x73,0x23,0x76,0x14,0x66,0x29,0x0D,0xC7,0x21,0x90,0x56,0x7C,0x87,0xF1,0x08,0x7B,0x81,0x12,0xB9,0x1B,0x2B,0xA2,0xD0,0x70,0xC9,0x51,0xBA,0x3E,0x3F,0x4F,0x6D,0xE6,0x8C,0x88,0x63,0x42,0x37,0xA1,0xF8,0x07,0x64,0x83,0xC8,0xEA,0xB1,0x06,0xCA,0x9F,0xAA,0xA4,0x2C,0x96,0x6F,0xD2,0x93,0xCB,0xF9,0x80,0x32,0x38,0x49,0x9D,0xC0,0xFD,0x39,0xDD,0x09,0xF2,0xC6,0x28,0xEC,0x6E,0x2F,0x1D,0xA0,0xAB,0xFE,0xE3,0xED,0xB2,0xDE,0x5B,0x29,0x88,0x76,0x4A,0x1F,0xAC,0xFC,0x9C,0x45,0xC0,0x5D,0xD9,0x73,0xE4,0xD7,0x0F,0xF1,0x18,0x20,0x53,0xC7,0x3C,0xAA,0x85,0x9D,0x50,0x0B,0xC0,0xAB,0x74,0x81,0x16,0xA6,0x97,0xDA,0x78,0x90,0xB3,0x50,0xAE,0xCA,0xDF,0xF1,0xEB,0xFC,0xFB,0x7B,0x3D,0x7E,0x07,0x8C,0x61,0x48,0x23,0x06,0xB7,0x12,0x4F,0xA7,0xF2,0x24,0x6B,0x2D,0x24,0x08,0xB6,0x11,0x2E,0x3E,0x92,0x9B,0xF8,0x64,0xFE,0x3A,0xBD,0x52,0xDA,0xB0,0x6B,0xD0,0x26,0x47,0x37,0xE6,0x02,0xCD,0xE1,0xBC,0x6E,0x6C,0xA4,0x8A,0x4A,0xE6,0x72,0x87,0xA9,0xBD,0x1C,0xB1,0x8D,0x37,0xCA,0xEB,0xE1,0x96,0x8F,0xDD,0xC5,0x1C,0x59,0x5F,0x39,0xEB,0x05,0x69,0x1D,0x61,0xD3,0x33,0x71,0xC0,0x96,0x05,0x55,0x4A,0x40,0x6B,0xF4,0xB8,0x82,0x5D,0xD0,0x32,0x54,0x07,0xBC,0x93,0x11,0x31,0x98,0x19,0xC7,0xB3,0x64,0xEE,0x9B,0x85,0x40,0x64,0x4D,0xDF,0x2C,0xC5,0x08,0xE9,0x08,0x4F,0xDE,0xE4,0xEB,0x14,0xB0,0xD2,0xCF,0x9E,0x66,0x88,0xA3,0x3F,0x23,0xBE,0x87,0xB5,0xF5,0x3C,0x7B,0x42,0xA9,0x0A,0x5F,0xC8,0x7F,0x50,0x33,0x69,0x3A,0x66,0x17,0xE3,0xEC,0x4A,0xCA,0xDF,0xE6,0x7C,0xEE,0x55,0x30,0x26,0x82,0xF4,0x75,0x10,0xA6,0x7E,0xA3,0x92,0x5A,0x89,0xFF,0xA4,0x7E,0x03,0x29,0xFE,0x12,0xA2,0x6C,0xC8,0x36,0x28,0xBA,0xC5,0xD5,0x73,0xD4,0xF3,0xF1,0xF9,0x02,0xA9,0x9D,0x58,0x47,0x9F,0xB9,0xD2,0x91,0x1D,0x45,0x25,0xCD,0x2B,0x61,0xAF,0x1B,0x71,0x0D,0x0E,0x5E,0x47,0x29,0x84,0x88,0xAD,0x70,0xD5,0xE5,0x9B,0x54,0x5F,0x33,0xC1,0x38,0xFE,0x76,0xF7,0x1C,0x74,0xA0,0x75,0xE0,0x83,0xFC,0x43,0xC4,0x09,0x2A,0x19,0xA8,0xA8,0x6F,0x2F,0x8C,0x22,0xB9,0x22,0x6F,0x79,0xD7,0x14,0x4B,0xF3,0x01,0x4E,0x27,0x52,0x44,0x78,0x03,0xD8,0x92,0xFA,0xFF,0x2F,0xCE,0xCC,0xDB,0xA5,0x18,0x96,0xB7,0x04,0x5D,0xA0,0x93,0x8E,0x8B,0x8C,0x4E,0xB2,0x81,0xBA,0x6A,0x38,0x57,0xE0,0x06,0x99,0x12,0xD6,0x22,0x13,0xC4,0x54,0xDE,0xE4,0x98,0x62,0xFA,0x6E,0x4E,0x38,0x96,0xCF,0x0B,0x0E,0xB2,0x45,0xDD,0x03,0x51,0x1E,0xB3,0x35,0x75,0x94,0x65,0x6F,0x19,0x35,0x20,0x59,0x3D,0xBF,0xF6,0xDB,0xC1,0x48,0xAA,0xED,0xE5,0xC2,0x7C,0xB7,0x89,0x63,0x39,0x81,0xAD,0xE9,0xEF,0xEF,0x0C,0x1B,0x2D,0xD9,0x28,0x91,0xFB,0x83,0x44,0x30,0xBE,0xB5,0x60,0xBA,0x68,0x37,0x9C,0x4D,0x34,0x01,0xB8,0xC7,0xE2,0x5B,0xD4,0x66,0xA7,0x6D,0xF0,0xEC,0x71,0x60,0x13,0xB7,0x1F,0x56,0x37,0x3D,0xC9,0x0C,0x5B,0x9C,0x8C,0x3A,0x7F,0x16,0x5A,0xB8,0x83,0xE1,0x06,0x8E,0xA7,0x6B,0xD0,0xD4,0xCB,0xCA,0x95,0xE2,0xEF,0x40,0x43,0xCE,0x32,0x7C,0x49,0xF8,0x16,0xF6,0x9F,0xA2,0x7A,0x57,0xDA,0x94,0x5E,0xDD,0x0C,0x16,0xA2,0x2A,0x50,0x20,0x86,0xA0,0x86,0x7A,0xEA,0x01,0xC3,0x4C,0xCE,0x8B,0x15,0x41,0x2D,0xD0,0x7B,0x77,0x09,0x5A,0xAD,0x2D,0x65,0xFB,0xE8,0x1B,0x41,0x71,0x3E,0x99,0xBD,0x86,0x62,0xAF,0x99,0x0C,0xB4,0xF5,0xF5,0xAD,0xF1,0xC3,0xD1,0x27,0x27,0x1E,0x47,0x0F,0x2B,0x28,0x63,0x85,0xFD,0x72,0x0F,0x24,0xB8,0x44,0x2B,0xAE,0x6E,0xC6,0xD7,0x59,0x32,0xF0,0xF3,0xD3,0xE4,0xAA,0x9F,0x72,0xA1,0x9C,0xBB,0xF8,0x77,0x91,0x58,0xA3,0x19,0xA7,0x7C,0x29,0xCF,0xFD,0x10,0x88,0x8A,0xCB,0x34,0x02,0x5C,0x49,0xC8,0xF5,0x00,0x7F,0xEC,0x7F,0xD6,0x25,0x80,0xDE,0x93,0x13,0xA4,0x54,0x45,0x3F,0x79,0x68,0x4F,0x09,0x5D,0xE2,0x99,0x53,0x31,0x43,0xDC,0x65,0x15,0xC9,0x0A,0xE7,0xE9,0x3E,0x56,0xD1,0xCD,0xB4,0x80,0x8B,0xA1,0x15,0xC5,0xBD,0x85,0x9F,0x13,0xB0,0x66,0xC4,0x7D,0x86,0x42,0x4E,0xAB,0xDC,0x2E,0xEF,0xEE,0xEA,0x0A,0x65,0x38,0x68,0xF6,0x92,0x64,0x5E,0xD2,0x18,0xB2,0x04,0xBE,0xB9,0xF7,0x32,0x9A,0x33,0x21,0x5C,0x3B,0xC1,0xE3,0x6A,0x1F,0x4B,0x35,0x30,0x73,0xEA,0x70,0x06,0x57,0x60,0xA6,0x84,0xAB,0x97,0xFA,0xB2,0x8F,0x1D,0x2C,0xE8,0xE3,0xBC,0x69,0xDE,0xC7,0x36,0xBF,0xD3,0x24,0x6D,0xD1,0xE5,0x00,0xE7,0x07,0xBF,0x6C,0x46,0x42,0x89,0x48,0xCC,0x94,0x0B,0xB4,0x3B,0xC8,0x3D,0x90,0xB1,0x1E,0x67,0xFC,0x10,0x5B,0x51,0xD8,0x9A,0x8D,0x7D,0x05,0xA6,0x87,0x4B,0x21,0x2C,0x51,0x11,0x4D,0x8D,0x14,0x27,0x69,0x07,0xC2,0xA5,0x95,0xF0,0x9E,0x93,0xB1,0x7A,0x48,0xC9,0xDD,0xDB,0x0D,0xFF,0xF9,0x51,0xDB,0xF2,0x1A,0x0A,0xB5,0xC4,0x3E,0x80,0x63,0x9E,0x52,0x21,0x26,0xA8,0x76,0xAB,0xF0,0x2A,0x8A,0x5C,0xAC,0x1C,0xAE,0xD6,0x7A,0x46,0xC2,0x77,0x3F,0x70,0xE6,0xFD,0xE9,0x9A,0xBF,0xAC,0x84,0xAC,0x9B,0x26,0x52,0xF6,0xF7,0x87,0x17,0xC0,0xD3,0x0D,0xC1,0x42,0x2F,0xFA,0x9D,0x74,0x0B,0x70,0x4B,0x2E,0x67,0xD1,0x0E,0x18,0x43,0x5B,0xD8,0x15,0xA0,0x00,0xB6,0x23,0x84,0x8A,0x60,0x79,0xE8,0x2B,0x25,0x4F,0xCC,0xA1,0xF3,0xCD,0x30,0x56,0xAF,0xFB,0x14,0xDC,0x79,0xA1,0x78,0x7D,0x3C,0x97,0x5C,0xF7,0xEA,0xBB,0x81,0xCB,0xE0,0x8D,0xA5,0x41,0x36,0xD7,0xC9,0xE0,0x73,0xE1,0xED,0x6A,0xA5,0x63,0x11,0x9D,0xF9,0x55,0x35,0x17,0x2F,0x0F,0x59,0xB6,0x6A,0x39,0x7D,0x3C,0xBC,0x34,0x9E,0x67,0xD2,0x02,0xBA,0xED,0x04,0x58,0xD6,0x4C,0x41,0x6E,0xF2,0xC6,0x97,0xEC,0x0E,0x31,0xCB,0xDA,0x2A,0xBB,0x1D,0x80,0x46,0x1A,0x58,0xB6,0x62,0x90,0x8E,0xD8,0xC3,0x62,0x4C,0xEE,0xE7,0xE8,0x9A,0xB4,0x8B,0x49,0xDF,0x82,0xAF,0xC3,0x09,0x00,0x53,0x34,0x55,0x36,0x77,0xBE,0x83,0x6C,0x1B,0x1F,0xC6,0x5A,0x3F,0x95,0x10,0x57,0xDC,0xCE,0x7E,0x61,0xF8,0x44,0xA8,0x3B,0x94,0xE5,0xED,0x0D,0xB0,0x6F,0x3B,0x8F,0x4C,0x98,0x67,0xB9,0x68,0x12,0xB1,0xE3,0x04,0xB3,0xF4,0xD5,0x20,0x39,0x22,0xF9,0xFD,0x05,0x28,0xCF,0xD9,0x8F,0xFE,0x95,0xB5,0x2E,0xBB,0xA3,0x91,0xA4,0x6D,0x21,0x6D,0x53,0x31,0x17,0x49,0xD9,0xE7,0x4D,0x25,0x78,0xA2,0x7B,0x01,0xF2,0x74,0x76,0xE2,0xA9,0x1A,0x40,0xC6,0x23,0xCC,0x1A,0xAA,0x82,0x89,0x2C,0x8E,0x08,0x5F,0xAE,0x72,0xFF,0x03,0x98,0x56,0x75,0xD5,0xC2,0x3A,0xD4,0x90,0xF4,0x1E,0x5E,0x46,0x1A,0x5A,0x64,0x0F,0xAC,0x2F,0xE1,0x5C,0x49,0xB0,0x51,0xA9,0xFF,0xC5,0xD4,0xFA,0xBC,0xB1,0x0E,0x7C,0x0A,0xC4,0x8B,0x2F,0xEF,0x5B,0x3B,0xDA,0x59,0x2E,0xBE,0x89,0x69,0xB3,0xB0,0xE9,0xDF,0xC6,0x35,0xBA,0x3A,0x59,0x85,0x4F,0x8C,0x2C,0x00,0x1C,0xCF,0x58,0xDA,0x9A,0x79,0x2D,0x5F,0xC9,0x9C,0xB2,0xEF,0x3C,0x2A,0xC7,0x6A,0x6F,0xFC,0xB7,0xF3,0xFC,0x4A,0xC2,0x76,0xAF,0xAF,0x5D,0xC6,0x5A,0x19,0x28,0x43,0x09,0x5A,0x5C,0x99,0x8F,0xEC,0x29,0x1C,0xDC,0x09,0xB6,0xAC,0x29,0xBF,0xC3,0x29,0x7A,0x8F,0x5E,0x27,0x1A,0x39,0x2B,0xA2,0x49,0xDC,0xB4,0x12,0xBC,0x6A,0xC1,0x97,0xEF,0x29,0xB5,0x4D,0x69,0x9F,0xC0,0xC8,0x3A,0x7A,0x5F,0x78,0xCF,0xCC,0x2A,0xFD,0x9C,0xE9,0xBF,0x75,0xD6,0x5F,0xCA,0xF0,0x85,0xBA,0x55,0x40,0x70,0x0C,0x20,0xC5,0x23,0x4F,0x54,0x1F,0xA5,0xF9,0x21,0x9A,0xF6,0x1C,0xBE,0x2A,0x03,0xAA,0xCB,0xAF,0x50,0x9A,0x56,0xA1,0x30,0x2C,0x23,0x24,0x63,0xC9,0xBC,0x94,0x96,0x7F,0xC9,0x11,0xC5,0x3C,0xBD,0xCB,0x43,0x8A,0xC8,0x4E,0x10,0x6F,0x57,0xFE,0xE5,0xD9,0x22,0x7B,0xB6,0x0F,0x52,0xE2,0x25,0xB9,0x27,0x67,0x76,0x5C,0xB8,0xD7,0x83,0xEA,0xCD,0x52,0xD0,0xA9,0xB9,0x88,0x56,0x1F,0xCC,0x0D,0x05,0xFA,0x53,0xBD,0xF0,0x4C,0x26,0x38,0xA3,0x7C,0xBB,0x36,0xC3,0xCA,0xCE,0xB3,0x90,0x2F,0x51,0x03,0x65,0x99,0x24,0x86,0x36,0xDA,0x50,0x5C,0xB0,0x6C,0x25,0xD9,0xE3,0x89,0xBA,0x69,0x16,0x3F,0xCF,0xEC,0x45,0xC3,0xAF,0x46,0x82,0x75,0xDA,0xC3,0xD1,0x90,0x45,0x73,0x24,0x26,0x30,0xF6,0x77,0x65,0x44,0x2C,0xF1,0xD3,0x31,0xA9,0xA2,0x36,0xAE,0x19,0x57,0x80,0xDB,0x9C,0x04,0xB0,0x46,0x92,0x64,0x06,0x33,0x17,0x37,0xE3,0xAC,0xA7,0xC2,0x55,0xD9,0x22,0x91,0x16,0xAD,0xF8,0x17,0xA0,0xD8,0x7D,0x44,0x45,0x47,0xCD,0xB1,0xF3,0x32,0x48,0xE2,0x25,0x42,0xD1,0x71,0x93,0x37,0x54,0x22,0x76,0xA8,0xE4,0xD7,0xC0,0xDD,0x61,0x84,0x83,0xA9,0xBB,0x02,0x35,0xDC,0x3E,0x51,0xD0,0x43,0x8E,0xA4,0x66,0x36,0x0B,0xF7,0x56,0xAB,0x05,0x97,0xE0,0xDE,0x80,0xC4,0x05,0x41,0x30,0x31,0xB3,0x34,0xB5,0x62,0xF0,0x40,0x6F,0xE4,0x46,0x35,0xEA,0xB7,0xA3,0xAA,0x5A,0x42,0x15,0xDF,0xDF,0x11,0x30,0x4A,0x57,0x5B,0x86,0x3F,0xD2,0x08,0x63,0xA0,0x62,0xFD,0xD5,0xD5,0xE7,0xAE,0x96,0xA1,0x3D,0x28,0x20,0xD4,0xB8,0x7B,0xC5,0x4B,0x08,0x8E,0x73,0x3E,0x8D,0xDD,0x43,0xA3,0x83,0xBD,0xF5,0xD6,0x06,0xEE,0x10,0x49,0xB6,0x1B,0xA6,0x3C,0x33,0x48,0xE5,0x48,0xE9,0xCE,0x53,0x3D,0x6C,0x9D,0xB6,0xA2,0xDC,0x68,0x00,0xD7,0x59,0x3B,0xD6,0xA7,0xC0,0xA8,0x60,0xD2,0x45,0xFB,0x85,0x4D,0xF5,0x0E,0x33,0x38,0x70,0x5D,0x70,0x4C,0xAA,0xDB,0xC6,0x39,0x2F,0x88,0x23,0xA6,0x9F,0x7D,0x95,0xD3,0x1A,0x2E,0xA5,0x4E,0x14,0x4E,0x13,0x3B,0x91,0x1D,0xF6,0xA4,0x21,0xE8,0x40,0xD1,0xA4,0xBB,0x03,0xA5,0x7E,0x3D,0xB5,0xD0,0xFB,0x6E,0x50,0x4F,0x4B,0x9B,0xE6,0x3A,0xCE,0xC8,0x97,0x8F,0x20,0x2A,0x21,0xFA,0xA5,0x79,0xC4,0x65,0x15,0x8C,0x72,0x10,0x90,0xDF,0x31,0x64,0x4A,0x59,0x87,0x11,0xCF,0x0A,0x62,0x8E,0x7F,0xFF,0xD4,0xFB,0xFA,0xAC,0xE4,0x66,0xF4,0xCC,0x52,0x13,0x71,0x9F,0xB7,0x8C,0xC1,0x6A,0x01,0xF9,0x44,0x39,0x42,0x8D,0x9E,0xBF,0xF4,0xF8,0x1B,0xEC,0x11,0x67,0xAB,0x19,0xA7,0x12,0x2E,0x4A,0x71,0x62,0xB7,0xD9,0xC7,0x17,0x32,0x8A,0x22,0x88,0x82,0x7F,0x94,0xFD,0x07,0x2C,0xD7,0x89,0xDD,0xAA,0x61,0xFC,0x58,0xF9,0x84,0x63,0xE8,0x0C,0x32,0x16,0x6D,0x5F,0x02,0x8B,0x63,0x3F,0xB4,0xFE,0xE6,0x6C,0x51,0x61,0x56,0x99,0xE7,0x14,0xD3,0xCA,0xA4,0x60,0x09,0x4C,0x12,0x15,0x8C,0x1F,0xF7,0x8A,0x3C,0xEA,0x41,0xFF,0xB9,0xB9,0x64,0x6A,0x31,0xF3,0xD2,0x1F,0xB4,0xA0,0x37,0x80,0x04,0x55,0x81,0xF5,0x81,0x06,0xC2,0x81,0x5B,0x80,0x74,0xF4,0xDE,0xD3,0x91,0x6B,0x6E,0x26,0x27,0x1E,0xEB,0x75,0x17,0x83,0xE5,0x15,0xA1,0xF6,0x60,0x46,0x44,0x69,0xD0,0xB3,0xF2,0x1C,0x55,0xE0,0xB1,0x68,0x8F,0x66,0x07,0x1D,0x0A,0x35,0xE2,0x82,0xBA,0xC0,0x54,0xF7,0x3F,0x93,0x82,0x87,0xA6,0x00,0x34,0xF2,0x23,0x53,0xD1,0x6D,0x93,0xA6,0x67,0x18,0x16,0xF5,0x24,0x6C,0xCC,0x73,0x92,0x19,0x49,0x20,0x77,0x86,0xF9,0xD5,0xC1,0xF3,0x7C,0x86,0xF1,0x6E,0x72,0xE6,0x47,0x1B,0xF7,0xB5,0xA2,0x84,0x47,0x40,0x14,0xF1,0xC2,0x13,0x57,0x85,0x18,0x95,0xE1,0xF0,0x9D,0xC6,0x04,0x6F,0x2D,0x33,0xB2,0x1A,0xA8,0x60,0x4E,0x7A,0x02,0xA7,0xF8,0x0F,0x87,0xF4,0x1D,0x90,0x37,0x01,0xAB,0xE5,0xB2,0x52,0xE8,0x91,0x68,0xD4,0x5E,0xE4,0xED,0x87,0xBB,0x7B,0x5D,0x72,0x0D,0x0E,0xD8,0x21,0x3D,0x93,0xD6,0x41,0x8B,0xE6,0x53,0x12,0x6E,0x79,0xE3,0xE7,0xD8,0x0C,0x66,0xB4,0x9B,0x78,0xBC,0x32,0x2D,0x0D,0x39,0x61,0xC8,0x92,0x89,0x94,0x7E,0xE7,0x0C,0xC7,0xA8,0x97,0x95,0x54,0x1E,0xE2,0x10,0x07,0xFB,0x7D,0xA0,0xF2,0x4D,0x08,0x25,0xA1,0x0E,0x7C,0xFF,0x27,0xB8,0x09,0x7A,0x74,0x5D,0x96,0xCA,0x81,0xEB,0xE3,0x4F,0xD2,0xDB,0x7E,0x41,0xB2,0x6D,0x0B,0xC4,0xE1,0x88,0x94,0x74,0x14,0x3E,0xE1,0xF1,0x47,0x7D,0x95,0x2B,0xC1,0xCB,0xE0,0xAE,0x92,0x2E,0x7F,0x1E,0x67,0x98,0x0A,0x9B,0x34,0xBD,0x9F,0x13,0x7E,0x0B,0xEA,0x96,0x2D,0xEE,0x75,0x26,0xD8,0x58,0x00,0xA3,0x8B,0x1B,0x74,0x79,0x0D,0xAD,0x01,0xFC,0x5E,0x48,0x9E,0x4C,0xAB,0xFE,0xEB,0xC9,0xF8,0xCE,0x76,0xC7,0x98,0x78,0x03,0x42,0xCB,0x9D,0x9C,0xF2,0x3E,0x2B,0xE9,0x77,0x6D,0x68,0x9D,0xAD,0xEB,0xDE,0xE8,0x28,0xB8,0x3B,0x77,0x98,0x4D,0x8D,0x02,0x1D,0x1E,0x5B,0x72,0x84,0x8D,0xED,0x07,0x01,0xDE,0x08,0x98,0xB1,0x2B,0xBE,0xED,0x34,0x78,0xFD,0x99,0xEE,0xFE,0x4B,0xEC,0x6B,0xAD,0xAE,0x73,0xDB,0x58,0x18,0x06,0x5E,0x0B,0x28,0x9B,0x50,0x6B,0x9E,0xEE,0xD5,0x38,0x7B,0x71,0x65,0xCD,0xCD,0x04,0xE0,0x9E,0x8E,0x70,0x3A,0x18,0x38,0x05,0xBF,0x4B,0xDD,0x9A,0x0F,0xBE,0x6B,0xEF,0x8A,0xED,0x85,0x20,0x2A,0xF4,0xCC,0x52,0x14,0x42,0x17,0xC4,0x56,0xA7,0x5E,0xB6,0x68,0x11,0x9E,0xD7,0xD2,0x52,0xD7,0xA5,0xEC,0xE4,0x0C,0x33,0xAE,0x01,0x45,0x41,0x90,0xB7,0xB3,0xF1,0xE5,0x87,0xFA,0x83,0xDB,0x31,0x21,0x15,0x99,0xD4,0x68,0x67,0xA7,0x62,0xA8,0x06,0x1D,0x21,0xE1,0x74,0x23,0x97,0x3A,0xE2,0x61,0x72,0x73,0x90,0x5F,0xC4,0xE9,0xBD,0x8B,0x12,0xA0,0xCF,0xB5,0xA4,0x7B,0x59,0xF7,0x41,0x32,0x2B,0xC9,0xF7,0xF2,0x4A,0x73,0xB4,0xBB,0x38,0x4D,0x02,0x60,0xAE,0x0F,0xE7,0x29,0xDC,0x31,0x51,0xDF,0x6C,0x44,0x61,0x96,0x1E,0x7A,0xD7,0x4D,0x88,0x38,0x32,0x04,0xFA,0x06,0x84,0xC4,0x9B,0xBC,0xC7,0x8D,0xE9,0x82,0x71,0x56,0x7F,0xC0,0x94,0x1F,0x0D,0xFE,0x22,0x5D,0x25,0x57,0x07,0x14,0x57,0x69,0xB1,0xCF,0xC1,0x2B,0x54,0x86,0xB3,0x15,0xE2,0x46,0xD2,0xAF,0xA1,0x0F,0xA0,0x91,0x17,0xD4,0x36,0xD3,0xF2,0x9D,0x44,0xED,0x44,0x6B,0xF4,0x98,0x74,0x22,0x86,0xA6,0xC2,0xF9,0x10,0xE4,0x27,0xB0,0x62,0xDA,0x91,0x70,0x03,0x60,0xD2,0x39,0x71,0x5E,0x64,0xE2,0xE7,0x1C,0x81,0xAB,0x95,0x22,0x37,0x31,0xB8,0xF6,0xE1,0x78,0xCA,0xC8,0x57,0xA3,0x5C,0x8A,0xB2,0xEA,0x2E,0xB4,0x04,0x2A,0x4F,0x0E,0x47,0x63,0x3D,0x30,0xF1,0xB8,0xAB,0x72,0x14,0xF1,0xD9,0x4C,0xA2,0x07,0x69,0x39,0x92,0x4E,0x1B,0x07,0x24,0x95,0x8D,0x45,0xC1,0xDC,0xFF,0x7B,0x77,0x1C,0x9E,0xC1,0x34,0x55,0xEC,0xFF,0x82,0x8E,0x7A,0xBD,0x67,0xC7,0x08,0x83,0xD1,0x0A,0x2A,0xD0,0x2D,0x43,0x58,0xEE,0x9B,0x98,0xCE,0xAC,0x7E,0xD1,0xBC,0x92,0xC8,0x11,0xDD,0x28,0x8B,0x58,0xAF,0x16,0x3D,0x83,0x39,0x54,0xD8,0xCA,0x4B,0x6A,0x6E,0x3C,0xFB,0x1F,0x5E,0x75,0x89,0x21,0xE8,0xAE,0x1F,0x63,0x0D,0xE7,0x6D,0x5D,0xBB,0x27,0x0C,0xE7,0xF8,0x6E,0x7E,0xD9,0x4E,0xB5,0xE8,0x9B,0xAB,0xFC,0x9A,0xA5,0x1D,0x66,0xB7,0x71,0xCB,0x2F,0xC5,0x4F,0x7D,0xF4,0x53,0x0D,0x98,0xBD,0x21,0x33,0x2E,0x7D,0x40,0x89,0x6D,0x34,0x32,0xB7,0xDB,0xEF,0xA4,0xF5,0x3E,0xA6,0xD6,0xCB,0x88,0x50,0x66,0xBE,0xB8,0x19,0x14,0x80,0x0E,0xC2,0x82,0xC2,0xEB,0x8B,0xF0,0xFC,0x5D,0x4B,0x91,0x46,0x1E,0x02,0xE3,0x78,0xA8,0xD9,0x75,0x3A,0x4D,0x90,0x07,0x04,0xFB,0xD2,0x2F,0xAD,0xDE,0x9B,0x5D,0x93,0x68,0x40,0xCB,0xD1,0x8D,0x09,0xB9,0xEF,0x3B,0xC9,0xD8,0x55,0x78,0x80,0xAA,0x6B,0xCE,0x5B,0x3C,0x29,0x2B,0x12,0x4E,0x17,0x9D,0xE4,0xFE,0x62,0xAD,0xAD,0x8C,0x5C,0x1B,0x76,0x1A,0x1E,0xFE,0x3F,0x68,0x20,0x48,0xFF,0x09,0x9A,0x0B,0xB6,0x7B,0xA4,0xBD,0x6D,0xED,0xE6,0x58,0x24,0x9F,0xD8,0xEE,0xBE,0xB2,0x0C,0x38,0xF7,0xC0,0x32,0x8E,0x2C,0x56,0x70,0x6B,0x65,0x24,0x4E,0xDD,0xA5,0x45,0xF4,0x9E,0xEC,0x37,0xCA,0x28,0x37,0xA1,0x88,0xCD,0x7E,0xD3,0xB6,0x7B,0x88,0x63,0xC3,0x4B,0xC1,0x11,0xFD,0xFD,0x1A,0x87,0xBF,0x18,0x53,0xF5,0x81,0xAE,0x93,0x94,0x3B,0xED,0xDA,0xE6,0x05,0x5B,0x01,0x70,0x47,0xBE,0x48,0x02,0x79,0x08,0xA4,0x34,0xE1,0x79,0xED,0x46,0xDF,0xCF,0x36,0xD0,0x9D,0x2A,0x7F,0xA2,0xA3,0x9C,0xBF,0xC3,0x19,0xDF,0xF6,0xB1,0x27,0x69,0x2D,0x27,0x65,0x8C,0x64,0x55,0x5B,0x3A,0x92,0xE5,0x2E,0x0A,0xDB,0x97,0x10,0xBC,0x00,0x01,0x52,0x59,0x49,0x73,0x6C,0xEF,0x89,0x12,0xD6,0xAC,0xC0,0x60,0xE8,0x1A,0x1B,0xF6,0xAA,0xFF,0x52,0x84,0x94,0x49,0xC8,0xA9,0x40,0x9F,0x81,0xDB,0x7E,0x29,0x5A,0x4D,0x3C,0xCC,0x13,0x3F,0x02,0x7A,0xD3,0x5E,0xB8,0x39,0x9A,0x2C,0x86,0x8F,0x41,0xBA,0xC4,0x6A,0x08,0xC8,0xFA,0xDC,0xFE,0x78,0x8F,0xEC,0xB7,0x0A,0xB1,0x5A,0x6C,0x9C,0xF3,0xBF,0x25,0xEE,0xCD,0x09,0xE5,0x8F,0x77,0x4A,0xAC,0xFD,0x49,0xFC,0x77,0x6B,0x0B,0x19,0x3E,0x19,0x35,0xAF,0x7C,0x31,0x9C,0x8A,0x35,0x43,0xA2,0x3C,0xEE,0xD5,0xE0,0xD9,0xA7,0xA7,0xDE,0x6F,0x67,0xC6,0x64,0x2C,0x2E,0xB4,0x5A,0x9A,0xF5,0x22,0x18,0x7F,0xBC,0x50,0x26,0xC9,0x4A,0xE0,0x53,0xF9,0x03,0x92,0x6D,0x4F,0xD8,0x04,0x2F,0xAA,0x91,0x76,0x11,0x1C,0x51,0x17,0xAB,0x5F,0x18,0x65,0x95,0xE9,0xC3,0xF3,0xD7,0x0C,0x8A,0x81,0xE9,0xBA,0x10,0xAC,0x3D,0x6C,0x59,0xDE,0x03,0xDA,0x82,0x48,0x41,0x3F,0xCB,0x3A,0x7F,0x89,0x0B,0x5B,0xC5,0xCA,0x42,0x29,0xFB,0x7C,0x99,0xBF,0xB9,0x99,0xD0,0xCD,0x87,0x2F,0x26,0x7D,0xF2,0x1F,0x6F,0x0F,0xCC,0xA9,0xB4,0x99,0x8E,0x4C,0xFD,0xEB,0xB0,0xFA,0x3D,0x8A,0x0A,0xB9,0x74,0xF8,0x34,0x0F,0xAF,0x6E,0x76,0xEA,0xE6,0x1C,0x48,0x5C,0x2B,0x3E,0x1B,0xA0,0x62,0x4C,0x25,0x16,0xB9,0xDA,0x67,0xF3,0xF0,0xA8,0x59,0x45,0x30,0xC9,0xE3,0x06,0x79,0xBB,0xDD,0xB0,0xA2,0x2D,0x9F,0x55,0xEB,0x5F,0xA1,0xE3,0x1D,0xEF,0xD4,0xD3,0x54,0x9D,0xEA,0x65,0x8F,0x0B,0xA8,0x80,0xC6,0x79,0x96,0x36,0x06,0x18,0x2C,0x75,0x4F,0x6A,0x12,0xC3,0x94,0xFC,0x50,0x26,0xDD,0x8E,0x6E,0x90,0x47,0xA3,0xBA,0x46,0x0E,0xD1,0x84,0xF0,0xD5,0x47,0xC6,0x15,0x9C,0x35,0xF8,0xA3,0x5C,0x54,0x42,0xE0,0x15,0x26,0x7C,0x56,0xCE,0xB0,0x3E,0xB3,0x87,0xC2,0x00,0x05,0x71,0x72,0x75,0x35,0x38,0x00,0x4B,0x83,0xE3,0x96,0x09,0x66,0xAA,0xE4,0x37,0xD0,0x6A,0x85,0x8D,0x93,0x23,0xF7,0xB3,0x25,0xF8,0x61,0xF1,0xC0,0xB1,0x13,0xCF,0x76,0xF3,0x3B,0x66,0x53,0xBA,0x49,0x58,0xE5,0x61,0xDF,0x1A,0x00,0x28,0xAD,0x24,0xB6,0xE8,0xCC,0x9E,0xF5,0xA1,0xBE,0xA0,0x43,0x7A,0x28,0xE2,0xA6,0x33,0x5A,0xDC,0x10,0xC5,0xEA,0xA9,0x20,0x8C,0x98,0x97,0x96,0x57,0x0E,0xD5,0x73,0x1E,0x7C,0xEB,0xC5,0xDE,0x1D,0x51,0x86,0x97,0x6F,0x6F,0x30,0x4C,0xF9,0x2D,0xD5,0x05,0x8B,0x13,0x63,0x9F,0xA6,0xC7,0xB5,0xD6,0xD4,0xF9,0x03,0x0D,0x42,0xBB,0xE6,0x44,0x30,0x85,0x50,0x84,0x51,0x3F,0x13,0xCD,0x23,0x01,0xA5,0x16,0xB5,0x43,0x40,0x5F,0xC7,0x7D,0xF6,0xA9,0x77,0x08,0xC6,0xE0,0x05,0x36,0x70,0x3B,0x93,0x74,0x95,0x72,0xE1,0x4A,0x23,0xB2,0x80,0xF0,0x60,0xFB,0xF2,0xCE,0xD6,0x20,0x64,0x8C,0x33,0x69,0x16,0xB2,0x85,0xA3,0x2C,0x91,0x08,0x62,0xF6,0x63,0x9A,0x1E,0xA7,0x4A,0x13,0xDF,0x7D,0xB8,0x81,0xE6,0x05,0x18,0x3E,0x27,0xDF,0xEA,0xAC,0x5B,0x8E,0xC3,0x25,0x9A,0x54,0x31,0xB7,0x29,0x7E,0xBC,0x64,0xE8,0xA4,0x4E,0xF6,0x94,0xF5,0x67,0x7F,0x55,0x2F,0x95,0xED,0x6C,0x57,0x35,0x52,0xAD,0x8D,0xC7,0xC0,0xD1,0xDC,0xEE,0x49,0x10,0x06,0x1C,0xDB,0x88,0x88,0xCB,0xD0,0x49,0x52,0x39,0x42,0x35,0x03,0x10,0xCB,0xF4,0xD9,0xE2,0x59,0xCD,0xA1,0x42,0xE6,0x0C,0x7B,0xB0,0x74,0x70,0x2A,0x99,0xFD,0xB1,0xF0,0x6B,0x6F,0x02,0xDA,0xE6,0xBC,0xC3,0x00,0x14,0x2E,0xBF,0x51,0x3D,0xA7,0x7E,0x8B,0xCF,0x35,0x47,0xF3,0x6F,0x8A,0x86,0x29,0x9D,0x18,0xFA,0x78,0xB4,0x91,0x3B,0xA2,0x46,0x03,0xF5,0x5B,0x25,0x87,0x34,0x81,0xD7,0x15,0x48,0xD0,0xFE,0x9C,0x89,0x0A,0x0C,0x0E,0xB0,0x72,0xAC,0xB1,0x71,0xA8,0x5E,0x23,0x0D,0xF9,0x77,0xAA,0xCC,0x23,0x85,0x38,0x7F,0x09,0x08,0xEB,0xBE,0xD3,0xFA,0x79,0xC2,0x82,0xD3,0xF0,0x03,0x58,0x21,0x62,0x3A,0x20,0x81,0xDD,0xFB,0xFA,0x73,0x4F,0x87,0xAB,0x5A,0xC6,0x46,0x71,0xA8,0x54,0xDE,0xFF,0x7F,0x5F,0x1F,0x25,0x8D,0xCD,0x63,0x74,0xA4,0x44,0xA2,0xAE,0x56,0xD6,0x9B,0xD6,0xF6,0x69,0x5A,0x0C,0x04,0xFB,0x26,0x5D,0x2D,0x72,0xE7,0x87,0xDF,0xE0,0x54,0xAD,0x52,0x33,0x95,0x77,0xA0,0xA1,0xE9,0x26,0x89,0x28,0x28,0xFC,0x7B,0xBA,0x11,0x84,0xDB,0x05,0xD0,0x5E,0x29,0x97,0xAC,0x0F,0x00,0x1E,0x6D,0xD5,0xF2,0x8C,0x0F,0xC2,0xC6,0x29,0xCE,0x18,0x34,0xBB,0xB2,0x49,0x1D,0x32,0x73,0x93,0xEF,0xA0,0x4A,0xEB,0x4F,0x1F,0x8B,0x31,0xBD,0x8D,0xF7,0x60,0x94,0x04,0x36,0xBA,0x66,0x96,0x85,0x90,0xEB,0x45,0x44,0x4A,0x19,0xD7,0x38,0x1B,0x30,0x5E,0xF9,0xC1,0xC2,0xCC,0xC0,0xB9,0x62,0x73,0x01,0x63,0x90,0xE1,0x7D,0x32,0xB9,0x68,0xBC,0xE8,0x4B,0xFA,0x24,0x66,0x9C,0xF1,0xE5,0xBC,0x6E,0x63,0x99,0xED,0x47,0xEA,0x58,0x37,0xB5,0x78,0x61,0x4F,0x15,0xC7,0xA0,0x95,0xE7,0x55,0xDC,0xC4,0xCE,0xDC,0x1D,0x1E,0x3C,0x4E,0xAE,0x34,0xB1,0x9D,0x6F,0xEE,0x43,0x0F,0x13,0xBF,0x6A,0x86,0xD2,0x65,0x98,0x14,0xEB,0x1D,0x38,0xAB,0x2A,0xC7,0xCA,0x39,0x56,0x96,0xE3,0xB0,0x97,0x4C,0x11,0x22,0x59,0xB5,0x72,0xA6,0x98,0x6F,0x80,0x34,0xE4,0x3E,0xA9,0xBD,0x25,0xE4,0x5B,0x2F,0x1C,0x9C,0xFB,0x90,0xDD,0x46,0x09,0x02,0xA1,0x17,0x20,0x8B,0x60,0xCD,0xD2,0x19,0xD3,0xE7,0x5F,0xCA,0x12,0x3D,0xAD,0x58,0x6E,0x6C,0x84,0xD1,0xAF,0xB6,0x76,0x43,0x96,0xCE,0xD6,0xFC,0x57,0x14,0x24,0x6E,0x2B,0x45,0x0D,0xE7,0xEA,0x9F,0xFF,0x75,0x72,0x11,0x28,0x7E,0xB3,0xCB,0xDA,0xEC,0xCF,0x9A,0xF3,0x65,0x0E,0x40,0x01,0xF7,0x37,0x38,0xA1,0x48,0xF6,0xE2,0x53,0xDA,0x8A,0xB3,0x7A,0x53,0x4B,0x69,0x88,0xC1,0xF8,0x43,0x05,0x12,0x39,0x99,0xF7,0x80,0x45,0xC8,0xDE,0x09,0x84,0x12,0x2C,0x9B,0xBD,0x6A,0x8C,0x24,0x7C,0xB0,0x7E,0xB6,0x00,0xE1,0x57,0x3F,0xC1,0x3B,0xA5,0xAD,0xC4,0xCF,0x3F,0x4A,0x05,0x15,0xCD,0xD8,0x79,0x44,0xE4,0x51,0xB8,0x9E,0x16,0xC3,0x81,0xE6,0xB6,0x7C,0x40,0x3C,0x44,0xEE,0x3C,0x6D,0x6D,0x67,0xFD,0xB7,0x9F,0xF5,0x4E,0x9D,0x12,0x26,0x8F,0x47,0xE0,0xB4,0xF3,0x16,0xC9,0x3D,0x32,0xCC,0x3B,0xAF,0x0B,0xB4,0x9B,0x10,0xCA,0x6E,0x69,0x82,0xB6,0x3F,0x40,0x0B,0x77,0xE5,0xB2,0x99,0xEF,0x6B,0x65,0x92,0x2E,0xB1,0x97,0x00,0x52,0xE0,0xBE,0x89,0x93,0x3A,0x4C,0x1B,0xAA,0x42,0xEC,0xA4,0x6B,0x98,0x1E,0x36,0x17,0xC9,0x37,0xBF,0xD6,0x13,0xC5,0x2D,0x65,0x39,0x48,0xFE,0xA4,0xE3,0xBA,0x6C,0xD8,0xB2,0x93,0xE5,0x19,0x68,0x61,0x77,0x20,0x10,0xC1,0xC8,0xE1,0xCA,0x33,0x5A,0x9D,0x9B,0x1A,0xD3,0x5C,0x41,0xE8,0x41,0x92,0xB8,0x8B,0xC5,0x53,0x62,0x79,0x57,0x2F,0x33,0x50,0xDE,0xEE,0xE9,0xA2,0x4C,0xD7,0x91,0x02,0xF3,0x16,0x4B,0xF0,0x61,0x6A,0x1A,0xD9,0xE8,0xAB,0xC0,0x2B,0x7A,0x18,0xEA,0xA6,0xA9,0xD9,0x30,0x54,0x3B,0xA5,0x61,0x7D,0xB2,0x64,0xBB,0x8F,0x20,0x5D,0xC3,0x2F,0x9F,0x9C,0x19,0xDD,0x0D,0xE0,0x48,0xF4,0x84,0x21,0x92,0x06,0x16,0xB9,0x1C,0xD1,0x1D,0x78,0xC6,0x23,0x8F,0x04,0x97,0x0A,0x06,0xC5,0x4D,0xF8,0x94,0xFC,0x35,0x58,0x2B,0x3D,0xEF,0xAA,0xB9,0x41,0xBE,0x83,0x30,0x80,0x64,0x71,0xA2,0x33,0x4E,0xFC,0x71,0xF2,0x94,0x0E,0xE3,0x8E,0xC5,0x27,0x6A,0x4F,0x1F,0xD5,0xF8,0x76,0x67,0x75,0x47,0xB7,0xBD,0x87,0xD5,0xCB,0xEC,0xAE,0x5C,0x0A,0x36,0x5C,0xCE,0x68,0x21,0x68,0x6B,0xA9,0xFB,0x9A,0xF9,0xD5,0xAA,0xB3,0x70,0x14,0x70,0x41,0xE2,0x2D,0x08,0xE1,0x5D,0xEC,0xD2,0x13,0xCF,0x90,0x83,0x3A,0x46,0x51,0x59,0xC8,0xD4,0xE2,0x73,0x45,0x07,0x23,0xA9,0xB7,0x95,0x5F,0xF8,0x9E,0x1C,0x9E,0x22,0x6C,0x8E,0xA7,0x5A,0xCC,0x31,0x66,0x80,0x3E,0xA3,0x1A,0xD1,0x17,0x2A,0xDB,0x0B,0xE5,0xB8,0x43,0x85,0x32,0xB3,0x82,0x5F,0xC0,0x21,0xFE,0x0E,0xE9,0xA8,0x3F,0xD4,0x1B,0x3A,0x06,0xAC,0xBB,0x85,0xC7,0x76,0x49,0x17,0xBB,0x27,0x60,0x9E,0x7A,0xFD,0x92,0x0C,0xC9,0xD7,0x1F,0xDF,0x08,0x0D,0xED,0x4D,0x74,0x5C,0xC4,0xC4,0xB5,0x86,0x36,0x56,0x8C,0xFE,0x96,0xE9,0x4D,0x24,0x64,0x7B,0x31,0x75,0x4D,0xF2,0xF0,0xAF,0xBF,0x60,0x3E,0x56,0xDC,0xE4,0xFF,0x8C,0x2E,0x76,0x83,0xDD,0x07,0xFF,0x42,0x07,0xF5,0x6D,0x7B,0x7F,0x55,0xD2,0xBA,0xA5,0xA7,0x40,0xC6,0xF4,0x8E,0xC9,0x07,0x2E,0x7C,0x5B,0xB4,0x04,0xF1,0x88,0x75,0xDE,0x03,0x1A,0x09,0x8F,0x2A,0x93,0xC8,0x55,0xD8,0x01,0xF1,0x2D,0x78,0xBE,0x30,0xF7,0x8A,0x2C,0x4C,0xA6,0xA3,0xA5,0x8D,0x7C,0x51,0x37,0x15,0xF2,0x86,0x3C,0xD4,0x28,0x74,0xAE,0xA8,0x79,0x5D,0x27,0x69,0xA3,0xAF,0xB5,0x50,0xDB,0x0F,0x0A,0x91,0x01,0xFD,0x98,0xED,0x50,0xD4,0x11,0x2C,0x8A,0x26,0x83,0x9F,0xA0,0xAB,0x50,0x5E,0x7A,0x59,0xC2,0x22,0x2B,0x70,0x4B,0xE3,0xF1,0x82,0xD9,0xDA,0x89,0x22,0x66,0x1B,0x53,0xD0,0xF4,0x67,0x02,0xF9,0x7D,0xA6,0xD8,0x0B,0xEF,0x63,0xA3,0x3F,0x4B,0x1A,0xC0,0xBE,0x91,0xDB,0xF3,0x29,0x9C,0x5E,0x8E,0x2C,0xD8,0x71,0x10,0xC4,0x98,0xAE,0x5F,0xE1,0x78,0x0A,0x66,0xB5,0x73,0x86,0xC2,0x6B,0x84,0xBF,0xC7,0x85,0x87,0x95,0xD3,0x9B,0xA4,0xC5,0x00,0xC8,0xFB,0x30,0x7E,0x1E,0xA0,0x38,0x65,0xCE,0x37,0xA2,0x58,0x3A,0xF6,0x7B,0xE6,0x77,0xEC,0x32,0x64,0xEE,0x41,0x13,0xF9,0x0E,0x55,0x3B,0x93,0x60,0x81,0xD1,0x0F,0xCA,0xDD,0xB0,0xCC,0xF1,0x33,0x72,0x57,0xCF,0xFE,0x7F,0x28,0x5C,0x16,0x51,0x8A,0x02,0x21,0x5D,0x22,0xC9,0xA9,0xB7,0xB4,0x39,0xC1,0xFD,0x18,0x31,0x0B,0x01,0xB8,0x09,0xD4,0xC3,0x11,0x8F,0xE4,0x1F,0xFC,0x27,0xB9,0x23,0x88,0x9D,0x45,0x8B,0x1C,0x0D,0xDC,0x97,0xDA,0x06,0xF0,0xD6,0x1D,0x2A,0x61,0x80,0xED,0x9A,0x6F,0xF7,0xA7,0x40,0x70,0xB1,0x15,0x49,0xE8,0xA5,0x14,0x89,0x43,0xE2,0xAD,0xF2,0x90,0x05,0x96,0x8C,0xFF,0x03,0x75,0xD2,0xF8,0x94,0xA1,0x53,0x3C,0x6C,0x4A,0xD0,0xEF,0xFA,0x74,0xAA,0x48,0x2B,0x50,0xB2,0xD7,0x9E,0xA6,0x6D,0xDE,0x34,0x6A,0x92,0xF5,0xE3,0x5B,0x52,0x47,0x2D,0xE5,0xD9,0x46,0x7D,0x04,0x17,0x4F,0xEB,0x56,0xA8,0x12,0xBD,0x59,0x44,0x79,0xCD,0x19,0xB6,0xBC,0xAB,0x99,0x68,0xEA,0x42,0xAC,0x8D,0x76,0x5A,0xDF,0x69,0x36,0x7A,0x67,0x24,0x2F,0x4D,0xD5,0x54,0xF4,0xAF,0x26,0x3D,0x9F,0x08,0x0C,0xE9,0x62,0xBB,0xCB,0x3E,0xBA,0xE0,0x07,0x7C,0x83,0xB3,0x25,0xE7,0xC6,0x2E,0x20,0x4E,0x1B,0x35,0x82,0x4C,0x6E,0x40,0xA1,0x1B,0xB3,0x2E,0x75,0x33,0xD9,0xD1,0x13,0x90,0xEC,0xEA,0xFD,0xF1,0x2F,0x7C,0x36,0x5F,0x08,0xEF,0xDE,0x52,0x77,0xE9,0x89,0x7D,0x02,0x22,0x01,0x71,0xAA,0x11,0x2B,0xDD,0x38,0x19,0xE1,0x97,0x94,0xAF,0xC4,0xE3,0x31,0x29,0xF4,0x21,0x98,0xBD,0x65,0x03,0xA8,0x07,0x99,0x3F,0xDC,0x26,0xD0,0xB7,0xFA,0x2D,0xFC,0xAB,0x3C,0x9E,0xB1,0x3A,0xE0,0x1F,0x6B,0x43,0x83,0x0C,0xF8,0x7E,0xAE,0x09,0xBC,0xFB,0xD3,0xC1,0x58,0x8E,0x7F,0xE4,0xB8,0x51,0x30,0x4B,0xA4,0xA6,0xE2,0x95,0x53,0x2A,0x46,0xBB,0x84,0xB5,0xF3,0xA5,0xA7,0x9F,0xE7,0x3E,0x80,0x10,0x5E,0xE8,0xDB,0xE5,0x20,0x1A,0xD6,0x82,0x78,0xEE,0x17,0x18,0x45,0xCE,0x61,0x12,0x44,0x57,0xCC,0x5B,0xC6,0x88,0x32,0xCB,0x76,0x37,0x6F,0x5D,0x24,0x96,0x9C,0xED,0x39,0x64,0x59,0x9D,0x79,0xAD,0x56,0x62,0x8C,0x48,0xCA,0x8B,0xB9,0x04,0x0F,0x5A,0x47,0x49,0x16,0x7A,0xFF,0x1D,0xBF,0x8F,0x06,0x74,0xD4,0x6D,0xF5,0x1E,0x9A,0x9B,0xEB,0xC9,0x42,0x28,0x2C,0xC7,0xE6,0x93,0x05,0x5C,0xA3,0xC0,0x27,0x6C,0x4E,0x15,0xA2,0x6E,0x3B,0x0E,0x00,0xBA,0x4F,0xA0,0xCD,0x0A,0x41,0xF6,0x3D,0x69,0xC8,0x91,0x35,0x60,0x50,0xD7,0x87,0xD2,0xB0,0xC2,0x8D,0xA9,0x63,0x85,0x34,0xF2,0xD8,0x23,0x55,0xAC,0xDF,0x25,0xB6,0xF0,0xCF,0x4C,0x6A,0x73,0x1C,0xB4,0x81,0x92,0xF7,0x0B,0x70,0x8A,0x68,0xDA,0x54,0xB2,0xD5,0x14,0x4A,0x4D,0xFE,0xBE,0x86,0xF9,0x66,0x0D,0xC5,0x72,0x67,0xC3,0x7B,0x27,0x6D,0x19,0x4E,0xCF,0xFE,0x66,0x43,0x98,0xF8,0x13,0x6C,0x10,0x33,0xBB,0x60,0xB0,0x51,0xA2,0x0A,0x64,0x3F,0xC8,0x22,0x02,0xC0,0xFD,0x81,0xEC,0xFB,0x3E,0xE0,0x74,0xAC,0xB9,0x12,0x88,0x16,0xCD,0x2E,0xC1,0x37,0xEB,0xA6,0xED,0x3C,0x2D,0xBA,0x3A,0x00,0x29,0xCC,0xF0,0x08,0x85,0x86,0xD5,0xBE,0x20,0xF2,0xE5,0x38,0x89,0x30,0x49,0xD0,0x6E,0x9F,0xA9,0xF5,0x21,0x40,0xB5,0x5A,0xF3,0xB7,0x42,0x84,0x57,0x3B,0xA0,0x8F,0xF1,0x2B,0x7A,0x0E,0x92,0x52,0xE9,0x1D,0xBF,0x6F,0xAD,0x18,0xC2,0xEA,0xC7,0x0B,0x69,0x93,0x06,0xFF,0x54,0x09,0x70,0xDF,0x55,0x03,0xDD,0x46,0xD7,0x4A,0x95,0xAA,0xE2,0xA4,0xB6,0xB4,0xF6,0x8E,0x91,0x2F,0x4F,0x01,0xCA,0xF9,0x31,0xF4,0x47,0xBC,0x9D,0x73,0xDB,0x59,0xA8,0x9A,0x1E,0x15,0x56,0x4B,0x07,0x58,0xEE,0x6B,0x23,0x99,0x67,0xDA,0x7E,0x26,0x35,0x4C,0x8D,0x87,0x28,0xFC,0x48,0x75,0x68,0x8C,0xF7,0xD6,0x14,0x82,0xB2,0x4D,0x36,0xD1,0x5F,0x7D,0xB3,0x04,0x2A,0x7F,0x11,0x1F,0xAE,0x0C,0x17,0x9E,0xC5,0x65,0xE4,0x7C,0x8B,0x0F,0xFA,0x8A,0x53,0xD8,0x3D,0x39,0xA1,0xC3,0x9C,0xD3,0x72,0xB8,0x25,0x94,0xC9,0xE3,0x44,0x32,0xCE,0xBD,0xA7,0x34,0x5E,0xAB,0xDC,0xB1,0x50,0x1B,0x2C,0xE7,0xD9,0x78,0x24,0x80,0x41,0x71,0x96,0xC6,0xC4,0xA3,0x5B,0x05,0xEF,0x5C,0x97,0xAF,0x77,0xE8,0xD4,0x1C,0x76,0x63,0x6A,0xD2,0xDE,0xE1,0x7B,0x5D,0x0D,0x62,0x90,0xA5,0xE6,0x83,0x61,0x1A,0x79,0x9B,0x45,0xCB,0xEC,0x59,0x1E,0x36,0xE9,0x1D,0x9B,0x4B,0xFA,0x8E,0xA6,0x66,0x7B,0x54,0xDF,0x05,0x70,0xB6,0xCF,0xA3,0xAE,0x41,0x43,0x07,0x01,0x5D,0xB4,0xD5,0x24,0xBD,0x6B,0x9A,0x0D,0x3E,0x00,0xC5,0xDB,0x65,0xF5,0xBB,0x40,0x42,0x7A,0x02,0x5E,0x61,0x50,0x16,0xB2,0x29,0xBE,0x23,0x2B,0x84,0xF7,0xA1,0x0B,0xF2,0xFD,0xA0,0xFF,0x33,0x67,0x9D,0x0F,0x18,0x14,0xCA,0x34,0xF6,0x75,0x09,0xCB,0x90,0xD6,0x3C,0xA5,0x44,0xFE,0x56,0xC7,0xE4,0x94,0x4F,0x0C,0x6C,0x98,0xE7,0x0A,0x3B,0xB7,0x92,0x99,0xD3,0xBA,0xED,0xCC,0x11,0xC4,0x7D,0x4A,0x21,0x06,0xD4,0xFC,0x04,0x72,0x71,0xF4,0xCE,0x38,0xDD,0xC8,0x19,0x4E,0xD9,0xC3,0x35,0x52,0x1F,0xE2,0x7C,0xDA,0x39,0x58,0x80,0xE6,0x4D,0x85,0xB5,0x32,0x62,0x8C,0x2D,0x74,0xD0,0xEF,0xA4,0x13,0xD8,0x5F,0xAA,0x45,0x28,0x49,0x3A,0xC0,0x53,0x17,0x3D,0xC6,0xB0,0x4C,0x86,0x60,0xD1,0x37,0x55,0x27,0x68,0x6F,0x8D,0x3F,0xB1,0x77,0x12,0xEE,0x95,0x96,0xF9,0x51,0x64,0x15,0x2A,0xA9,0x8F,0x97,0x82,0x26,0x9E,0x1C,0x83,0xE8,0x20,0xA8,0x1B,0x5B,0x63,0x57,0x30,0xF1,0xAF,0x81,0xBC,0x78,0x9C,0x73,0x79,0x08,0xDC,0xD2,0x8A,0xB8,0xC1,0x6D,0xD7,0x2E,0x93,0xAC,0xF3,0x9F,0x1A,0xE1,0xEA,0xBF,0xA2,0xAD,0x2F,0x6E,0x5C,0x48,0xB3,0x87,0x69,0x2C,0xA7,0xCD,0xC9,0xFB,0x7F,0x7E,0x0E,0x91,0x31,0x88,0x10,0xF8,0x5A,0x6A,0xE3,0x8B,0xDE,0xEB,0xE5,0x89,0xAB,0xF0,0x47,0xB9,0x46,0x25,0xC2,0x22,0x03,0x76,0xE0,0xA6,0xF8,0x00,0x67,0x0C,0x34,0xFF,0x4C,0xBF,0x77,0x4B,0xD4,0x71,0xC9,0xC0,0xD5,0xFE,0xD8,0x42,0x7D,0x06,0x33,0xC1,0xAE,0xB9,0xC2,0x20,0x45,0x68,0xE6,0x38,0xDA,0x70,0x3F,0x60,0x02,0x37,0x86,0x1B,0xD1,0x91,0xE7,0x40,0x6A,0x97,0x04,0x1E,0x6D,0x12,0x7F,0x08,0xFD,0x44,0x8F,0xB8,0xF3,0x23,0x87,0xDB,0x7A,0x65,0x35,0xD2,0xE2,0x21,0xB7,0x75,0x54,0x72,0x95,0xEE,0x11,0xA7,0x10,0xDE,0xFC,0xBC,0xB2,0xDC,0x89,0x3D,0xB4,0xAF,0x0D,0xDF,0x47,0xC6,0x66,0x29,0x59,0xAC,0x28,0x9A,0x9E,0x7B,0xF0,0xD0,0x3E,0x1F,0xE4,0x39,0x0B,0xFA,0x78,0xE8,0xF5,0xB6,0xBD,0xC8,0x4D,0xFB,0xA4,0x79,0xC4,0x3A,0x80,0xEF,0x96,0x85,0xDD,0x5F,0x8B,0x24,0x2E,0x2F,0xCB,0xD6,0xEB,0x30,0xCA,0xA8,0x64,0xAA,0xF7,0x5C,0xA5,0xA0,0xF6,0x7C,0xD3,0xE9,0x74,0xE5,0x7E,0x07,0x41,0x09,0x36,0x2D,0x55,0x17,0x15,0xA2,0xEC,0x8C,0x32,0x57,0x92,0x5A,0x69,0x3C,0xCD,0x73,0xEA,0xE3,0x82,0x56,0x0A,0x14,0x50,0xF9,0x16,0x98,0xF4,0x27,0xE1,0x88,0x52,0x2C,0x03,0xF1,0x31,0xAD,0xD9,0xCC,0x1C,0xBE,0x4A,0x49,0x61,0xBB,0x0E,0xB1,0x1A,0x0F,0xD7,0x8D,0x6E,0xB5,0x2B,0x05,0x48,0x94,0x62,0x19,0x8E,0x9F,0x4E,0x6F,0x8A,0xA3,0x99,0x25,0x26,0xAB,0x53,0x51,0x83,0x1D,0x76,0x93,0x2A,0x9B,0x46,0xED,0xBA,0xCE,0x84,0xE0,0xC5,0x5D,0x6C,0xCF,0xB0,0x5B,0x3B,0xC3,0x18,0x90,0xB3,0xA9,0x01,0xF2,0x13,0x81,0x6B,0x9C,0xC7,0x22,0x5E,0x63,0xA1,0x43,0x9D,0x58,0x4F,0x2B,0x7B,0xFC,0xCC,0x99,0x3D,0x64,0xC5,0x91,0x5A,0xED,0xA6,0x61,0x0C,0xE3,0x16,0x1A,0x89,0x73,0x00,0xF9,0x8F,0x74,0x5E,0x98,0x29,0xCF,0x05,0x21,0x6E,0x1C,0x7E,0xF8,0x76,0xC4,0x26,0xDC,0xA7,0x5B,0x3E,0x2D,0x18,0xB0,0xDF,0xC6,0xE0,0x63,0x5C,0xD7,0x6F,0xCB,0xDE,0x69,0xA1,0xCA,0x55,0x2A,0x12,0x52,0xE1,0xE6,0xB8,0x79,0x1E,0xD5,0x31,0xF5,0xC8,0x95,0x41,0x30,0x3A,0x88,0xF1,0xC3,0x9B,0xDA,0x67,0x9E,0x24,0x53,0xD6,0xBA,0xE5,0xEB,0xF6,0xA3,0xA8,0x15,0x27,0x66,0xE4,0x20,0xCE,0xFA,0x01,0x80,0x84,0xEE,0x65,0x47,0x37,0x36,0xB2,0x59,0xC1,0x78,0xD8,0xAA,0x23,0x13,0xB1,0xAC,0xA2,0x97,0xC2,0x0E,0xB9,0xE2,0xC0,0x8B,0x6C,0x0F,0xF0,0xA9,0x3F,0x4A,0x6B,0x7F,0x57,0x10,0xA5,0x02,0xD2,0x54,0xA0,0x2F,0xEF,0xC7,0xB3,0x4C,0x96,0x1D,0x32,0xEA,0x86,0xFF,0x39,0x4E,0x0A,0x08,0xE7,0x9C,0xFD,0x14,0x48,0xD3,0x22,0xF4,0x6D,0x8C,0x49,0x77,0x44,0xF2,0xBC,0x2C,0x92,0x4B,0x33,0x0B,0x09,0x5F,0x19,0x28,0x17,0x6A,0xF7,0x60,0xFB,0xE8,0xBE,0xCD,0x62,0xE9,0xB4,0xBB,0x42,0xD4,0x2E,0x7A,0xB6,0x83,0x5D,0x51,0x46,0x40,0x3C,0xBF,0x7D,0x75,0x9F,0xD9,0x82,0x1F,0xB7,0x0D,0xEC,0x06,0xDD,0xAD,0x8E,0xAE,0xD1,0x25,0x45,0xDB,0xFE,0x72,0x43,0xA4,0xF3,0x9A,0xD0,0x34,0x8D,0x58,0x85,0x9D,0x4F,0x68,0x03,0x38,0x3B,0x4D,0xB5,0x94,0x71,0x87,0xBD,0x90,0x07,0x50,0x81,0x56,0x1B,0x7C,0x8A,0x70,0x93,0x35,0xAB,0x04,0xAF,0xC9,0x11,0xB5,0x3E,0xDB,0xDF,0x6D,0xE9,0x1C,0x6C,0x23,0x83,0x02,0x9A,0x48,0xEA,0xF1,0x78,0xCC,0x99,0xF7,0xF9,0xB9,0x9B,0x55,0xE2,0x54,0xAB,0xD0,0x37,0x11,0x30,0xF2,0x64,0xAE,0x93,0x8E,0x6A,0x6B,0x61,0xCE,0x1A,0x98,0xC0,0xD3,0xAA,0xC5,0x7F,0x81,0x3C,0xE1,0xBE,0x08,0x8D,0xF8,0xF3,0xB0,0xAD,0x3D,0xBF,0x4E,0x7C,0xA1,0x5A,0x7B,0x95,0x9F,0x7D,0xA3,0x2D,0x00,0x65,0x87,0xFC,0xEB,0x84,0x76,0x43,0x38,0x07,0x9D,0xBB,0x90,0x85,0x8C,0x34,0x91,0x0E,0x32,0xFA,0x09,0xBA,0x71,0x49,0x22,0x45,0xBD,0xE3,0xA7,0x97,0x70,0x20,0x3F,0x9E,0xC2,0x66,0xB6,0xFD,0xCA,0x01,0xB8,0x4D,0x3A,0x57,0x28,0x5B,0x41,0xD2,0x2F,0x05,0xA2,0xD4,0x94,0x5E,0xC3,0x72,0x47,0x25,0x7A,0x35,0x03,0xDE,0x6F,0xD6,0x33,0x58,0xC6,0x14,0x16,0xEE,0x63,0x60,0xDC,0xE6,0xCF,0x2A,0x0B,0xDA,0xCB,0x5C,0x27,0xD1,0x0D,0x40,0x6E,0xF0,0x2B,0xC8,0x92,0x4A,0x5F,0xF4,0x0A,0x1D,0xD8,0x06,0xE4,0x26,0x1B,0x67,0x82,0xD9,0x2E,0xC4,0x56,0xB7,0x44,0xEC,0xF6,0xD5,0x5D,0x86,0x7E,0x1E,0xF5,0x8A,0x29,0x18,0x80,0xA5,0xC1,0x8B,0xFF,0xA8,0x2C,0x1F,0xD7,0x12,0x77,0xC9,0xA9,0xE7,0x50,0x52,0x10,0x68,0x73,0x4C,0x04,0x42,0x3B,0xA0,0x31,0xAC,0x96,0x39,0xB3,0xE5,0xE0,0x19,0xB2,0xEF,0x21,0xED,0x8F,0x75,0x4B,0xFE,0x24,0x0C,0x0F,0xFB,0x59,0x89,0x9C,0xE8,0x74,0xB4,0x46,0x69,0x17,0xCD,0xA4,0x62,0xB1,0xDD,0x53,0xBC,0x15,0x51,0x4F,0x13,0xC7,0xA6,0xAF,0x36,0x88,0x79,0x19,0x36,0xBD,0x67,0x98,0xEC,0xC4,0x04,0x8B,0x7F,0xF9,0x29,0x8E,0x3B,0x7C,0x54,0x46,0xDF,0x09,0xF8,0x63,0x3F,0xD6,0xB7,0xCC,0x23,0x21,0x65,0x12,0xD4,0xAD,0xC1,0x3C,0x03,0x32,0x74,0x22,0x20,0x18,0x60,0xB9,0x07,0x97,0xD9,0x6F,0x5C,0x62,0xA7,0x9D,0x51,0x05,0xFF,0x69,0x90,0x9F,0xC2,0x49,0xE6,0x95,0xC3,0xD0,0x4B,0xDC,0x41,0xC7,0x26,0x9C,0x34,0xA9,0xF2,0xB4,0x5E,0x56,0x94,0x17,0x6B,0x6D,0x7A,0x76,0xA8,0xFB,0xB1,0xD8,0x8F,0x68,0x59,0xD5,0xF0,0x6E,0x0E,0xFA,0x85,0xA5,0x86,0xF6,0x2D,0x96,0xAC,0x5A,0xBF,0x9E,0x66,0x10,0x13,0x28,0x43,0x64,0xB6,0xAE,0x73,0xA6,0x1F,0x3A,0xE2,0x84,0x2F,0x80,0x1E,0xB8,0x5B,0xA1,0x57,0x30,0x7D,0xAA,0x7B,0x2C,0xBB,0x3D,0xC8,0x27,0x4A,0x8D,0xC6,0x71,0xBA,0xEE,0x4F,0x16,0xB2,0xE7,0xD7,0x50,0x00,0x55,0x37,0x45,0x0A,0x2E,0xE4,0x02,0xB3,0x75,0x5F,0xA4,0xD2,0x2B,0x58,0xA2,0x31,0x77,0x48,0xCB,0xED,0xF4,0x9B,0x33,0x06,0x15,0x70,0x8C,0xF7,0x0D,0xEF,0x5D,0xD3,0x35,0x52,0x93,0xCD,0xCA,0x79,0x39,0x01,0x7E,0xE1,0x8A,0x42,0xF5,0xE0,0x44,0xFC,0x0F,0xB5,0x4C,0xF1,0xB0,0xE8,0xDA,0xA3,0x11,0x1B,0x6A,0xBE,0xE3,0xDE,0x1A,0xFE,0x2A,0xD1,0xE5,0x0B,0xCF,0x4D,0x0C,0x3E,0x83,0x88,0xDD,0xC0,0xCE,0x91,0xFD,0x78,0x9A,0x38,0x08,0x81,0xF3,0x53,0xEA,0x72,0x99,0x1D,0x1C,0x6C,0x4E,0xC5,0xAF,0xAB,0x40,0x61,0x14,0x82,0xDB,0x24,0x47,0xA0,0xEB,0xC9,0x92,0x25,0xE9,0xBC,0x89,0x87,0x97,0x46,0x57,0xC0,0xBB,0x4D,0x91,0xDC,0xF2,0x6C,0xB7,0x54,0x0E,0xD6,0xC3,0x68,0x9F,0x42,0xF3,0x4A,0xAF,0xC4,0x5A,0x88,0x8A,0x72,0xFF,0xFC,0x40,0x7A,0x53,0xB6,0x6A,0x49,0xC1,0x1A,0xE2,0x82,0x69,0x16,0xB5,0x84,0x1C,0x39,0x5D,0x17,0x63,0x34,0x96,0x81,0x44,0x9A,0x78,0xBA,0x87,0xFB,0x1E,0x45,0xB2,0x58,0xCA,0x2B,0xD8,0x70,0xA7,0x3C,0xAD,0x30,0x0A,0xA5,0x2F,0x79,0x7C,0x85,0x2E,0x73,0xBD,0x71,0x13,0xE9,0xB0,0x83,0x4B,0x8E,0xEB,0x55,0x35,0x7B,0xCC,0xCE,0x8C,0xF4,0xEF,0xD0,0x98,0xDE,0x38,0xFE,0x2D,0x41,0xCF,0x20,0x89,0xCD,0xD3,0x8F,0x5B,0x3A,0x33,0xAA,0x14,0xE5,0xD7,0x62,0xB8,0x90,0x93,0x67,0xC5,0x15,0x00,0x74,0xE8,0x28,0xDA,0xF5,0x8B,0x51,0x50,0x05,0x6B,0x65,0x25,0x07,0xC9,0x7E,0xC8,0x37,0x4C,0xAB,0x8D,0xAC,0x6E,0xF8,0x29,0xA2,0x47,0x43,0xF1,0x75,0x80,0xF0,0xBF,0x1F,0x9E,0x06,0xD4,0x76,0x6D,0xE4,0x7D,0x22,0x94,0x11,0x64,0x6F,0x2C,0x31,0xA1,0x23,0xD2,0xE0,0x3D,0xC6,0xE7,0x09,0x32,0x0F,0x12,0xF6,0xF7,0xFD,0x52,0x86,0x04,0x5C,0x4F,0x36,0x59,0xE3,0x1D,0xA0,0x0C,0x19,0x10,0xA8,0x0D,0x92,0xAE,0x66,0x95,0x26,0xED,0xD5,0xBE,0xD9,0x21,0x7F,0x03,0xE1,0x3F,0xB1,0x9C,0xF9,0x1B,0x60,0x77,0x18,0xEA,0xDF,0xA4,0x9B,0x01,0x27,0xB4,0xC7,0xDD,0x4E,0xB3,0x99,0x3E,0x48,0x08,0xC2,0x5F,0xEE,0xDB,0xB9,0xE6,0xA9,0x3B,0x0B,0xEC,0xBC,0xA3,0x02,0x5E,0xFA,0x2A,0x61,0x56,0x9D,0x24,0xD1,0xA6,0xCB,0x7B,0xED,0x98,0xB9,0x59,0xBE,0xDD,0x22,0xDC,0x6B,0x30,0x12,0x7E,0x70,0x45,0x10,0x78,0xF1,0xC1,0x63,0x8B,0x13,0xAA,0x0A,0x95,0xE5,0xE4,0x60,0x52,0x56,0x3C,0xB7,0xF2,0x1C,0x28,0xD3,0xC7,0xF5,0xB4,0x36,0x39,0x24,0x71,0x7A,0x81,0x04,0x68,0x37,0x08,0xB5,0x4C,0xF6,0x5A,0x23,0x11,0x49,0x47,0x93,0xE2,0xE8,0x07,0xE3,0x27,0x1A,0x34,0x6A,0xAB,0xCC,0xF8,0xC0,0x80,0x33,0xBB,0x73,0x18,0x87,0x05,0xBD,0x19,0x0C,0x14,0x32,0xB1,0x8E,0xFF,0xCA,0x62,0x0D,0x0E,0x75,0x89,0xEC,0x2A,0xA4,0x16,0xF4,0xF3,0xBC,0xCE,0xAC,0x4A,0xFB,0x1D,0xD7,0x2B,0x5D,0xA6,0x8C,0xC8,0x5B,0xA1,0xD2,0xB3,0xDE,0x31,0xC4,0x43,0x88,0x3F,0x74,0x4B,0xEF,0xB6,0x17,0xF9,0xA9,0x2E,0x1E,0xD6,0x7D,0x1B,0xC3,0xA2,0x41,0xE7,0x79,0x84,0xC9,0xAE,0x58,0x42,0xD5,0x82,0x53,0x46,0xA3,0x55,0x6F,0xEA,0xE9,0x9F,0x67,0x4F,0x9D,0xBA,0xD1,0xE6,0x5F,0x8A,0x57,0x76,0x21,0x48,0x02,0x09,0x2C,0xA0,0x91,0x7C,0x03,0xF7,0x97,0xD4,0x0F,0x7F,0x5C,0xCD,0x65,0xDF,0x3E,0xA7,0x4D,0x0B,0x50,0x92,0xEE,0x6D,0xAF,0x51,0x8F,0x83,0x94,0x06,0xFC,0xA8,0x64,0x3B,0x66,0x69,0x90,0x3A,0x6C,0x1F,0xB0,0xB8,0x25,0xB2,0x29,0x8D,0xCB,0xFA,0xC5,0x99,0xE1,0xD9,0xDB,0x20,0x6E,0xFE,0x40,0x5E,0x9B,0xA5,0x96,0x01,0xF0,0x26,0xBF,0x4E,0x2F,0xC6,0x9A,0x9C,0xD8,0xDA,0x35,0x38,0x54,0x2D,0xEB,0x9E,0x44,0xCF,0xE0,0xFD,0x3D,0x15,0x61,0xD0,0x00,0x86,0x72,0xAD,0x85,0xC2,0x77,0x69,0x5C,0xF4,0x9B,0x82,0xA4,0x27,0x18,0xBC,0x32,0x80,0x62,0x98,0xE3,0x1F,0x7A,0x6E,0x56,0x16,0xA5,0xA2,0xFC,0x3D,0x5A,0x93,0x2B,0x8F,0x9A,0x2D,0xE5,0x8E,0x11,0xD5,0x1E,0xA9,0xE2,0x25,0x48,0xA7,0x52,0x6F,0x3F,0xB8,0x88,0xDD,0x79,0x20,0x81,0xDC,0x6D,0x8B,0x41,0x65,0x2A,0x58,0x3A,0x5E,0xCD,0x37,0x44,0xBD,0xCB,0x30,0x1A,0x1D,0x85,0x3C,0x9C,0xEE,0x67,0x57,0xF5,0xC4,0xC0,0xAA,0x21,0x03,0x73,0x72,0xF6,0xCF,0x28,0x4B,0xB4,0xED,0x7B,0x0E,0x2F,0xE8,0xE6,0xD3,0x86,0x4A,0xFD,0xA6,0x84,0xCC,0xB5,0x87,0xDF,0x9E,0x23,0xDA,0x60,0x91,0x75,0xB1,0x8C,0xD1,0x05,0x74,0x7E,0x51,0x63,0x22,0xA0,0x64,0x8A,0xBE,0x45,0x17,0x92,0xFE,0xA1,0xAF,0xB2,0xE7,0xEC,0x0F,0x77,0x4F,0x4D,0x1B,0x5D,0x6C,0x53,0xC8,0x0D,0x33,0x00,0xB6,0xF8,0x68,0xD6,0xAD,0xF0,0xFF,0x06,0x90,0x6A,0x3E,0xF2,0x2E,0xB3,0x24,0xBF,0xAC,0xFA,0x89,0x26,0x6B,0xAB,0x83,0xF7,0x08,0xD2,0x59,0x76,0x3B,0x13,0x54,0xE1,0x46,0x96,0x10,0xE4,0xD8,0xB9,0x50,0x0C,0x97,0x66,0xB0,0x29,0xAE,0xC2,0xBB,0x7D,0x0A,0x4E,0x4C,0xA3,0x7C,0x7F,0x09,0xF1,0xD0,0x35,0xC3,0xF9,0x70,0xC9,0x1C,0xC1,0xD9,0x0B,0x2C,0x47,0x34,0xD7,0x71,0xEF,0x40,0xEB,0x8D,0x55,0xD4,0x43,0x14,0xC5,0x12,0x5F,0x38,0xCE,0x31,0xDB,0x9D,0xC6,0x5B,0xF3,0x49,0xA8,0xC7,0x19,0x15,0x02,0x04,0x78,0xFB,0x39,0x9F,0xBA,0x36,0x07,0xE0,0xB7,0xDE,0x94,0x42,0x99,0xE9,0xCA,0xEA,0x95,0x61,0x01,0x76,0xB4,0x37,0x4B,0x4D,0x5A,0x56,0x88,0xE7,0x06,0xBC,0x14,0x89,0xD2,0x94,0x7E,0x4E,0x2E,0xDA,0xA5,0x85,0xA6,0xD6,0x0D,0xDB,0x91,0xF8,0xAF,0x48,0x79,0xF5,0xD0,0x08,0x63,0x44,0x96,0x8E,0x53,0x86,0x3F,0xB6,0x8C,0x7A,0x9F,0xBE,0x46,0x30,0x33,0x81,0x77,0x10,0x5D,0x8A,0x5B,0x0C,0x9B,0x1A,0xC2,0xA4,0x0F,0xA0,0x3E,0x98,0x7B,0xAB,0x5F,0xD9,0x09,0xAE,0x1B,0x5C,0x74,0x39,0x16,0x9D,0x47,0xB8,0xCC,0xE4,0x24,0xEC,0x03,0x01,0x45,0x32,0xF4,0x8D,0xE1,0x66,0xFF,0x29,0xD8,0x43,0x1F,0xF6,0x97,0x99,0x27,0xB7,0xF9,0x4F,0x7C,0x42,0x87,0x1C,0x23,0x12,0x54,0x02,0x00,0x38,0x40,0x69,0xC6,0xB5,0xE3,0xF0,0x6B,0xFC,0x61,0xBD,0x71,0x25,0xDF,0x49,0xB0,0xBF,0xE2,0x31,0x3B,0x4A,0x9E,0xC3,0xFE,0x3A,0xDE,0x2F,0x95,0x6C,0xD1,0x90,0xC8,0xFA,0x83,0xA3,0xA8,0xFD,0xE0,0xEE,0xB1,0xDD,0x58,0x0A,0xF1,0xC5,0x2B,0xEF,0x6D,0x2C,0x1E,0xB9,0x3D,0x3C,0x4C,0x6E,0xE5,0x8F,0x8B,0xBA,0x18,0x28,0xA1,0xD3,0x73,0xCA,0x52,0xCB,0xE9,0xB2,0x05,0xC9,0x9C,0xA9,0xA7,0x60,0x41,0x34,0xA2,0xFB,0x04,0x67,0x80,0xCE,0x6F,0x36,0x92,0xC7,0xF7,0x70,0x20,0x1D,0xE8,0x07,0x6A,0xAD,0xE6,0x51,0x9A,0x55,0x7F,0x84,0xF2,0x0B,0x78,0x82,0x11,0x75,0x17,0x65,0x2A,0x0E,0xC4,0x22,0x93,0x35,0x50,0xAC,0xD7,0x2D,0xCF,0x7D,0xF3,0x57,0x68,0xEB,0xCD,0xD4,0xBB,0x13,0x26,0x5E,0xC1,0xAA,0x62,0xD5,0xC0,0x64,0xDC,0x15,0x72,0xB3,0xED,0xEA,0x59,0x19,0x21,0x4C,0x42,0x2C,0x79,0x57,0xE0,0x2E,0x0C,0x82,0x65,0x1E,0xE1,0xD1,0x47,0x85,0xA4,0x6A,0x6E,0x8B,0x00,0xD9,0xA9,0x5C,0xD8,0x2F,0xB7,0x36,0x96,0xCD,0x44,0x5F,0xFD,0x38,0xBD,0x0B,0x54,0x18,0x05,0x46,0x4D,0xC9,0xFB,0x0A,0x88,0x20,0xCE,0xEF,0x14,0xDF,0x3B,0x26,0x1B,0xAF,0x7B,0xD4,0xDE,0x1F,0x66,0x75,0x2D,0x89,0x34,0xCA,0x70,0x81,0x39,0x30,0x25,0x4F,0x87,0xBB,0x24,0xFC,0xC4,0x0F,0xBC,0x56,0x08,0xF0,0x97,0x98,0x16,0xC8,0x2A,0x49,0x32,0xD0,0xB5,0xF6,0xC3,0x31,0x5E,0x0E,0x28,0xB2,0x8D,0x67,0xF4,0xEE,0x9D,0x61,0x17,0xB0,0x9A,0xC7,0x76,0xEB,0x21,0x80,0xCF,0x90,0xF2,0x95,0xC5,0x22,0x12,0xD3,0x77,0x2B,0x8A,0xB4,0x7F,0x48,0x03,0xE2,0x8F,0xF8,0x0D,0xE9,0x7E,0x6F,0xBE,0xF5,0xB8,0x64,0x92,0x7D,0x9E,0x45,0xDB,0x41,0xEA,0xFF,0x27,0x63,0xDA,0x6B,0xB6,0xA1,0x73,0xED,0x86,0xD5,0xD6,0x5B,0xA3,0x9F,0x7A,0x53,0x69,0x33,0xE8,0x60,0x43,0x3F,0x40,0xAB,0xCB,0x10,0x35,0xAD,0x9C,0x1D,0x4A,0x3E,0x74,0xB3,0x6D,0xA8,0xBF,0xD2,0xAE,0x93,0x51,0x71,0x9B,0x6C,0x37,0x59,0xF1,0x02,0xE3,0x19,0x84,0x15,0x8E,0x50,0x06,0x8C,0x23,0x5A,0x07,0xAC,0x55,0xC0,0x3A,0x58,0x94,0xA7,0x62,0xAA,0x99,0x52,0x1C,0x7C,0xC2,0xDD,0xA5,0xE7,0xE5,0xF7,0xB1,0xF9,0xC6,0x68,0x04,0xD7,0x11,0xE4,0xA0,0x09,0xE6,0x13,0x72,0xA6,0xFA,0xCC,0x3D,0x83,0x1A,0xB9,0x91,0x4B,0xFE,0x3C,0xEC,0x4E,0xBA,0x01,0xC1,0x5D,0x29,0x78,0xA2,0xDC,0xF3,0xBB,0x83,0x48,0xFB,0x11,0x4F,0xB7,0xD0,0xC6,0x7E,0x77,0x62,0x08,0xC0,0xFC,0x63,0xB1,0x84,0x76,0x19,0x49,0x6F,0xF5,0xCA,0xDF,0x51,0x8F,0x6D,0x0E,0x75,0x97,0xF2,0x80,0x31,0xAC,0x66,0xC7,0x88,0xD7,0xB5,0x20,0xB3,0xA9,0xDA,0x26,0x50,0xF7,0xDD,0xF3,0x38,0x0F,0x44,0xA5,0xC8,0xBF,0x4A,0xD2,0x82,0x65,0x55,0x94,0x30,0x6C,0xCD,0xC5,0x22,0x59,0xA6,0x96,0x00,0xC2,0xE3,0x0B,0x05,0x6B,0x3E,0x10,0xA7,0x69,0x4B,0x68,0xF0,0x71,0xD1,0x8A,0x03,0x18,0xBA,0x2D,0x29,0xCC,0x47,0x9E,0xEE,0x1B,0x9F,0x8E,0xBC,0x4D,0xCF,0x67,0x89,0xA8,0x53,0x7F,0xFA,0x4C,0x13,0x5F,0x42,0x01,0x0A,0x58,0x21,0x32,0x6A,0xCE,0x73,0x8D,0x37,0x98,0x7C,0x61,0x5C,0xE8,0x3C,0x93,0x99,0x1D,0x40,0xEB,0x12,0x87,0x7D,0x1F,0xD3,0x5E,0xC3,0x52,0xC9,0x17,0x41,0xCB,0x64,0x9A,0xE2,0xA0,0xA2,0xB0,0xF6,0xBE,0x81,0xE0,0x25,0xED,0xDE,0x15,0x5B,0x3B,0x85,0x54,0x35,0xE1,0xBD,0x8B,0x7A,0xC4,0x5D,0x2F,0x43,0x90,0x56,0xA3,0xE7,0x4E,0xA1,0x46,0x86,0x1A,0x6E,0x3F,0xE5,0x9B,0xB4,0xFE,0xD6,0x0C,0xB9,0x7B,0xAB,0x09,0xFD,0x3A,0xD9,0x02,0x9C,0x06,0xAD,0xB8,0x60,0xAE,0x39,0x28,0xF9,0xB2,0xFF,0x23,0xD5,0x92,0x91,0x1C,0xE4,0xD8,0x3D,0x14,0x2E,0x24,0x9D,0x2C,0xF1,0xE6,0x34,0xAA,0xC1,0x57,0x72,0xEA,0xDB,0x5A,0x0D,0x79,0x33,0x74,0xAF,0x27,0x04,0x78,0x07,0xEC,0x8C,0x36,0xDC,0x2B,0x70,0x1E,0xB6,0x45,0xA4,0xF4,0x2A,0xEF,0xF8,0x95,0xE9,0xD4,0x16,0x13,0xB1,0x81,0x08,0x7A,0xDA,0x63,0xFB,0x10,0x94,0x95,0xE5,0xC7,0x4C,0x26,0x22,0xC9,0xE8,0x9D,0x0B,0x52,0xAD,0xCE,0x29,0x62,0x40,0x1B,0xAC,0x60,0x35,0x00,0x0E,0x86,0x3C,0xC5,0x78,0x39,0x61,0x53,0x2A,0x98,0x92,0xE3,0x37,0x6A,0x57,0x93,0x77,0xA3,0x58,0x6C,0x82,0x46,0xC4,0x85,0xB7,0x0A,0x01,0x54,0x49,0x47,0x18,0x74,0xF1,0xFE,0xC1,0x42,0x64,0x7D,0x12,0xBA,0x8F,0x9C,0xF9,0x05,0x7E,0x84,0x66,0xD4,0x5A,0xBC,0xDB,0x1A,0x44,0x43,0xF0,0xB0,0x88,0xF7,0x68,0x03,0xCB,0x7C,0x69,0xCD,0x75,0xB4,0x41,0xAE,0xC3,0x04,0x4F,0xF8,0x33,0x67,0xC6,0x9F,0x3B,0x6E,0x5E,0xD9,0x89,0xDC,0xBE,0xCC,0x83,0xA7,0x6D,0x8B,0x3A,0xFC,0xD6,0x2D,0x5B,0xA2,0xD1,0x2B,0xB8,0x1F,0x25,0xD3,0x36,0x17,0xEF,0x99,0x9A,0xA1,0xCA,0xED,0x3F,0x27,0xFA,0x2F,0x96,0xB3,0x6B,0x0D,0xA6,0x09,0x97,0x31,0xD2,0x28,0xDE,0xB9,0xF4,0x23,0xF2,0xA5,0x32,0x4E,0xAF,0x15,0xBD,0x20,0x7B,0x3D,0xD7,0xDF,0x1D,0x9E,0xE2,0xE4,0xF3,0xFF,0x21,0x72,0x38,0x51,0x06,0xE1,0xD0,0x5C,0x79,0xE7,0x87,0x73,0x0C,0x2C,0x0F,0x7F,0xA4,0xB5,0x8A,0xBB,0xFD,0xAB,0xA9,0x91,0xE9,0x30,0x8E,0x1E,0x50,0xE6,0xD5,0xEB,0x2E,0x14,0xD8,0x8C,0x76,0xE0,0x19,0x16,0x4B,0xC0,0x6F,0x1C,0x4A,0x59,0xC2,0x55,0xC8,0x90,0xBF,0x34,0xEE,0x11,0x65,0x4D,0x8D,0x02,0xF6,0x70,0xA0,0x07,0xB2,0xF5,0xDD,0xCF,0x56,0x80,0x71,0xEA,0xB6,0x5F,0x3E,0x45,0xAA,0xA8,0xEC,0x9B,0x5D,0x24,0x48,0x1B,0x44,0xF2,0x77,0x02,0x09,0x4A,0x57,0xC7,0x45,0xB4,0x86,0x5B,0xA0,0x81,0x6F,0x54,0x69,0x74,0x90,0x91,0x9B,0x34,0xE0,0x62,0x3A,0x29,0x50,0x3F,0x85,0x7B,0xC6,0x36,0x63,0x0D,0x03,0x43,0x61,0xAF,0x18,0xAE,0x51,0x2A,0xCD,0xEB,0xCA,0x08,0x9E,0x4F,0xC4,0x21,0x25,0x97,0x13,0xE6,0x96,0xD9,0x79,0xF8,0x60,0xB2,0x10,0x0B,0x82,0xD2,0xA1,0xBB,0x28,0xD5,0xFF,0x58,0x2E,0x6E,0xA4,0x39,0x88,0xBD,0xDF,0x80,0xCF,0x5D,0x6D,0x8A,0xDA,0xC5,0x64,0x38,0x9C,0x4C,0x07,0x30,0xFB,0x42,0xB7,0xC0,0xAD,0x6A,0x7F,0x76,0xCE,0x6B,0xF4,0xC8,0x00,0xF3,0x40,0x8B,0xB3,0xD8,0xBF,0x47,0x19,0x65,0x87,0x59,0xD7,0xFA,0x9F,0x7D,0x06,0x11,0x7E,0x8C,0xB9,0xC2,0xFD,0x67,0x41,0x0C,0x2F,0xA7,0x7C,0x84,0xE4,0x0F,0x70,0xD3,0xE2,0x7A,0x5F,0x3B,0x71,0x05,0x52,0xF0,0xE7,0x22,0xFC,0x1E,0xDC,0xE1,0x9D,0x78,0x23,0xD4,0x3E,0xAC,0x4D,0xBE,0x16,0xF1,0x20,0x31,0xA6,0xDD,0x2B,0xF7,0xBA,0x94,0x0A,0xD1,0x32,0x68,0xB0,0xA5,0x0E,0xF9,0x24,0x95,0x2C,0xC9,0xA2,0x3C,0xEE,0xEC,0x14,0x99,0x9A,0x26,0x1C,0x35,0xD0,0x5E,0x98,0x4B,0x27,0xA9,0x46,0xEF,0xAB,0xB5,0xE9,0x3D,0x5C,0x55,0xCC,0x72,0x83,0xB1,0x04,0xDE,0xF6,0xF5,0x01,0xA3,0x73,0x66,0x12,0x8E,0x4E,0xBC,0x93,0xED,0x37,0xC1,0x5A,0xCB,0x56,0x6C,0xC3,0x49,0x1F,0x1A,0xE3,0x48,0x15,0xDB,0x17,0x75,0x8F,0xD6,0xE5,0x2D,0xE8,0x8D,0x33,0x53,0x1D,0xAA,0xA8,0xEA,0x92,0x89,0xB6,0xFE,0xB8,0x50,0x75,0xEB,0x00,0x80,0xFF,0xFF,0xFF,0x80,0x75,0xEB,0x00,0x80,0xFF,0xFF,0xFF,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xA0,0xEF,0x26,0x00,0x80,0xFF,0xFF,0xFF + +.section .text + .global sub_ffffff8000ec7320 + + .align 0x10 + jumptbl_0xffffff8000ebb693: + .long _0xffffff8000ebdd4b - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebbe75 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebba50 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebc958 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebb977 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebbab0 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebb772 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebbc46 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebba9f - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebbae2 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebe32a - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebc047 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebdda6 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebbd40 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebba9f - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebc598 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebe3ae - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebd3b3 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebb9a1 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebb962 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebd024 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebc159 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebb94f - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebb880 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebe385 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebdaab - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebb6c8 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebd36f - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebbfc1 - jumptbl_0xffffff8000ebb693 + .long _0xffffff8000ebbabc - jumptbl_0xffffff8000ebb693 + + .align 0x10 + jumptbl_0xffffff8000ebb93c: + .long _0xffffff8000ebc6d4 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebc881 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebb8e1 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebd410 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebc800 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebba9f - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebc64d - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebd1f6 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebb98c - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebbb46 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebbbf4 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000eb7dd6 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebba9f - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebd4c6 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebe484 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebbbd1 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebc714 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebe426 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebd8ef - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebe400 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebcf9e - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebc2c0 - jumptbl_0xffffff8000ebb93c + .long _0xffffff8000ebc5be - jumptbl_0xffffff8000ebb93c + + .align 0x10 + jumptbl_0xffffff8000ebbad2: + .long _0xffffff8000ebba25 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebbec1 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000eb7dc1 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebba79 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebc9b0 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebb90a - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebbc90 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebd238 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebd52e - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebd64e - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebc676 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebca3e - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000eb90ff - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebb6f1 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebbdb8 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebbb73 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebcd4d - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebc0fa - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebb6ad - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebe494 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebbcda - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebd468 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebc829 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebd5e6 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebc782 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebc26e - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebe0a8 - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebb7bb - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebbd8a - jumptbl_0xffffff8000ebbad2 + .long _0xffffff8000ebc574 - jumptbl_0xffffff8000ebbad2 + + .align 0x10 + jumptbl_0xffffff8000ebe7fc: + .long _0xffffff8000ebf91d - jumptbl_0xffffff8000ebe7fc + .long _0xffffff8000ebf3e9 - jumptbl_0xffffff8000ebe7fc + .long _0xffffff8000ebe895 - jumptbl_0xffffff8000ebe7fc + .long _0xffffff8000ebf947 - jumptbl_0xffffff8000ebe7fc + .long _0xffffff8000ebfa81 - jumptbl_0xffffff8000ebe7fc + .long _0xffffff8000ebf99d - jumptbl_0xffffff8000ebe7fc + .long _0xffffff8000ebf790 - jumptbl_0xffffff8000ebe7fc + .long _0xffffff8000ebf615 - jumptbl_0xffffff8000ebe7fc + .long _0xffffff8000ebfb7e - jumptbl_0xffffff8000ebe7fc + .long _0xffffff8000ec6f5f - jumptbl_0xffffff8000ebe7fc + + .align 0x10 + jumptbl_0xffffff8000ebea20: + .long _0xffffff8000ebfbd4 - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebea76 - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebe950 - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ec6b6b - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebf52c - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebfbfa - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebe72a - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebeb3c - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebef84 - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebfafc - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ec6b1f - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ec70ac - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ec66e5 - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebf9de - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ec6a9b - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebf855 - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebf4c9 - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebf317 - jumptbl_0xffffff8000ebea20 + .long _0xffffff8000ebee14 - jumptbl_0xffffff8000ebea20 + + .align 0x10 + jumptbl_0xffffff8000ec6610: + .long _0xffffff8000ebf6bd - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebfc0c - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebf5d6 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebee6c - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebec64 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebed50 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebea30 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebeba2 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ec701b - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ec6620 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ec6ef4 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ec6a5c - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebe6d8 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebfa02 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebe855 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebfbc2 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebf47f - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ec66a1 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebeec1 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ec7145 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebe80c - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebef28 - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebee2b - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebea0e - jumptbl_0xffffff8000ec6610 + .long _0xffffff8000ebe6bb - jumptbl_0xffffff8000ec6610 + + .align 0x10 + jumptbl_0xffffff8000eb7cb9: + .long _0xffffff8000eb7cc2 - jumptbl_0xffffff8000eb7cb9 + .long _0xffffff8000eb7b30 - jumptbl_0xffffff8000eb7cb9 + .long _0xffffff8000eb76d6 - jumptbl_0xffffff8000eb7cb9 + .long _0xffffff8000eb7650 - jumptbl_0xffffff8000eb7cb9 + .long _0xffffff8000eb7850 - jumptbl_0xffffff8000eb7cb9 + .long _0xffffff8000eb783b - jumptbl_0xffffff8000eb7cb9 + + .align 0x10 + jumptbl_0xffffff8000ec755b: + .long _0xffffff8000ec756b - jumptbl_0xffffff8000ec755b + .long _0xffffff8000ec79e8 - jumptbl_0xffffff8000ec755b + .long _0xffffff8000ec7bfc - jumptbl_0xffffff8000ec755b + .long _0xffffff8000ec74ed - jumptbl_0xffffff8000ec755b + .long _0xffffff8000ec73d0 - jumptbl_0xffffff8000ec755b + + .align 0x100 + sub_0xffffff8000eb7d00: + _0xffffff8000eb7d00: push rbp + _0xffffff8000eb7d01: mov rbp, rsp + _0xffffff8000eb7d04: push r15 + _0xffffff8000eb7d06: push r14 + _0xffffff8000eb7d08: push r13 + _0xffffff8000eb7d0a: push r12 + _0xffffff8000eb7d0c: push rbx + _0xffffff8000eb7d0d: sub rsp, 0x358 + _0xffffff8000eb7d14: lea rcx, [rbp - 0x344] + _0xffffff8000eb7d1b: mov dword ptr [rbp - 0x344], ecx + _0xffffff8000eb7d21: lea rsi, [rbp - 0x348] + _0xffffff8000eb7d28: mov dword ptr [rbp - 0x348], esi + _0xffffff8000eb7d2e: lea rax, [rbp - 0x350] + _0xffffff8000eb7d35: mov qword ptr [rbp - 0x350], rax + _0xffffff8000eb7d3c: lea rax, [rbp - 0x358] + _0xffffff8000eb7d43: mov qword ptr [rbp - 0x358], rax + _0xffffff8000eb7d4a: mov dword ptr [rbp - 0x35c], 0x2a0d4638 + _0xffffff8000eb7d54: mov qword ptr [rbp - 0x350], rcx + _0xffffff8000eb7d5b: mov qword ptr [rbp - 0x358], rsi + _0xffffff8000eb7d62: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000eb7d68: lea edx, [rax + 0x26940251] + _0xffffff8000eb7d6e: mov r8, qword ptr [rbp - 0x350] + _0xffffff8000eb7d75: mov dword ptr [r8], edx + _0xffffff8000eb7d78: add eax, 0x2e11899 + _0xffffff8000eb7d7d: mov dword ptr [rbp - 0x348], eax + _0xffffff8000eb7d83: mov dword ptr [rbp - 0x35c], 0x50a14884 + _0xffffff8000eb7d8d: lea r8, [rip + jumptbl_0xffffff8000ebb93c] + _0xffffff8000eb7d94: movabs r9, 0x2fff5bfbe797ffbe + _0xffffff8000eb7d9e: movabs r10, 0x3fbfeedfc51bf7ef + _0xffffff8000eb7da8: movabs r11, 0x2fc5f9fede7adfe7 + _0xffffff8000eb7db2: movabs rbx, 0x5fffb76f9f77ff7f + _0xffffff8000eb7dbc: jmp _0xffffff8000ebbabc + _0xffffff8000eb7dc1: mov rcx, qword ptr [rbp - 0x128] + _0xffffff8000eb7dc8: mov eax, dword ptr [rbp - 0x1ec] + _0xffffff8000eb7dce: mov dword ptr [rcx + 4], eax + _0xffffff8000eb7dd1: jmp _0xffffff8000ebe494 + _0xffffff8000eb7dd6: mov esi, dword ptr [rdi + 4] + _0xffffff8000eb7dd9: mov eax, 3 + _0xffffff8000eb7dde: xor edx, edx + _0xffffff8000eb7de0: div esi + _0xffffff8000eb7de2: imul eax, edx, 0x416b0f2 + _0xffffff8000eb7de8: mov ecx, eax + _0xffffff8000eb7dea: imul ecx, ecx + _0xffffff8000eb7ded: mov r8d, eax + _0xffffff8000eb7df0: sub r8d, ecx + _0xffffff8000eb7df3: add eax, 0x2f89c8e4 + _0xffffff8000eb7df8: imul eax, eax + _0xffffff8000eb7dfb: lea ecx, [rax + r8 - 0x59ad0b10] + _0xffffff8000eb7e03: add eax, r8d + _0xffffff8000eb7e06: mov r8d, 0x59ad0b14 + _0xffffff8000eb7e0c: sub r8d, eax + _0xffffff8000eb7e0f: mov eax, ecx + _0xffffff8000eb7e11: and eax, r8d + _0xffffff8000eb7e14: xor r8d, ecx + _0xffffff8000eb7e17: lea eax, [r8 + rax*2] + _0xffffff8000eb7e1b: mov r8d, eax + _0xffffff8000eb7e1e: xor r8d, ecx + _0xffffff8000eb7e21: sar eax, 1 + _0xffffff8000eb7e23: sar ecx, 1 + _0xffffff8000eb7e25: add ecx, eax + _0xffffff8000eb7e27: sar r8d, 1 + _0xffffff8000eb7e2a: sub ecx, r8d + _0xffffff8000eb7e2d: or ecx, edx + _0xffffff8000eb7e2f: mov eax, 0x6eaf7a5a + _0xffffff8000eb7e34: sub eax, ecx + _0xffffff8000eb7e36: mov edx, eax + _0xffffff8000eb7e38: and edx, ecx + _0xffffff8000eb7e3a: xor eax, ecx + _0xffffff8000eb7e3c: lea r8d, [rax + rdx*2] + _0xffffff8000eb7e40: xor r8d, ecx + _0xffffff8000eb7e43: mov eax, 0xf1f8edae + _0xffffff8000eb7e48: sub eax, r8d + _0xffffff8000eb7e4b: mov ecx, eax + _0xffffff8000eb7e4d: and ecx, 0xa2854251 + _0xffffff8000eb7e53: mov edx, r8d + _0xffffff8000eb7e56: and edx, 0xa2854251 + _0xffffff8000eb7e5c: add edx, 0x450a84a2 + _0xffffff8000eb7e62: sub edx, ecx + _0xffffff8000eb7e64: and ecx, r8d + _0xffffff8000eb7e67: add ecx, ecx + _0xffffff8000eb7e69: and edx, 0xa2854251 + _0xffffff8000eb7e6f: add edx, ecx + _0xffffff8000eb7e71: mov ecx, eax + _0xffffff8000eb7e73: and ecx, 0x4952288a + _0xffffff8000eb7e79: mov r9d, r8d + _0xffffff8000eb7e7c: and r9d, 0x4952288a + _0xffffff8000eb7e83: add r9d, 0x12a45114 + _0xffffff8000eb7e8a: sub r9d, ecx + _0xffffff8000eb7e8d: and ecx, r8d + _0xffffff8000eb7e90: add ecx, ecx + _0xffffff8000eb7e92: and r9d, 0x4952288a + _0xffffff8000eb7e99: add r9d, ecx + _0xffffff8000eb7e9c: add r9d, edx + _0xffffff8000eb7e9f: mov ecx, r8d + _0xffffff8000eb7ea2: and ecx, 0x14289524 + _0xffffff8000eb7ea8: mov edx, eax + _0xffffff8000eb7eaa: and edx, ecx + _0xffffff8000eb7eac: add edx, edx + _0xffffff8000eb7eae: and eax, 0x14289524 + _0xffffff8000eb7eb3: add eax, 0x8512a48 + _0xffffff8000eb7eb8: sub eax, ecx + _0xffffff8000eb7eba: and eax, 0x14289524 + _0xffffff8000eb7ebf: add eax, edx + _0xffffff8000eb7ec1: add eax, r9d + _0xffffff8000eb7ec4: mov ecx, r8d + _0xffffff8000eb7ec7: and ecx, 0x8a49224a + _0xffffff8000eb7ecd: mov edx, eax + _0xffffff8000eb7ecf: and edx, ecx + _0xffffff8000eb7ed1: mov r9d, r8d + _0xffffff8000eb7ed4: and r9d, 0x24924921 + _0xffffff8000eb7edb: mov r10d, eax + _0xffffff8000eb7ede: and r10d, r9d + _0xffffff8000eb7ee1: add r10d, r10d + _0xffffff8000eb7ee4: mov r11d, eax + _0xffffff8000eb7ee7: and r11d, 0x24924921 + _0xffffff8000eb7eee: add r11d, 0x9249242 + _0xffffff8000eb7ef5: sub r11d, r9d + _0xffffff8000eb7ef8: and r11d, 0x24924921 + _0xffffff8000eb7eff: add r11d, r10d + _0xffffff8000eb7f02: add edx, edx + _0xffffff8000eb7f04: mov r9d, eax + _0xffffff8000eb7f07: and r9d, 0x8a49224a + _0xffffff8000eb7f0e: add r9d, 0x14924494 + _0xffffff8000eb7f15: sub r9d, ecx + _0xffffff8000eb7f18: and r9d, 0x8a49224a + _0xffffff8000eb7f1f: add r9d, edx + _0xffffff8000eb7f22: and eax, 0x51249494 + _0xffffff8000eb7f27: and r8d, 0x51249494 + _0xffffff8000eb7f2e: add r8d, eax + _0xffffff8000eb7f31: add r8d, r9d + _0xffffff8000eb7f34: add r8d, r11d + _0xffffff8000eb7f37: mov rcx, r8 + _0xffffff8000eb7f3a: xor rcx, 0xffffffff9f5797f8 + _0xffffff8000eb7f41: mov r9d, r8d + _0xffffff8000eb7f44: and r9d, 0x9f5797f8 + _0xffffff8000eb7f4b: lea rcx, [rcx + r9*2] + _0xffffff8000eb7f4f: movabs r9, 0xa92290a84a45424a + _0xffffff8000eb7f59: and r9, rcx + _0xffffff8000eb7f5c: movabs r10, 0x44892a42a4921521 + _0xffffff8000eb7f66: and r10, rcx + _0xffffff8000eb7f69: cmp r8d, 0x60a86808 + _0xffffff8000eb7f70: sbb r8, r8 + _0xffffff8000eb7f73: and r8d, 1 + _0xffffff8000eb7f77: shl r8, 0x20 + _0xffffff8000eb7f7b: add r8, r10 + _0xffffff8000eb7f7e: add r8, r9 + _0xffffff8000eb7f81: movabs r9, 0x125445151128a894 + _0xffffff8000eb7f8b: and r9, rcx + _0xffffff8000eb7f8e: add r9, r8 + _0xffffff8000eb7f91: mov r8, qword ptr [rdi + 8] + _0xffffff8000eb7f95: mov cl, byte ptr [r8 + r9] + _0xffffff8000eb7f99: mov al, cl + _0xffffff8000eb7f9b: and al, 0xf9 + _0xffffff8000eb7f9d: sub cl, al + _0xffffff8000eb7f9f: xor cl, al + _0xffffff8000eb7fa1: mov eax, 2 + _0xffffff8000eb7fa6: xor edx, edx + _0xffffff8000eb7fa8: div esi + _0xffffff8000eb7faa: mov eax, edx + _0xffffff8000eb7fac: xor eax, 0xa5b6f58f + _0xffffff8000eb7fb1: mov r9d, eax + _0xffffff8000eb7fb4: and r9d, 0x800009 + _0xffffff8000eb7fbb: and eax, 0x1122102 + _0xffffff8000eb7fc0: mov r10d, 0xf3e45264 + _0xffffff8000eb7fc6: sub r10d, eax + _0xffffff8000eb7fc9: and r10d, 0x51522922 + _0xffffff8000eb7fd0: add r10d, r9d + _0xffffff8000eb7fd3: add r10d, 0x242d4204 + _0xffffff8000eb7fda: mov eax, r10d + _0xffffff8000eb7fdd: and eax, 0x71dd682e + _0xffffff8000eb7fe2: xor r10d, 0xfbddfcfe + _0xffffff8000eb7fe9: lea eax, [r10 + rax*2] + _0xffffff8000eb7fed: imul edx, edx, 0x70c79e6a + _0xffffff8000eb7ff3: sar edx, 1 + _0xffffff8000eb7ff5: mov r9d, edx + _0xffffff8000eb7ff8: imul r9d, r9d + _0xffffff8000eb7ffc: mov r10d, 7 + _0xffffff8000eb8002: sub r10d, r9d + _0xffffff8000eb8005: add edx, 0x61a94b1d + _0xffffff8000eb800b: imul edx, edx + _0xffffff8000eb800e: add edx, r10d + _0xffffff8000eb8011: and edx, 6 + _0xffffff8000eb8014: mov r9d, eax + _0xffffff8000eb8017: and r9d, edx + _0xffffff8000eb801a: xor edx, eax + _0xffffff8000eb801c: lea r9d, [rdx + r9*2] + _0xffffff8000eb8020: lea r10, [r9 + 0x202c2d36] + _0xffffff8000eb8027: movabs r11, 0x7fffffff6ef6729d + _0xffffff8000eb8031: and r11, r10 + _0xffffff8000eb8034: movabs rbx, 0xffffffff6ef6729d + _0xffffff8000eb803e: xor rbx, r10 + _0xffffff8000eb8041: lea r10, [rbx + r11*2] + _0xffffff8000eb8045: cmp r9d, 0x70dd602d + _0xffffff8000eb804c: sbb r9, r9 + _0xffffff8000eb804f: and r9d, 1 + _0xffffff8000eb8053: shl r9, 0x20 + _0xffffff8000eb8057: mov r11, r10 + _0xffffff8000eb805a: and r11, r9 + _0xffffff8000eb805d: xor r9, r10 + _0xffffff8000eb8060: lea r9, [r9 + r11*2] + _0xffffff8000eb8064: and cl, byte ptr [r8 + r9] + _0xffffff8000eb8068: mov r9b, cl + _0xffffff8000eb806b: add r9b, r9b + _0xffffff8000eb806e: mov al, 0xde + _0xffffff8000eb8070: sub al, r9b + _0xffffff8000eb8073: mov dl, al + _0xffffff8000eb8075: xor dl, r9b + _0xffffff8000eb8078: and al, r9b + _0xffffff8000eb807b: add al, al + _0xffffff8000eb807d: add al, dl + _0xffffff8000eb807f: mov dl, r9b + _0xffffff8000eb8082: and dl, 0xa0 + _0xffffff8000eb8085: mov r10b, r9b + _0xffffff8000eb8088: sub r10b, dl + _0xffffff8000eb808b: and r10b, al + _0xffffff8000eb808e: and dl, al + _0xffffff8000eb8090: xor cl, 0x6f + _0xffffff8000eb8093: and r9b, 0xde + _0xffffff8000eb8097: add r9b, cl + _0xffffff8000eb809a: sub r9b, dl + _0xffffff8000eb809d: sub r9b, r10b + _0xffffff8000eb80a0: xor r9b, 0xa8 + _0xffffff8000eb80a4: mov eax, 1 + _0xffffff8000eb80a9: xor edx, edx + _0xffffff8000eb80ab: div esi + _0xffffff8000eb80ad: mov eax, edx + _0xffffff8000eb80af: neg eax + _0xffffff8000eb80b1: and eax, 0xf495921a + _0xffffff8000eb80b6: imul eax, eax, 0x7c8c2dc5 + _0xffffff8000eb80bc: sar eax, 1 + _0xffffff8000eb80be: mov ecx, eax + _0xffffff8000eb80c0: xor ecx, 1 + _0xffffff8000eb80c3: neg ecx + _0xffffff8000eb80c5: lea eax, [rax + rcx + 1] + _0xffffff8000eb80c9: xor eax, 0x5b07f10e + _0xffffff8000eb80ce: not edx + _0xffffff8000eb80d0: mov ecx, edx + _0xffffff8000eb80d2: or ecx, 0xf649e9c4 + _0xffffff8000eb80d8: and ecx, eax + _0xffffff8000eb80da: and edx, 0xa4f80ef1 + _0xffffff8000eb80e0: xor edx, ecx + _0xffffff8000eb80e2: mov eax, edx + _0xffffff8000eb80e4: not eax + _0xffffff8000eb80e6: lea ecx, [rdx + 0x4b754fbe] + _0xffffff8000eb80ec: mov r10d, ecx + _0xffffff8000eb80ef: and r10d, eax + _0xffffff8000eb80f2: xor ecx, eax + _0xffffff8000eb80f4: lea eax, [rcx + r10*2] + _0xffffff8000eb80f8: xor eax, 0x1250390e + _0xffffff8000eb80fd: mov ecx, eax + _0xffffff8000eb80ff: and ecx, 0x2249444a + _0xffffff8000eb8105: xor edx, 0xedafc6f1 + _0xffffff8000eb810b: mov r10d, edx + _0xffffff8000eb810e: and r10d, 0x2249444a + _0xffffff8000eb8115: add r10d, 0x4928894 + _0xffffff8000eb811c: sub r10d, ecx + _0xffffff8000eb811f: and r10d, 0x2249444a + _0xffffff8000eb8126: mov ecx, eax + _0xffffff8000eb8128: and ecx, 0x549212a4 + _0xffffff8000eb812e: mov r11d, edx + _0xffffff8000eb8131: and r11d, 0x549212a4 + _0xffffff8000eb8138: add r11d, 0x29242548 + _0xffffff8000eb813f: sub r11d, ecx + _0xffffff8000eb8142: and r11d, 0x549212a4 + _0xffffff8000eb8149: add r11d, r10d + _0xffffff8000eb814c: and eax, 0x8924a911 + _0xffffff8000eb8151: and edx, 0x8924a911 + _0xffffff8000eb8157: add edx, 0x12495222 + _0xffffff8000eb815d: sub edx, eax + _0xffffff8000eb815f: and edx, 0x8924a911 + _0xffffff8000eb8165: add edx, r11d + _0xffffff8000eb8168: mov eax, edx + _0xffffff8000eb816a: and eax, 0x7ddebffb + _0xffffff8000eb816f: xor edx, 0xfddebffb + _0xffffff8000eb8175: lea ecx, [rdx + rax*2] + _0xffffff8000eb8178: mov r10, rcx + _0xffffff8000eb817b: xor r10, 0xffffffffb6abf048 + _0xffffff8000eb8182: mov r11d, ecx + _0xffffff8000eb8185: and r11d, 0xb6abf048 + _0xffffff8000eb818c: lea r10, [r10 + r11*2] + _0xffffff8000eb8190: movabs r11, 0x48894a2912449254 + _0xffffff8000eb819a: and r11, r10 + _0xffffff8000eb819d: cmp ecx, 0x49540fb8 + _0xffffff8000eb81a3: sbb rcx, rcx + _0xffffff8000eb81a6: and ecx, 1 + _0xffffff8000eb81a9: shl rcx, 0x20 + _0xffffff8000eb81ad: xor r11, rcx + _0xffffff8000eb81b0: and rcx, r10 + _0xffffff8000eb81b3: add rcx, rcx + _0xffffff8000eb81b6: add rcx, r11 + _0xffffff8000eb81b9: movabs r11, 0xb776b5d6edbb6dab + _0xffffff8000eb81c3: and r11, r10 + _0xffffff8000eb81c6: add r11, rcx + _0xffffff8000eb81c9: mov al, byte ptr [r8 + r11] + _0xffffff8000eb81cd: not al + _0xffffff8000eb81cf: mov cl, al + _0xffffff8000eb81d1: and cl, 0x47 + _0xffffff8000eb81d4: sub al, cl + _0xffffff8000eb81d6: mov dl, byte ptr [r8] + _0xffffff8000eb81d9: not dl + _0xffffff8000eb81db: and al, dl + _0xffffff8000eb81dd: and dl, cl + _0xffffff8000eb81df: add dl, al + _0xffffff8000eb81e1: mov al, dl + _0xffffff8000eb81e3: xor al, 0xfb + _0xffffff8000eb81e5: not dl + _0xffffff8000eb81e7: add dl, dl + _0xffffff8000eb81e9: and dl, 8 + _0xffffff8000eb81ec: add al, dl + _0xffffff8000eb81ee: add al, 0xf2 + _0xffffff8000eb81f0: mov cl, al + _0xffffff8000eb81f2: and cl, 0xa + _0xffffff8000eb81f5: or cl, 4 + _0xffffff8000eb81f8: mov r10b, 0xe + _0xffffff8000eb81fb: sub r10b, dl + _0xffffff8000eb81fe: mov dl, r10b + _0xffffff8000eb8201: and dl, 0xa + _0xffffff8000eb8204: sub cl, dl + _0xffffff8000eb8206: and dl, al + _0xffffff8000eb8208: mov r11b, al + _0xffffff8000eb820b: and r11b, 0x54 + _0xffffff8000eb820f: mov bl, r10b + _0xffffff8000eb8212: and bl, r11b + _0xffffff8000eb8215: add bl, bl + _0xffffff8000eb8217: mov r14b, r10b + _0xffffff8000eb821a: and r14b, 0x54 + _0xffffff8000eb821e: or r14b, 0x28 + _0xffffff8000eb8222: sub r14b, r11b + _0xffffff8000eb8225: and r14b, 0x54 + _0xffffff8000eb8229: or r14b, bl + _0xffffff8000eb822c: add dl, dl + _0xffffff8000eb822e: and cl, 0xa + _0xffffff8000eb8231: or cl, dl + _0xffffff8000eb8233: and al, 0xa1 + _0xffffff8000eb8235: and r10b, 0xa0 + _0xffffff8000eb8239: add r10b, al + _0xffffff8000eb823c: or r10b, cl + _0xffffff8000eb823f: add r10b, r14b + _0xffffff8000eb8242: xor r10b, 0xd9 + _0xffffff8000eb8246: mov r11b, r10b + _0xffffff8000eb8249: add r11b, r9b + _0xffffff8000eb824c: and r10b, r9b + _0xffffff8000eb824f: add r10b, r10b + _0xffffff8000eb8252: sub r11b, r10b + _0xffffff8000eb8255: mov eax, 5 + _0xffffff8000eb825a: xor edx, edx + _0xffffff8000eb825c: div esi + _0xffffff8000eb825e: mov eax, 0x7bfef7dd + _0xffffff8000eb8263: sub eax, edx + _0xffffff8000eb8265: mov ecx, edx + _0xffffff8000eb8267: and ecx, 1 + _0xffffff8000eb826a: mov r9d, eax + _0xffffff8000eb826d: and r9d, ecx + _0xffffff8000eb8270: add r9d, r9d + _0xffffff8000eb8273: mov r10d, eax + _0xffffff8000eb8276: and r10d, 0x52452251 + _0xffffff8000eb827d: neg r10d + _0xffffff8000eb8280: lea ecx, [rcx + r10 + 0x248a44a2] + _0xffffff8000eb8288: and ecx, 0x52452251 + _0xffffff8000eb828e: add ecx, r9d + _0xffffff8000eb8291: mov r9d, edx + _0xffffff8000eb8294: and r9d, 4 + _0xffffff8000eb8298: mov r10d, eax + _0xffffff8000eb829b: and r10d, r9d + _0xffffff8000eb829e: add r10d, r10d + _0xffffff8000eb82a1: mov ebx, eax + _0xffffff8000eb82a3: and ebx, 0x249248a4 + _0xffffff8000eb82a9: neg ebx + _0xffffff8000eb82ab: lea r9d, [r9 + rbx + 0x9249148] + _0xffffff8000eb82b3: and r9d, 0x249248a4 + _0xffffff8000eb82ba: add r9d, r10d + _0xffffff8000eb82bd: add r9d, ecx + _0xffffff8000eb82c0: mov ecx, edx + _0xffffff8000eb82c2: and ecx, 2 + _0xffffff8000eb82c5: mov r10d, eax + _0xffffff8000eb82c8: and r10d, ecx + _0xffffff8000eb82cb: add r10d, r10d + _0xffffff8000eb82ce: and eax, 0x928950a + _0xffffff8000eb82d3: neg eax + _0xffffff8000eb82d5: lea eax, [rcx + rax + 0x12512a14] + _0xffffff8000eb82dc: and eax, 0x8928950a + _0xffffff8000eb82e1: add eax, r10d + _0xffffff8000eb82e4: add eax, r9d + _0xffffff8000eb82e7: xor eax, 0x9489a7ba + _0xffffff8000eb82ec: mov ecx, 1 + _0xffffff8000eb82f1: sub ecx, edx + _0xffffff8000eb82f3: mov r9d, ecx + _0xffffff8000eb82f6: xor r9d, edx + _0xffffff8000eb82f9: and ecx, edx + _0xffffff8000eb82fb: mov r10d, edx + _0xffffff8000eb82fe: xor r10d, 0x9489a7ba + _0xffffff8000eb8305: lea ebx, [r10 + rax + 0x537a27f6] + _0xffffff8000eb830d: and r10d, eax + _0xffffff8000eb8310: add r10d, r10d + _0xffffff8000eb8313: sub ebx, r10d + _0xffffff8000eb8316: mov eax, ebx + _0xffffff8000eb8318: xor eax, 0x6bb12189 + _0xffffff8000eb831d: lea ecx, [r9 + rcx*2] + _0xffffff8000eb8321: shl edx, cl + _0xffffff8000eb8323: and edx, 0xa + _0xffffff8000eb8326: add edx, eax + _0xffffff8000eb8328: and ebx, 0x6bb12189 + _0xffffff8000eb832e: lea ecx, [rdx + rbx*2] + _0xffffff8000eb8331: mov r9, 0xffffffffc4d5bea4 + _0xffffff8000eb8338: sub r9, rcx + _0xffffff8000eb833b: movabs r10, 0x44494a4909521524 + _0xffffff8000eb8345: mov rbx, r9 + _0xffffff8000eb8348: and rbx, r10 + _0xffffff8000eb834b: mov rax, rcx + _0xffffff8000eb834e: and rax, 0x9521524 + _0xffffff8000eb8354: movabs rdx, 0x892949212a42a48 + _0xffffff8000eb835e: add rdx, rax + _0xffffff8000eb8361: sub rdx, rbx + _0xffffff8000eb8364: and rdx, r10 + _0xffffff8000eb8367: and rbx, rcx + _0xffffff8000eb836a: add rbx, rbx + _0xffffff8000eb836d: add rbx, rdx + _0xffffff8000eb8370: movabs r10, 0xa9149112a4894852 + _0xffffff8000eb837a: and r10, r9 + _0xffffff8000eb837d: mov eax, ecx + _0xffffff8000eb837f: and eax, 0xa4894852 + _0xffffff8000eb8384: add rax, r10 + _0xffffff8000eb8387: add rax, rbx + _0xffffff8000eb838a: movabs r10, 0x12a224a45224a289 + _0xffffff8000eb8394: and r9, r10 + _0xffffff8000eb8397: mov rbx, rcx + _0xffffff8000eb839a: and rbx, 0x5224a289 + _0xffffff8000eb83a1: movabs rdx, 0x5444948a4494512 + _0xffffff8000eb83ab: add rdx, rbx + _0xffffff8000eb83ae: sub rdx, r9 + _0xffffff8000eb83b1: and rdx, r10 + _0xffffff8000eb83b4: and r9, rcx + _0xffffff8000eb83b7: add r9, r9 + _0xffffff8000eb83ba: add r9, rdx + _0xffffff8000eb83bd: add r9, rax + _0xffffff8000eb83c0: movabs r10, 0xa85128a48a924489 + _0xffffff8000eb83ca: mov rbx, r9 + _0xffffff8000eb83cd: and rbx, r10 + _0xffffff8000eb83d0: mov eax, ecx + _0xffffff8000eb83d2: and eax, 0x8a924489 + _0xffffff8000eb83d7: movabs rdx, 0x50a2514915248912 + _0xffffff8000eb83e1: add rdx, rax + _0xffffff8000eb83e4: sub rdx, rbx + _0xffffff8000eb83e7: and rbx, rcx + _0xffffff8000eb83ea: movabs rax, 0x128a454924292924 + _0xffffff8000eb83f4: mov r14, r9 + _0xffffff8000eb83f7: and r14, rax + _0xffffff8000eb83fa: mov r15, rcx + _0xffffff8000eb83fd: and r15, 0x24292924 + _0xffffff8000eb8404: movabs r12, 0x5148a9248525248 + _0xffffff8000eb840e: add r12, r15 + _0xffffff8000eb8411: sub r12, r14 + _0xffffff8000eb8414: and r14, rcx + _0xffffff8000eb8417: movabs r15, 0x4524921251449252 + _0xffffff8000eb8421: and r15, r9 + _0xffffff8000eb8424: mov r9, r15 + _0xffffff8000eb8427: and r9, rcx + _0xffffff8000eb842a: mov r13, rcx + _0xffffff8000eb842d: and r13, 0x51449252 + _0xffffff8000eb8434: xor r13, r15 + _0xffffff8000eb8437: add r9, r9 + _0xffffff8000eb843a: add r9, r13 + _0xffffff8000eb843d: and r12, rax + _0xffffff8000eb8440: add r14, r14 + _0xffffff8000eb8443: add r14, r12 + _0xffffff8000eb8446: add r14, r9 + _0xffffff8000eb8449: and rdx, r10 + _0xffffff8000eb844c: add rbx, rbx + _0xffffff8000eb844f: add rbx, rdx + _0xffffff8000eb8452: add rbx, r14 + _0xffffff8000eb8455: cmp ecx, 0x3b2a415c + _0xffffff8000eb845b: sbb rcx, rcx + _0xffffff8000eb845e: and ecx, 1 + _0xffffff8000eb8461: shl rcx, 0x20 + _0xffffff8000eb8465: mov r9, rbx + _0xffffff8000eb8468: and r9, rcx + _0xffffff8000eb846b: xor rcx, rbx + _0xffffff8000eb846e: lea rcx, [rcx + r9*2] + _0xffffff8000eb8472: mov cl, byte ptr [r8 + rcx] + _0xffffff8000eb8476: mov eax, 4 + _0xffffff8000eb847b: xor edx, edx + _0xffffff8000eb847d: div esi + _0xffffff8000eb847f: mov eax, 0x7be9f7bf + _0xffffff8000eb8484: sub eax, edx + _0xffffff8000eb8486: mov r9d, eax + _0xffffff8000eb8489: and r9d, edx + _0xffffff8000eb848c: xor eax, edx + _0xffffff8000eb848e: lea eax, [rax + r9*2] + _0xffffff8000eb8492: xor eax, 0xb4049443 + _0xffffff8000eb8497: mov r9d, edx + _0xffffff8000eb849a: or r9d, 0x64279818 + _0xffffff8000eb84a1: lea r10d, [rax + r9] + _0xffffff8000eb84a5: and r9d, eax + _0xffffff8000eb84a8: add r9d, r9d + _0xffffff8000eb84ab: sub r10d, r9d + _0xffffff8000eb84ae: mov eax, r10d + _0xffffff8000eb84b1: xor eax, 0x6f54e12d + _0xffffff8000eb84b6: lea r9d, [r10 + r10] + _0xffffff8000eb84ba: and r9d, 0x7eefdaec + _0xffffff8000eb84c1: xor r9d, 0x204618a4 + _0xffffff8000eb84c8: add r9d, eax + _0xffffff8000eb84cb: imul eax, r9d, 0x2daaea8b + _0xffffff8000eb84d2: add edx, edx + _0xffffff8000eb84d4: mov r9d, edx + _0xffffff8000eb84d7: and r9d, 0xc + _0xffffff8000eb84db: and edx, 2 + _0xffffff8000eb84de: add edx, r9d + _0xffffff8000eb84e1: imul edx, edx, 0x2daaea8b + _0xffffff8000eb84e7: mov r9d, eax + _0xffffff8000eb84ea: and r9d, edx + _0xffffff8000eb84ed: xor eax, edx + _0xffffff8000eb84ef: lea eax, [rax + r9*2] + _0xffffff8000eb84f3: imul r9d, eax, 0xe1a6ad23 + _0xffffff8000eb84fa: mov r10, r9 + _0xffffff8000eb84fd: xor r10, 0xffffffffc49e1acb + _0xffffff8000eb8504: mov ebx, r9d + _0xffffff8000eb8507: and ebx, 0xc49e1acb + _0xffffff8000eb850d: lea r10, [r10 + rbx*2] + _0xffffff8000eb8511: cmp r9d, 0x3b61e535 + _0xffffff8000eb8518: sbb r9, r9 + _0xffffff8000eb851b: and r9d, 1 + _0xffffff8000eb851f: shl r9, 0x20 + _0xffffff8000eb8523: mov rbx, r10 + _0xffffff8000eb8526: and rbx, r9 + _0xffffff8000eb8529: xor r9, r10 + _0xffffff8000eb852c: lea r9, [r9 + rbx*2] + _0xffffff8000eb8530: mov al, byte ptr [r8 + r9] + _0xffffff8000eb8534: mov dl, cl + _0xffffff8000eb8536: xor dl, al + _0xffffff8000eb8538: and cl, al + _0xffffff8000eb853a: add cl, cl + _0xffffff8000eb853c: add cl, dl + _0xffffff8000eb853e: mov al, cl + _0xffffff8000eb8540: add al, al + _0xffffff8000eb8542: add cl, 0x2f + _0xffffff8000eb8545: mov dl, cl + _0xffffff8000eb8547: and dl, 0xa2 + _0xffffff8000eb854a: and al, 0x5e + _0xffffff8000eb854c: neg al + _0xffffff8000eb854e: mov r9b, al + _0xffffff8000eb8551: and r9b, 0xa2 + _0xffffff8000eb8555: or r9b, 0x44 + _0xffffff8000eb8559: sub r9b, dl + _0xffffff8000eb855c: and dl, al + _0xffffff8000eb855e: add dl, dl + _0xffffff8000eb8560: and r9b, 0xa2 + _0xffffff8000eb8564: or r9b, dl + _0xffffff8000eb8567: mov dl, cl + _0xffffff8000eb8569: and dl, 9 + _0xffffff8000eb856c: or dl, 2 + _0xffffff8000eb856f: mov r10b, al + _0xffffff8000eb8572: and r10b, 8 + _0xffffff8000eb8576: sub dl, r10b + _0xffffff8000eb8579: and r10b, cl + _0xffffff8000eb857c: add r10b, r10b + _0xffffff8000eb857f: and dl, 9 + _0xffffff8000eb8582: or dl, r10b + _0xffffff8000eb8585: or dl, r9b + _0xffffff8000eb8588: and cl, 0x54 + _0xffffff8000eb858b: mov r9b, al + _0xffffff8000eb858e: and r9b, 0x54 + _0xffffff8000eb8592: or r9b, 0x28 + _0xffffff8000eb8596: sub r9b, cl + _0xffffff8000eb8599: and cl, al + _0xffffff8000eb859b: add cl, cl + _0xffffff8000eb859d: and r9b, 0x54 + _0xffffff8000eb85a1: or r9b, cl + _0xffffff8000eb85a4: add r9b, dl + _0xffffff8000eb85a7: xor r9b, r11b + _0xffffff8000eb85aa: xor r9b, 0x52 + _0xffffff8000eb85ae: mov eax, 6 + _0xffffff8000eb85b3: xor edx, edx + _0xffffff8000eb85b5: div esi + _0xffffff8000eb85b7: mov eax, edx + _0xffffff8000eb85b9: xor eax, 0x1d9675a3 + _0xffffff8000eb85be: mov ecx, eax + _0xffffff8000eb85c0: shl ecx, 0xe + _0xffffff8000eb85c3: and ecx, 0x10000 + _0xffffff8000eb85c9: or ecx, eax + _0xffffff8000eb85cb: xor ecx, 0x236b8e1e + _0xffffff8000eb85d1: mov eax, ecx + _0xffffff8000eb85d3: shl eax, 0xe + _0xffffff8000eb85d6: and eax, 0x10000 + _0xffffff8000eb85db: xor eax, ecx + _0xffffff8000eb85dd: lea ecx, [rax + rax - 0x2ea40e12] + _0xffffff8000eb85e4: mov r10d, ecx + _0xffffff8000eb85e7: and r10d, 0xa0a05240 + _0xffffff8000eb85ee: add eax, 0xe8adf8f7 + _0xffffff8000eb85f3: mov r11d, eax + _0xffffff8000eb85f6: and r11d, 0x51522924 + _0xffffff8000eb85fd: mov ebx, 0xf2f47b68 + _0xffffff8000eb8602: sub ebx, r11d + _0xffffff8000eb8605: and ebx, 0x51522924 + _0xffffff8000eb860b: add ebx, r10d + _0xffffff8000eb860e: mov r10d, ecx + _0xffffff8000eb8611: and r10d, 0x49020812 + _0xffffff8000eb8618: mov r11d, eax + _0xffffff8000eb861b: and r11d, 0xa4854489 + _0xffffff8000eb8622: mov r14d, 0x5b7efbf7 + _0xffffff8000eb8628: lea r11d, [r11 + r14 + 0x490a8912] + _0xffffff8000eb8630: and r11d, 0xa4854489 + _0xffffff8000eb8637: add r11d, r10d + _0xffffff8000eb863a: add r11d, ebx + _0xffffff8000eb863d: and ecx, 0x144020a0 + _0xffffff8000eb8643: and eax, 0xa289252 + _0xffffff8000eb8648: add eax, 0x45124a4 + _0xffffff8000eb864d: add eax, 0x5dfefb0 + _0xffffff8000eb8652: and eax, 0xa289252 + _0xffffff8000eb8657: add eax, ecx + _0xffffff8000eb8659: add eax, r11d + _0xffffff8000eb865c: mov ecx, eax + _0xffffff8000eb865e: and ecx, 0x214a48a1 + _0xffffff8000eb8664: mov r10d, eax + _0xffffff8000eb8667: and r10d, 0x54252254 + _0xffffff8000eb866e: mov r11d, 0xa84a44a8 + _0xffffff8000eb8674: sub r11d, r10d + _0xffffff8000eb8677: and r11d, 0x54252254 + _0xffffff8000eb867e: add r11d, ecx + _0xffffff8000eb8681: and eax, 0x8a90950a + _0xffffff8000eb8686: and edx, 5 + _0xffffff8000eb8689: lea ecx, [rax + rdx*2] + _0xffffff8000eb868c: add ecx, r11d + _0xffffff8000eb868f: mov r10, rcx + _0xffffff8000eb8692: xor r10, 0xffffffffd963cdd3 + _0xffffff8000eb8699: mov r11d, ecx + _0xffffff8000eb869c: and r11d, 0xd963cdd3 + _0xffffff8000eb86a3: lea r10, [r10 + r11*2] + _0xffffff8000eb86a7: movabs r11, 0x489152490a25424a + _0xffffff8000eb86b1: and r11, r10 + _0xffffff8000eb86b4: cmp ecx, 0x269c322d + _0xffffff8000eb86ba: sbb rcx, rcx + _0xffffff8000eb86bd: and ecx, 1 + _0xffffff8000eb86c0: shl rcx, 0x20 + _0xffffff8000eb86c4: xor r11, rcx + _0xffffff8000eb86c7: movabs rbx, 0x224a2494a48894a4 + _0xffffff8000eb86d1: mov rax, r10 + _0xffffff8000eb86d4: and rax, rbx + _0xffffff8000eb86d7: movabs rdx, 0x4494492949112948 + _0xffffff8000eb86e1: sub rdx, rax + _0xffffff8000eb86e4: and rdx, rbx + _0xffffff8000eb86e7: add rdx, r11 + _0xffffff8000eb86ea: and rcx, r10 + _0xffffff8000eb86ed: add rcx, rcx + _0xffffff8000eb86f0: add rcx, rdx + _0xffffff8000eb86f3: movabs r11, 0x9524892251522911 + _0xffffff8000eb86fd: and r11, r10 + _0xffffff8000eb8700: add r11, rcx + _0xffffff8000eb8703: mov cl, byte ptr [r8 + r11] + _0xffffff8000eb8707: mov eax, 7 + _0xffffff8000eb870c: xor edx, edx + _0xffffff8000eb870e: div esi + _0xffffff8000eb8710: lea eax, [rdx + rdx] + _0xffffff8000eb8713: mov r10d, eax + _0xffffff8000eb8716: or r10d, 0xa + _0xffffff8000eb871a: xor r10d, 4 + _0xffffff8000eb871e: or eax, 4 + _0xffffff8000eb8721: xor eax, 0xa + _0xffffff8000eb8724: and eax, r10d + _0xffffff8000eb8727: xor eax, 0xe + _0xffffff8000eb872a: xor edx, 0x7db9afbf + _0xffffff8000eb8730: add edx, 0xf7d7f7c8 + _0xffffff8000eb8736: mov r10d, eax + _0xffffff8000eb8739: and r10d, edx + _0xffffff8000eb873c: xor edx, eax + _0xffffff8000eb873e: lea r10d, [rdx + r10*2] + _0xffffff8000eb8742: mov r11, 0xffffffff8a6e5879 + _0xffffff8000eb8749: sub r11, r10 + _0xffffff8000eb874c: mov rbx, r11 + _0xffffff8000eb874f: and rbx, r10 + _0xffffff8000eb8752: xor r11, r10 + _0xffffff8000eb8755: lea r11, [r11 + rbx*2] + _0xffffff8000eb8759: mov rbx, r11 + _0xffffff8000eb875c: and rbx, r10 + _0xffffff8000eb875f: xor r11, r10 + _0xffffff8000eb8762: lea r11, [r11 + rbx*2] + _0xffffff8000eb8766: cmp r10d, 0x7591a787 + _0xffffff8000eb876d: sbb r10, r10 + _0xffffff8000eb8770: and r10d, 1 + _0xffffff8000eb8774: shl r10, 0x20 + _0xffffff8000eb8778: mov rbx, r11 + _0xffffff8000eb877b: and rbx, r10 + _0xffffff8000eb877e: xor r10, r11 + _0xffffff8000eb8781: lea r10, [r10 + rbx*2] + _0xffffff8000eb8785: xor dl, dl + _0xffffff8000eb8787: sub dl, byte ptr [r8 + r10] + _0xffffff8000eb878b: mov al, cl + _0xffffff8000eb878d: xor al, dl + _0xffffff8000eb878f: and dl, cl + _0xffffff8000eb8791: add dl, dl + _0xffffff8000eb8793: add dl, al + _0xffffff8000eb8795: mov cl, 0x5b + _0xffffff8000eb8797: mov al, dl + _0xffffff8000eb8799: mul cl + _0xffffff8000eb879b: mov cl, al + _0xffffff8000eb879d: add dl, dl + _0xffffff8000eb879f: mov al, dl + _0xffffff8000eb87a1: and al, 0x36 + _0xffffff8000eb87a3: mov dl, 0xa5 + _0xffffff8000eb87a5: mul dl + _0xffffff8000eb87a7: add cl, 0x99 + _0xffffff8000eb87aa: mov dl, al + _0xffffff8000eb87ac: xor dl, cl + _0xffffff8000eb87ae: and cl, al + _0xffffff8000eb87b0: add cl, cl + _0xffffff8000eb87b2: mov al, cl + _0xffffff8000eb87b4: add al, dl + _0xffffff8000eb87b6: mov cl, 0xd3 + _0xffffff8000eb87b8: mul cl + _0xffffff8000eb87ba: xor al, 0x52 + _0xffffff8000eb87bc: mov r10b, r9b + _0xffffff8000eb87bf: add r10b, al + _0xffffff8000eb87c2: and al, r9b + _0xffffff8000eb87c5: add al, al + _0xffffff8000eb87c7: sub r10b, al + _0xffffff8000eb87ca: mov eax, 9 + _0xffffff8000eb87cf: xor edx, edx + _0xffffff8000eb87d1: div esi + _0xffffff8000eb87d3: mov eax, edx + _0xffffff8000eb87d5: xor eax, 0xc6ce54dc + _0xffffff8000eb87da: lea ecx, [rax + rax] + _0xffffff8000eb87dd: lea r9d, [rax - 0x74ca604d] + _0xffffff8000eb87e4: and ecx, 0x4082926 + _0xffffff8000eb87ea: sub r9d, ecx + _0xffffff8000eb87ed: neg ecx + _0xffffff8000eb87ef: lea eax, [rax + rcx - 0x11b4c309] + _0xffffff8000eb87f6: mov ecx, 0x5c9ea2b9 + _0xffffff8000eb87fb: sub ecx, r9d + _0xffffff8000eb87fe: mov r11d, ecx + _0xffffff8000eb8801: and r11d, eax + _0xffffff8000eb8804: xor ecx, eax + _0xffffff8000eb8806: lea eax, [rcx + r11*2] + _0xffffff8000eb880a: lea rcx, [rip] + _0xffffff8000eb8811: mov rcx, qword ptr [rcx + 0x1204] + // Inject value + //mov rcx, 0x29444c6318eac681 + _0xffffff8000eb8818: mov ecx, eax + _0xffffff8000eb881a: xor ecx, r9d + _0xffffff8000eb881d: mov r11d, r9d + _0xffffff8000eb8820: and r11d, eax + _0xffffff8000eb8823: mov ebx, r9d + _0xffffff8000eb8826: and ebx, 0x9425148a + _0xffffff8000eb882c: mov r14d, eax + _0xffffff8000eb882f: and r14d, ebx + _0xffffff8000eb8832: add r14d, r14d + _0xffffff8000eb8835: mov r15d, eax + _0xffffff8000eb8838: and r15d, 0x9425148a + _0xffffff8000eb883f: add r15d, 0x284a2914 + _0xffffff8000eb8846: sub r15d, ebx + _0xffffff8000eb8849: and r15d, 0x9425148a + _0xffffff8000eb8850: add r15d, r14d + _0xffffff8000eb8853: and ecx, 0x4a8a4924 + _0xffffff8000eb8859: and r11d, 0x4a8a4924 + _0xffffff8000eb8860: add r11d, r11d + _0xffffff8000eb8863: add r11d, ecx + _0xffffff8000eb8866: and eax, 0x2150a251 + _0xffffff8000eb886b: add edx, edx + _0xffffff8000eb886d: xor edx, 0x2df9b9c8 + _0xffffff8000eb8873: lea ecx, [rdx + rdx] + _0xffffff8000eb8876: and ecx, 0x10 + _0xffffff8000eb8879: neg ecx + _0xffffff8000eb887b: lea ecx, [rdx + rcx + 8] + _0xffffff8000eb887f: and ecx, 0x1e + _0xffffff8000eb8882: and r9d, 0x2150a251 + _0xffffff8000eb8889: add r9d, ecx + _0xffffff8000eb888c: add r9d, eax + _0xffffff8000eb888f: add r9d, r11d + _0xffffff8000eb8892: add r9d, r15d + _0xffffff8000eb8895: mov rcx, r9 + _0xffffff8000eb8898: xor rcx, 0xfffffffff24ff494 + _0xffffff8000eb889f: mov r11d, r9d + _0xffffff8000eb88a2: and r11d, 0xf24ff494 + _0xffffff8000eb88a9: lea rcx, [rcx + r11*2] + _0xffffff8000eb88ad: cmp r9d, 0xdb00b6c + _0xffffff8000eb88b4: sbb r9, r9 + _0xffffff8000eb88b7: and r9d, 1 + _0xffffff8000eb88bb: shl r9, 0x20 + _0xffffff8000eb88bf: mov r11, rcx + _0xffffff8000eb88c2: and r11, r9 + _0xffffff8000eb88c5: xor r9, rcx + _0xffffff8000eb88c8: lea rcx, [r9 + r11*2] + _0xffffff8000eb88cc: mov cl, byte ptr [r8 + rcx] + _0xffffff8000eb88d0: mov eax, 8 + _0xffffff8000eb88d5: xor edx, edx + _0xffffff8000eb88d7: div esi + _0xffffff8000eb88d9: mov eax, 0x7ffd326b + _0xffffff8000eb88de: sub eax, edx + _0xffffff8000eb88e0: mov r9d, edx + _0xffffff8000eb88e3: and r9d, 4 + _0xffffff8000eb88e7: mov r11d, eax + _0xffffff8000eb88ea: and r11d, r9d + _0xffffff8000eb88ed: add r11d, r11d + _0xffffff8000eb88f0: mov ebx, eax + _0xffffff8000eb88f2: and ebx, 0x229112a4 + _0xffffff8000eb88f8: neg ebx + _0xffffff8000eb88fa: lea r9d, [r9 + rbx + 0x5222548] + _0xffffff8000eb8902: and r9d, 0x229112a4 + _0xffffff8000eb8909: add r9d, r11d + _0xffffff8000eb890c: mov r11d, edx + _0xffffff8000eb890f: and r11d, 2 + _0xffffff8000eb8913: mov ebx, eax + _0xffffff8000eb8915: and ebx, r11d + _0xffffff8000eb8918: add ebx, ebx + _0xffffff8000eb891a: mov r14d, eax + _0xffffff8000eb891d: and r14d, 0x492a4912 + _0xffffff8000eb8924: neg r14d + _0xffffff8000eb8927: lea r11d, [r11 + r14 + 0x12549224] + _0xffffff8000eb892f: and r11d, 0x492a4912 + _0xffffff8000eb8936: add r11d, ebx + _0xffffff8000eb8939: add r11d, r9d + _0xffffff8000eb893c: mov r9d, edx + _0xffffff8000eb893f: and r9d, 9 + _0xffffff8000eb8943: mov ebx, eax + _0xffffff8000eb8945: and ebx, r9d + _0xffffff8000eb8948: add ebx, ebx + _0xffffff8000eb894a: and eax, 0x1444a449 + _0xffffff8000eb894f: neg eax + _0xffffff8000eb8951: lea eax, [r9 + rax + 0x28894892] + _0xffffff8000eb8959: and eax, 0x9444a449 + _0xffffff8000eb895e: add eax, ebx + _0xffffff8000eb8960: add eax, r11d + _0xffffff8000eb8963: xor eax, 0xc52516a2 + _0xffffff8000eb8968: mov r9d, edx + _0xffffff8000eb896b: xor r9d, 0xc52516a2 + _0xffffff8000eb8972: lea r11d, [r9 + rax - 0x6810100b] + _0xffffff8000eb897a: and r9d, eax + _0xffffff8000eb897d: add r9d, r9d + _0xffffff8000eb8980: sub r11d, r9d + _0xffffff8000eb8983: add edx, edx + _0xffffff8000eb8985: mov eax, edx + _0xffffff8000eb8987: and eax, 4 + _0xffffff8000eb898a: mov r9d, r11d + _0xffffff8000eb898d: and r9d, eax + _0xffffff8000eb8990: mov ebx, edx + _0xffffff8000eb8992: and ebx, 0x10 + _0xffffff8000eb8995: mov r14d, r11d + _0xffffff8000eb8998: and r14d, ebx + _0xffffff8000eb899b: add r14d, r14d + _0xffffff8000eb899e: mov r15d, r11d + _0xffffff8000eb89a1: and r15d, 0x92449451 + _0xffffff8000eb89a8: neg r15d + _0xffffff8000eb89ab: lea ebx, [rbx + r15 + 0x248928a2] + _0xffffff8000eb89b3: and ebx, 0x92449451 + _0xffffff8000eb89b9: add ebx, r14d + _0xffffff8000eb89bc: add r9d, r9d + _0xffffff8000eb89bf: mov r14d, r11d + _0xffffff8000eb89c2: and r14d, 0x49294924 + _0xffffff8000eb89c9: neg r14d + _0xffffff8000eb89cc: lea eax, [rax + r14 + 0x12529248] + _0xffffff8000eb89d4: and eax, 0x49294924 + _0xffffff8000eb89d9: add eax, r9d + _0xffffff8000eb89dc: and r11d, 0x2492228a + _0xffffff8000eb89e3: and edx, 2 + _0xffffff8000eb89e6: add edx, r11d + _0xffffff8000eb89e9: add edx, eax + _0xffffff8000eb89eb: add edx, ebx + _0xffffff8000eb89ed: cmp edx, 0x17ed2260 + _0xffffff8000eb89f3: sbb r9, r9 + _0xffffff8000eb89f6: and r9d, 1 + _0xffffff8000eb89fa: shl r9, 0x20 + _0xffffff8000eb89fe: add r9, rdx + _0xffffff8000eb8a01: or cl, byte ptr [r9 + r8 - 0x17ed2260] + _0xffffff8000eb8a09: mov al, cl + _0xffffff8000eb8a0b: add al, al + _0xffffff8000eb8a0d: mov dl, al + _0xffffff8000eb8a0f: and dl, 0x82 + _0xffffff8000eb8a12: and al, 0x14 + _0xffffff8000eb8a14: or al, dl + _0xffffff8000eb8a16: neg al + _0xffffff8000eb8a18: add cl, 0x4b + _0xffffff8000eb8a1b: mov dl, cl + _0xffffff8000eb8a1d: xor dl, al + _0xffffff8000eb8a1f: and cl, al + _0xffffff8000eb8a21: add cl, cl + _0xffffff8000eb8a23: add cl, dl + _0xffffff8000eb8a25: xor cl, 0x87 + _0xffffff8000eb8a28: mov al, cl + _0xffffff8000eb8a2a: add al, al + _0xffffff8000eb8a2c: add cl, 0xa1 + _0xffffff8000eb8a2f: and al, 0x42 + _0xffffff8000eb8a31: sub cl, al + _0xffffff8000eb8a33: xor cl, 0x2a + _0xffffff8000eb8a36: mov al, cl + _0xffffff8000eb8a38: shl al, 3 + _0xffffff8000eb8a3b: and al, 0x20 + _0xffffff8000eb8a3d: xor al, cl + _0xffffff8000eb8a3f: mov r9b, al + _0xffffff8000eb8a42: shl r9b, 7 + _0xffffff8000eb8a46: xor r9b, al + _0xffffff8000eb8a49: mov eax, 0xa + _0xffffff8000eb8a4e: xor edx, edx + _0xffffff8000eb8a50: div esi + _0xffffff8000eb8a52: mov eax, 0xb110db45 + _0xffffff8000eb8a57: sub eax, edx + _0xffffff8000eb8a59: lea ecx, [rdx + 0x4eef24bc] + _0xffffff8000eb8a5f: mov r11d, eax + _0xffffff8000eb8a62: and r11d, ecx + _0xffffff8000eb8a65: xor eax, ecx + _0xffffff8000eb8a67: lea ecx, [rax + r11*2] + _0xffffff8000eb8a6b: and ecx, 0x1f + _0xffffff8000eb8a6e: mov eax, 0xce5ffbbb + _0xffffff8000eb8a73: shl eax, cl + _0xffffff8000eb8a75: lea r11d, [rdx - 0x31a00445] + _0xffffff8000eb8a7c: shl r11d, cl + _0xffffff8000eb8a7f: sub r11d, eax + _0xffffff8000eb8a82: and r11d, 0x1e + _0xffffff8000eb8a86: xor edx, 0x36fc0bca + _0xffffff8000eb8a8c: lea eax, [rdx + rdx] + _0xffffff8000eb8a8f: and eax, 0x8e0140a + _0xffffff8000eb8a94: neg eax + _0xffffff8000eb8a96: lea eax, [rdx + rax + 0x4689ca1] + _0xffffff8000eb8a9d: mov ecx, r11d + _0xffffff8000eb8aa0: and ecx, eax + _0xffffff8000eb8aa2: xor r11d, eax + _0xffffff8000eb8aa5: lea ecx, [r11 + rcx*2] + _0xffffff8000eb8aa9: mov r11, rcx + _0xffffff8000eb8aac: xor r11, 0xffffffffcd7b6b95 + _0xffffff8000eb8ab3: mov ebx, ecx + _0xffffff8000eb8ab5: and ebx, 0xcd7b6b95 + _0xffffff8000eb8abb: lea r11, [r11 + rbx*2] + _0xffffff8000eb8abf: cmp ecx, 0x3284946b + _0xffffff8000eb8ac5: sbb rcx, rcx + _0xffffff8000eb8ac8: and ecx, 1 + _0xffffff8000eb8acb: shl rcx, 0x20 + _0xffffff8000eb8acf: mov rbx, r11 + _0xffffff8000eb8ad2: and rbx, rcx + _0xffffff8000eb8ad5: xor rcx, r11 + _0xffffff8000eb8ad8: lea rcx, [rcx + rbx*2] + _0xffffff8000eb8adc: mov cl, byte ptr [r8 + rcx] + _0xffffff8000eb8ae0: mov eax, 0xb + _0xffffff8000eb8ae5: xor edx, edx + _0xffffff8000eb8ae7: div esi + _0xffffff8000eb8ae9: mov eax, edx + _0xffffff8000eb8aeb: xor eax, 0x5eb2f1be + _0xffffff8000eb8af0: lea r11d, [rax + rax] + _0xffffff8000eb8af4: and edx, 0xe + _0xffffff8000eb8af7: mov ebx, eax + _0xffffff8000eb8af9: and ebx, 0x5000a0a4 + _0xffffff8000eb8aff: lea edx, [rbx + rdx*2] + _0xffffff8000eb8b02: mov ebx, eax + _0xffffff8000eb8b04: and ebx, 0x204009 + _0xffffff8000eb8b0a: mov r14d, r11d + _0xffffff8000eb8b0d: and r14d, 0x8000010 + _0xffffff8000eb8b14: add r14d, ebx + _0xffffff8000eb8b17: xor r14d, 0xa0050248 + _0xffffff8000eb8b1e: add r14d, edx + _0xffffff8000eb8b21: and r11d, 0x15202224 + _0xffffff8000eb8b28: and eax, 0xa921112 + _0xffffff8000eb8b2d: mov edx, 0x1fb43f36 + _0xffffff8000eb8b32: sub edx, eax + _0xffffff8000eb8b34: and edx, 0xa921512 + _0xffffff8000eb8b3a: add edx, r11d + _0xffffff8000eb8b3d: lea r11d, [rdx + r14 + 0x5148a8a4] + _0xffffff8000eb8b45: lea rbx, [r11 - 0x5e90b1bc] + _0xffffff8000eb8b4c: cmp r11d, 0x5e90b1bc + _0xffffff8000eb8b53: sbb r11, r11 + _0xffffff8000eb8b56: and r11d, 1 + _0xffffff8000eb8b5a: shl r11, 0x20 + _0xffffff8000eb8b5e: mov rax, rbx + _0xffffff8000eb8b61: and rax, r11 + _0xffffff8000eb8b64: xor r11, rbx + _0xffffff8000eb8b67: lea r11, [r11 + rax*2] + _0xffffff8000eb8b6b: mov al, byte ptr [r8 + r11] + _0xffffff8000eb8b6f: mov dl, cl + _0xffffff8000eb8b71: and dl, al + _0xffffff8000eb8b73: mov r11b, al + _0xffffff8000eb8b76: xor r11b, cl + _0xffffff8000eb8b79: sar cl, 1 + _0xffffff8000eb8b7b: and dl, 1 + _0xffffff8000eb8b7e: add dl, cl + _0xffffff8000eb8b80: sar al, 1 + _0xffffff8000eb8b82: add al, dl + _0xffffff8000eb8b84: sar r11b, 1 + _0xffffff8000eb8b87: sub al, r11b + _0xffffff8000eb8b8a: mov cl, al + _0xffffff8000eb8b8c: add cl, cl + _0xffffff8000eb8b8e: mov dl, cl + _0xffffff8000eb8b90: or dl, 0xdc + _0xffffff8000eb8b93: sar dl, 1 + _0xffffff8000eb8b95: xor dl, 0x11 + _0xffffff8000eb8b98: mov r11b, 0xfe + _0xffffff8000eb8b9b: sub r11b, dl + _0xffffff8000eb8b9e: mov bl, cl + _0xffffff8000eb8ba0: or bl, 0x22 + _0xffffff8000eb8ba3: xor bl, 0xdc + _0xffffff8000eb8ba6: sar bl, 1 + _0xffffff8000eb8ba8: xor dl, bl + _0xffffff8000eb8baa: sub r11b, bl + _0xffffff8000eb8bad: add r11b, dl + _0xffffff8000eb8bb0: and r11b, 0x52 + _0xffffff8000eb8bb4: neg r11b + _0xffffff8000eb8bb7: xor al, 0x29 + _0xffffff8000eb8bb9: and cl, 0x52 + _0xffffff8000eb8bbc: add cl, al + _0xffffff8000eb8bbe: mov al, cl + _0xffffff8000eb8bc0: xor al, r11b + _0xffffff8000eb8bc3: and cl, r11b + _0xffffff8000eb8bc6: add cl, cl + _0xffffff8000eb8bc8: add cl, al + _0xffffff8000eb8bca: xor cl, 0x2a + _0xffffff8000eb8bcd: mov dl, cl + _0xffffff8000eb8bcf: shl dl, 3 + _0xffffff8000eb8bd2: and dl, 0x20 + _0xffffff8000eb8bd5: xor dl, cl + _0xffffff8000eb8bd7: xor r9b, dl + _0xffffff8000eb8bda: shl dl, 7 + _0xffffff8000eb8bdd: xor dl, r9b + _0xffffff8000eb8be0: mov cl, dl + _0xffffff8000eb8be2: and cl, 1 + _0xffffff8000eb8be5: mov r9b, 0xab + _0xffffff8000eb8be8: mov al, cl + _0xffffff8000eb8bea: mul r9b + _0xffffff8000eb8bed: shr ax, 8 + _0xffffff8000eb8bf1: shr al, 1 + _0xffffff8000eb8bf3: mov r11b, 3 + _0xffffff8000eb8bf6: mul r11b + _0xffffff8000eb8bf9: sub cl, al + _0xffffff8000eb8bfb: mov bl, cl + _0xffffff8000eb8bfd: neg bl + _0xffffff8000eb8bff: and bl, cl + _0xffffff8000eb8c01: mov al, bl + _0xffffff8000eb8c03: mul r9b + _0xffffff8000eb8c06: shr ax, 8 + _0xffffff8000eb8c0a: shr al, 1 + _0xffffff8000eb8c0c: mul r11b + _0xffffff8000eb8c0f: sub bl, al + _0xffffff8000eb8c11: shl bl, 7 + _0xffffff8000eb8c14: xor bl, dl + _0xffffff8000eb8c16: mov eax, 0xd + _0xffffff8000eb8c1b: xor edx, edx + _0xffffff8000eb8c1d: div esi + _0xffffff8000eb8c1f: lea eax, [rdx + rdx] + _0xffffff8000eb8c22: mov ecx, 0x12 + _0xffffff8000eb8c27: sub ecx, eax + _0xffffff8000eb8c29: mov r9d, ecx + _0xffffff8000eb8c2c: and r9d, eax + _0xffffff8000eb8c2f: xor ecx, eax + _0xffffff8000eb8c31: lea eax, [rcx + r9*2] + _0xffffff8000eb8c35: sar eax, 1 + _0xffffff8000eb8c37: mov ecx, eax + _0xffffff8000eb8c39: xor ecx, edx + _0xffffff8000eb8c3b: sub eax, ecx + _0xffffff8000eb8c3d: add eax, edx + _0xffffff8000eb8c3f: xor edx, 0x9ec32dfe + _0xffffff8000eb8c45: mov ecx, edx + _0xffffff8000eb8c47: and ecx, 0x14002122 + _0xffffff8000eb8c4d: mov r9d, edx + _0xffffff8000eb8c50: and r9d, 0xa820494 + _0xffffff8000eb8c57: add r9d, 0x42101084 + _0xffffff8000eb8c5e: and r9d, 0x4a921494 + _0xffffff8000eb8c65: add r9d, ecx + _0xffffff8000eb8c68: and edx, 0x80410049 + _0xffffff8000eb8c6e: mov ecx, 0xe3dac493 + _0xffffff8000eb8c73: sub ecx, edx + _0xffffff8000eb8c75: and ecx, 0xa1494249 + _0xffffff8000eb8c7b: add ecx, r9d + _0xffffff8000eb8c7e: xor ecx, 0x248002 + _0xffffff8000eb8c84: add ecx, 0xfffdeeb8 + _0xffffff8000eb8c8a: mov edx, eax + _0xffffff8000eb8c8c: and edx, ecx + _0xffffff8000eb8c8e: xor ecx, eax + _0xffffff8000eb8c90: lea ecx, [rcx + rdx*2] + _0xffffff8000eb8c93: lea r9, [rcx - 0x7dbde431] + _0xffffff8000eb8c9a: cmp ecx, 0x7dbde431 + _0xffffff8000eb8ca0: sbb rcx, rcx + _0xffffff8000eb8ca3: and ecx, 1 + _0xffffff8000eb8ca6: shl rcx, 0x20 + _0xffffff8000eb8caa: mov r11, r9 + _0xffffff8000eb8cad: and r11, rcx + _0xffffff8000eb8cb0: xor rcx, r9 + _0xffffff8000eb8cb3: lea rcx, [rcx + r11*2] + _0xffffff8000eb8cb7: mov cl, byte ptr [r8 + rcx] + _0xffffff8000eb8cbb: mov eax, 0xc + _0xffffff8000eb8cc0: xor edx, edx + _0xffffff8000eb8cc2: div esi + _0xffffff8000eb8cc4: imul eax, edx, 0x9245850a + _0xffffff8000eb8cca: xor edx, 0x355fbfee + _0xffffff8000eb8cd0: mov r9d, edx + _0xffffff8000eb8cd3: and r9d, 0x2002 + _0xffffff8000eb8cda: mov r11d, edx + _0xffffff8000eb8cdd: and r11d, 0x140a1289 + _0xffffff8000eb8ce4: lea r9d, [r11 + r9 + 0x42291240] + _0xffffff8000eb8cec: mov r11d, edx + _0xffffff8000eb8cef: and r11d, 0x1100004 + _0xffffff8000eb8cf6: lea edx, [rdx + rdx] + _0xffffff8000eb8cf9: and edx, 0x820888 + _0xffffff8000eb8cff: add edx, r11d + _0xffffff8000eb8d02: xor edx, 0x48004014 + _0xffffff8000eb8d08: lea edx, [rdx + r9 - 0x6f75ff80] + _0xffffff8000eb8d10: mov r9d, eax + _0xffffff8000eb8d13: sar r9d, 1 + _0xffffff8000eb8d16: lea r11d, [r9 - 0x16947326] + _0xffffff8000eb8d1d: imul r11d, r11d + _0xffffff8000eb8d21: sub eax, r11d + _0xffffff8000eb8d24: add r9d, 0x16947326 + _0xffffff8000eb8d2b: imul r9d, r9d + _0xffffff8000eb8d2f: add r9d, eax + _0xffffff8000eb8d32: and r9d, 0x1c + _0xffffff8000eb8d36: mov eax, r9d + _0xffffff8000eb8d39: and eax, edx + _0xffffff8000eb8d3b: xor r9d, edx + _0xffffff8000eb8d3e: lea r9d, [r9 + rax*2] + _0xffffff8000eb8d42: cmp r9d, 0x304f8de2 + _0xffffff8000eb8d49: sbb r11, r11 + _0xffffff8000eb8d4c: and r11d, 1 + _0xffffff8000eb8d50: shl r11, 0x20 + _0xffffff8000eb8d54: add r11, r9 + _0xffffff8000eb8d57: mov al, byte ptr [r11 + r8 - 0x304f8de2] + _0xffffff8000eb8d5f: mov dl, cl + _0xffffff8000eb8d61: xor dl, al + _0xffffff8000eb8d63: and cl, al + _0xffffff8000eb8d65: add cl, cl + _0xffffff8000eb8d67: add cl, dl + _0xffffff8000eb8d69: mov dl, 5 + _0xffffff8000eb8d6b: sub dl, cl + _0xffffff8000eb8d6d: mov al, dl + _0xffffff8000eb8d6f: and al, 0x21 + _0xffffff8000eb8d71: mov r9b, cl + _0xffffff8000eb8d74: and r9b, 0x21 + _0xffffff8000eb8d78: or r9b, 2 + _0xffffff8000eb8d7c: sub r9b, al + _0xffffff8000eb8d7f: and al, cl + _0xffffff8000eb8d81: mov r11b, dl + _0xffffff8000eb8d84: and r11b, 0x94 + _0xffffff8000eb8d88: mov r14b, cl + _0xffffff8000eb8d8b: and r14b, 0x94 + _0xffffff8000eb8d8f: or r14b, 0x28 + _0xffffff8000eb8d93: sub r14b, r11b + _0xffffff8000eb8d96: and r11b, cl + _0xffffff8000eb8d99: add r11b, r11b + _0xffffff8000eb8d9c: and r14b, 0x94 + _0xffffff8000eb8da0: or r14b, r11b + _0xffffff8000eb8da3: add al, al + _0xffffff8000eb8da5: and r9b, 0x21 + _0xffffff8000eb8da9: or r9b, al + _0xffffff8000eb8dac: and dl, 0x4a + _0xffffff8000eb8daf: mov r11b, cl + _0xffffff8000eb8db2: and r11b, 0x4a + _0xffffff8000eb8db6: add dl, r11b + _0xffffff8000eb8db9: add dl, r9b + _0xffffff8000eb8dbc: add dl, r14b + _0xffffff8000eb8dbf: mov al, dl + _0xffffff8000eb8dc1: xor al, cl + _0xffffff8000eb8dc3: and al, 0x24 + _0xffffff8000eb8dc5: mov r9b, cl + _0xffffff8000eb8dc8: and r9b, dl + _0xffffff8000eb8dcb: and r9b, 0x24 + _0xffffff8000eb8dcf: add r9b, r9b + _0xffffff8000eb8dd2: or r9b, al + _0xffffff8000eb8dd5: mov r14b, dl + _0xffffff8000eb8dd8: and r14b, r11b + _0xffffff8000eb8ddb: mov r15b, 0x4f + _0xffffff8000eb8dde: mov al, cl + _0xffffff8000eb8de0: mul r15b + _0xffffff8000eb8de3: shr ax, 8 + _0xffffff8000eb8de7: mov r15b, cl + _0xffffff8000eb8dea: sub r15b, al + _0xffffff8000eb8ded: shr r15b, 1 + _0xffffff8000eb8df0: add r15b, al + _0xffffff8000eb8df3: shr r15b, 5 + _0xffffff8000eb8df7: mov al, r15b + _0xffffff8000eb8dfa: mul r15b + _0xffffff8000eb8dfd: mov r12b, 0x9f + _0xffffff8000eb8e00: sub r12b, al + _0xffffff8000eb8e03: mov r13b, 0x31 + _0xffffff8000eb8e06: mov al, r15b + _0xffffff8000eb8e09: mul r13b + _0xffffff8000eb8e0c: mov r13b, cl + _0xffffff8000eb8e0f: sub r13b, al + _0xffffff8000eb8e12: add r13b, r13b + _0xffffff8000eb8e15: add r13b, r12b + _0xffffff8000eb8e18: mov al, r15b + _0xffffff8000eb8e1b: add al, 0x31 + _0xffffff8000eb8e1d: mul al + _0xffffff8000eb8e1f: add al, r13b + _0xffffff8000eb8e22: sar al, 1 + _0xffffff8000eb8e24: and cl, 0x91 + _0xffffff8000eb8e27: add cl, 0xfb + _0xffffff8000eb8e2a: sub cl, al + _0xffffff8000eb8e2c: xor al, 5 + _0xffffff8000eb8e2e: add al, cl + _0xffffff8000eb8e30: mov cl, dl + _0xffffff8000eb8e32: and cl, 0x91 + _0xffffff8000eb8e35: add cl, al + _0xffffff8000eb8e37: add cl, r9b + _0xffffff8000eb8e3a: add r14b, r14b + _0xffffff8000eb8e3d: and dl, 0x4a + _0xffffff8000eb8e40: or dl, 0x14 + _0xffffff8000eb8e43: sub dl, r11b + _0xffffff8000eb8e46: and dl, 0x4a + _0xffffff8000eb8e49: or dl, r14b + _0xffffff8000eb8e4c: add dl, cl + _0xffffff8000eb8e4e: xor dl, bl + _0xffffff8000eb8e50: shl bl, 3 + _0xffffff8000eb8e53: and bl, 0x20 + _0xffffff8000eb8e56: xor bl, dl + _0xffffff8000eb8e58: xor bl, 0x1a + _0xffffff8000eb8e5b: mov eax, 0xf + _0xffffff8000eb8e60: xor edx, edx + _0xffffff8000eb8e62: div esi + _0xffffff8000eb8e64: mov eax, 1 + _0xffffff8000eb8e69: sub eax, edx + _0xffffff8000eb8e6b: mov dword ptr [rbp - 0x360], eax + _0xffffff8000eb8e71: mov ecx, eax + _0xffffff8000eb8e73: and ecx, edx + _0xffffff8000eb8e75: xor dword ptr [rbp - 0x360], edx + _0xffffff8000eb8e7b: mov eax, dword ptr [rbp - 0x360] + _0xffffff8000eb8e81: lea ecx, [rax + rcx*2] + _0xffffff8000eb8e84: and ecx, 0x1f + _0xffffff8000eb8e87: mov eax, edx + _0xffffff8000eb8e89: and eax, 0xa + _0xffffff8000eb8e8c: shl eax, cl + _0xffffff8000eb8e8e: xor eax, 0x501d3c16 + _0xffffff8000eb8e93: mov r9d, edx + _0xffffff8000eb8e96: and r9d, 5 + _0xffffff8000eb8e9a: shl r9d, cl + _0xffffff8000eb8e9d: xor r9d, 0x501d3c16 + _0xffffff8000eb8ea4: lea ecx, [r9 + rax] + _0xffffff8000eb8ea8: and r9d, eax + _0xffffff8000eb8eab: add r9d, r9d + _0xffffff8000eb8eae: sub ecx, r9d + _0xffffff8000eb8eb1: mov eax, 0x1e + _0xffffff8000eb8eb6: sub eax, ecx + _0xffffff8000eb8eb8: mov r9d, eax + _0xffffff8000eb8ebb: and r9d, ecx + _0xffffff8000eb8ebe: xor eax, ecx + _0xffffff8000eb8ec0: lea eax, [rax + r9*2] + _0xffffff8000eb8ec4: and eax, ecx + _0xffffff8000eb8ec6: xor edx, 0x260513cd + _0xffffff8000eb8ecc: lea ecx, [rdx + rdx] + _0xffffff8000eb8ecf: and ecx, 0x22184 + _0xffffff8000eb8ed5: neg ecx + _0xffffff8000eb8ed7: lea ecx, [rdx + rcx + 0xc29dcc2] + _0xffffff8000eb8ede: mov edx, eax + _0xffffff8000eb8ee0: and edx, ecx + _0xffffff8000eb8ee2: xor eax, ecx + _0xffffff8000eb8ee4: lea ecx, [rax + rdx*2] + _0xffffff8000eb8ee7: lea r9, [rcx + rcx] + _0xffffff8000eb8eeb: movabs r11, 0x108002100 + _0xffffff8000eb8ef5: and r11, r9 + _0xffffff8000eb8ef8: mov r9d, ecx + _0xffffff8000eb8efb: and r9d, 0xa424948a + _0xffffff8000eb8f02: movabs rax, 0x4a9112948492914 + _0xffffff8000eb8f0c: add rax, r9 + _0xffffff8000eb8f0f: movabs r9, 0x1dab776b7bffef80 + _0xffffff8000eb8f19: add r9, rax + _0xffffff8000eb8f1c: movabs rax, 0x22548894a424948a + _0xffffff8000eb8f26: and rax, r9 + _0xffffff8000eb8f29: add rax, r11 + _0xffffff8000eb8f2c: mov r9, rcx + _0xffffff8000eb8f2f: and r9, 0x5bdb6b75 + _0xffffff8000eb8f36: add r9, rax + _0xffffff8000eb8f39: movabs r11, 0xddab776b70e7934a + _0xffffff8000eb8f43: add r11, r9 + _0xffffff8000eb8f46: cmp ecx, 0x322ccf0f + _0xffffff8000eb8f4c: mov ecx, 0xd8eb8d27 + _0xffffff8000eb8f51: mov r9, 0xffffffffd8eb8d27 + _0xffffff8000eb8f58: cmovb r9, rcx + _0xffffff8000eb8f5c: mov rcx, r11 + _0xffffff8000eb8f5f: and rcx, r9 + _0xffffff8000eb8f62: xor r9, r11 + _0xffffff8000eb8f65: lea rcx, [r9 + rcx*2] + _0xffffff8000eb8f69: xor r9b, r9b + _0xffffff8000eb8f6c: sub r9b, byte ptr [r8 + rcx] + _0xffffff8000eb8f70: mov eax, 0xe + _0xffffff8000eb8f75: xor edx, edx + _0xffffff8000eb8f77: div esi + _0xffffff8000eb8f79: mov eax, edx + _0xffffff8000eb8f7b: xor eax, 0x75ff3bd8 + _0xffffff8000eb8f80: lea ecx, [rdx + rdx] + _0xffffff8000eb8f83: imul edx, eax, 0xd1f0d019 + _0xffffff8000eb8f89: mov esi, 0xbf6ae740 + _0xffffff8000eb8f8e: sub esi, eax + _0xffffff8000eb8f90: imul esi, esi, 0xd1f0d019 + _0xffffff8000eb8f96: mov r11d, esi + _0xffffff8000eb8f99: and r11d, edx + _0xffffff8000eb8f9c: xor esi, edx + _0xffffff8000eb8f9e: lea edx, [rsi + r11*2] + _0xffffff8000eb8fa2: imul edx, edx, 0x3c98c29 + _0xffffff8000eb8fa8: mov esi, ecx + _0xffffff8000eb8faa: and esi, 4 + _0xffffff8000eb8fad: xor esi, 0x14 + _0xffffff8000eb8fb0: and ecx, 0x10 + _0xffffff8000eb8fb3: xor ecx, 0x1c + _0xffffff8000eb8fb6: sub ecx, esi + _0xffffff8000eb8fb8: and ecx, 0x10 + _0xffffff8000eb8fbb: add ecx, eax + _0xffffff8000eb8fbd: add ecx, edx + _0xffffff8000eb8fbf: cmp ecx, 0x356a2318 + _0xffffff8000eb8fc5: sbb rsi, rsi + _0xffffff8000eb8fc8: and esi, 1 + _0xffffff8000eb8fcb: shl rsi, 0x20 + _0xffffff8000eb8fcf: add rsi, rcx + _0xffffff8000eb8fd2: mov al, byte ptr [rsi + r8 - 0x356a2318] + _0xffffff8000eb8fda: mov cl, al + _0xffffff8000eb8fdc: xor cl, r9b + _0xffffff8000eb8fdf: and r9b, al + _0xffffff8000eb8fe2: add r9b, r9b + _0xffffff8000eb8fe5: add r9b, cl + _0xffffff8000eb8fe8: mov al, r9b + _0xffffff8000eb8feb: add al, al + _0xffffff8000eb8fed: mov cl, r9b + _0xffffff8000eb8ff0: and cl, 9 + _0xffffff8000eb8ff3: and al, 0x20 + _0xffffff8000eb8ff5: or cl, al + _0xffffff8000eb8ff7: mov dl, r9b + _0xffffff8000eb8ffa: and dl, 0x52 + _0xffffff8000eb8ffd: mov sil, 0xb4 + _0xffffff8000eb9000: sub sil, dl + _0xffffff8000eb9003: and sil, 0x52 + _0xffffff8000eb9007: or sil, cl + _0xffffff8000eb900a: and r9b, 0xa4 + _0xffffff8000eb900e: mov cl, 0x48 + _0xffffff8000eb9010: sub cl, r9b + _0xffffff8000eb9013: and cl, 0xa4 + _0xffffff8000eb9016: add cl, sil + _0xffffff8000eb9019: neg al + _0xffffff8000eb901b: mov dl, cl + _0xffffff8000eb901d: xor dl, al + _0xffffff8000eb901f: and al, cl + _0xffffff8000eb9021: add al, al + _0xffffff8000eb9023: add al, dl + _0xffffff8000eb9025: xor al, 0x1a + _0xffffff8000eb9027: mov dl, bl + _0xffffff8000eb9029: add dl, al + _0xffffff8000eb902b: and al, bl + _0xffffff8000eb902d: add al, al + _0xffffff8000eb902f: sub dl, al + _0xffffff8000eb9031: mov al, dl + _0xffffff8000eb9033: xor al, 0x51 + _0xffffff8000eb9035: mov cl, al + _0xffffff8000eb9037: xor cl, r10b + _0xffffff8000eb903a: and al, r10b + _0xffffff8000eb903d: add al, al + _0xffffff8000eb903f: add al, cl + _0xffffff8000eb9041: mov cl, al + _0xffffff8000eb9043: add cl, cl + _0xffffff8000eb9045: xor al, 0x7f + _0xffffff8000eb9047: xor cl, 0x81 + _0xffffff8000eb904a: mov sil, cl + _0xffffff8000eb904d: xor sil, al + _0xffffff8000eb9050: and cl, al + _0xffffff8000eb9052: add cl, cl + _0xffffff8000eb9054: add cl, sil + _0xffffff8000eb9057: mov rsi, qword ptr [rdi + 0x18] + _0xffffff8000eb905b: mov rdi, qword ptr [rdi + 0x10] + _0xffffff8000eb905f: mov byte ptr [rdi], cl + _0xffffff8000eb9061: mov al, dl + _0xffffff8000eb9063: sar al, 1 + _0xffffff8000eb9065: mov cl, al + _0xffffff8000eb9067: xor cl, 5 + _0xffffff8000eb906a: mov dil, 5 + _0xffffff8000eb906d: sub dil, cl + _0xffffff8000eb9070: add dil, al + _0xffffff8000eb9073: or dil, 0x54 + _0xffffff8000eb9077: xor dil, 0x2c + _0xffffff8000eb907b: mov al, dl + _0xffffff8000eb907d: or al, 0xf5 + _0xffffff8000eb907f: xor al, 0xa + _0xffffff8000eb9081: add al, 0xec + _0xffffff8000eb9083: and al, 0xaa + _0xffffff8000eb9085: xor al, 0xd2 + _0xffffff8000eb9087: mov r8b, dil + _0xffffff8000eb908a: add r8b, al + _0xffffff8000eb908d: and al, dil + _0xffffff8000eb9090: add al, al + _0xffffff8000eb9092: sub r8b, al + _0xffffff8000eb9095: mov cl, 1 + _0xffffff8000eb9097: sub cl, r8b + _0xffffff8000eb909a: mov al, cl + _0xffffff8000eb909c: xor al, r8b + _0xffffff8000eb909f: and cl, r8b + _0xffffff8000eb90a2: add cl, cl + _0xffffff8000eb90a4: add cl, al + _0xffffff8000eb90a6: shl r8b, cl + _0xffffff8000eb90a9: neg r8b + _0xffffff8000eb90ac: or dl, 0xf1 + _0xffffff8000eb90af: xor dl, 0xe + _0xffffff8000eb90b2: mov al, dl + _0xffffff8000eb90b4: or al, 0xb1 + _0xffffff8000eb90b6: xor al, 0x4e + _0xffffff8000eb90b8: and dl, 0x4e + _0xffffff8000eb90bb: or dl, 0xa0 + _0xffffff8000eb90be: add dl, al + _0xffffff8000eb90c0: mov cl, dl + _0xffffff8000eb90c2: xor cl, al + _0xffffff8000eb90c4: and dl, al + _0xffffff8000eb90c6: add dl, dl + _0xffffff8000eb90c8: add dl, cl + _0xffffff8000eb90ca: mov al, dl + _0xffffff8000eb90cc: xor al, r8b + _0xffffff8000eb90cf: and dl, r8b + _0xffffff8000eb90d2: add dl, dl + _0xffffff8000eb90d4: add dl, al + _0xffffff8000eb90d6: xor dl, 0x13 + _0xffffff8000eb90d9: mov al, dl + _0xffffff8000eb90db: and al, 9 + _0xffffff8000eb90dd: mov cl, dl + _0xffffff8000eb90df: and cl, 0xa2 + _0xffffff8000eb90e2: add cl, 2 + _0xffffff8000eb90e5: and cl, 0xa2 + _0xffffff8000eb90e8: or cl, al + _0xffffff8000eb90ea: and dl, 0x54 + _0xffffff8000eb90ed: or dl, 0x28 + _0xffffff8000eb90f0: add dl, 0x40 + _0xffffff8000eb90f3: and dl, 0x54 + _0xffffff8000eb90f6: or dl, cl + _0xffffff8000eb90f8: mov byte ptr [rsi], dl + _0xffffff8000eb90fa: jmp _0xffffff8000ebe494 + _0xffffff8000eb90ff: mov rcx, qword ptr [rbp - 0x60] + _0xffffff8000eb9103: mov eax, dword ptr [rcx + 4] + _0xffffff8000eb9106: mov edx, dword ptr [rcx + 8] + _0xffffff8000eb9109: mov esi, dword ptr [rcx + 0xc] + _0xffffff8000eb910c: mov edi, esi + _0xffffff8000eb910e: xor edi, edx + _0xffffff8000eb9110: and edi, eax + _0xffffff8000eb9112: lea r8d, [rdi + rdi] + _0xffffff8000eb9116: and r8d, 0x2a763186 + _0xffffff8000eb911d: neg r8d + _0xffffff8000eb9120: lea edi, [rdi + r8 + 0x153b18c3] + _0xffffff8000eb9128: xor edi, esi + _0xffffff8000eb912a: xor edi, 0x153b18c3 + _0xffffff8000eb9130: mov r8d, dword ptr [rbp - 0xa0] + _0xffffff8000eb9137: mov dword ptr [rbp - 0x360], r8d + _0xffffff8000eb913e: mov r8d, dword ptr [rbp - 0x9c] + _0xffffff8000eb9145: mov dword ptr [rbp - 0x364], r8d + _0xffffff8000eb914c: add edi, dword ptr [rbp - 0x360] + _0xffffff8000eb9152: mov r8d, edi + _0xffffff8000eb9155: xor r8d, 0x7adb6fbb + _0xffffff8000eb915c: add r8d, dword ptr [rcx] + _0xffffff8000eb915f: and edi, 0x7adb6fbb + _0xffffff8000eb9165: lea ecx, [r8 + rdi*2 + 0x5c8f34bd] + _0xffffff8000eb916d: mov edi, ecx + _0xffffff8000eb916f: shl edi, 8 + _0xffffff8000eb9172: and edi, 0x9f7bbf00 + _0xffffff8000eb9178: mov r8d, ecx + _0xffffff8000eb917b: shl r8d, 7 + _0xffffff8000eb917f: xor r8d, 0x4fbddfeb + _0xffffff8000eb9186: lea edi, [r8 + rdi - 0x4fbddfeb] + _0xffffff8000eb918e: mov r8d, ecx + _0xffffff8000eb9191: shr r8d, 0x18 + _0xffffff8000eb9195: and r8d, 0xd4 + _0xffffff8000eb919c: shr ecx, 0x19 + _0xffffff8000eb919f: add ecx, 0x266c836a + _0xffffff8000eb91a5: sub ecx, r8d + _0xffffff8000eb91a8: xor ecx, 0x266c836a + _0xffffff8000eb91ae: or ecx, edi + _0xffffff8000eb91b0: add ecx, eax + _0xffffff8000eb91b2: mov edi, edx + _0xffffff8000eb91b4: xor edi, eax + _0xffffff8000eb91b6: and edi, ecx + _0xffffff8000eb91b8: lea r8d, [rdi + rdi] + _0xffffff8000eb91bc: and r8d, 0x894467da + _0xffffff8000eb91c3: neg r8d + _0xffffff8000eb91c6: lea edi, [rdi + r8 + 0x44a233ed] + _0xffffff8000eb91ce: xor edi, edx + _0xffffff8000eb91d0: xor edi, 0x44a233ed + _0xffffff8000eb91d6: add edi, dword ptr [rbp - 0x364] + _0xffffff8000eb91dc: mov r8d, edi + _0xffffff8000eb91df: xor r8d, 0x777fd6bc + _0xffffff8000eb91e6: add r8d, esi + _0xffffff8000eb91e9: and edi, 0x777fd6bc + _0xffffff8000eb91ef: lea esi, [r8 + rdi*2 + 0x7147e09a] + _0xffffff8000eb91f7: mov edi, esi + _0xffffff8000eb91f9: shl edi, 0xd + _0xffffff8000eb91fc: and edi, 0xdfb7a000 + _0xffffff8000eb9202: mov r8d, esi + _0xffffff8000eb9205: shl r8d, 0xc + _0xffffff8000eb9209: xor r8d, 0x6fdbd7dd + _0xffffff8000eb9210: lea edi, [r8 + rdi - 0x6fdbd7dd] + _0xffffff8000eb9218: mov r8d, esi + _0xffffff8000eb921b: shr r8d, 0x13 + _0xffffff8000eb921f: and r8d, 0xb4a + _0xffffff8000eb9226: shr esi, 0x14 + _0xffffff8000eb9229: add esi, 0x21e615a5 + _0xffffff8000eb922f: sub esi, r8d + _0xffffff8000eb9232: xor esi, 0x21e615a5 + _0xffffff8000eb9238: or esi, edi + _0xffffff8000eb923a: add esi, ecx + _0xffffff8000eb923c: mov edi, ecx + _0xffffff8000eb923e: xor edi, eax + _0xffffff8000eb9240: and edi, esi + _0xffffff8000eb9242: lea r8d, [rdi + rdi] + _0xffffff8000eb9246: and r8d, 0xfd72c678 + _0xffffff8000eb924d: neg r8d + _0xffffff8000eb9250: lea edi, [rdi + r8 + 0x7eb9633c] + _0xffffff8000eb9258: xor edi, eax + _0xffffff8000eb925a: xor edi, 0x7eb9633c + _0xffffff8000eb9260: mov r8d, dword ptr [rbp - 0x98] + _0xffffff8000eb9267: mov dword ptr [rbp - 0x368], r8d + _0xffffff8000eb926e: add edi, r8d + _0xffffff8000eb9271: mov r8d, edi + _0xffffff8000eb9274: xor r8d, 0x7599e7ff + _0xffffff8000eb927b: add r8d, edx + _0xffffff8000eb927e: and edi, 0x7599e7ff + _0xffffff8000eb9284: lea edx, [r8 + rdi*2 - 0x51797724] + _0xffffff8000eb928c: mov edi, edx + _0xffffff8000eb928e: shl edi, 0x12 + _0xffffff8000eb9291: and edi, 0x3b3c0000 + _0xffffff8000eb9297: mov r8d, edx + _0xffffff8000eb929a: shl r8d, 0x11 + _0xffffff8000eb929e: xor r8d, 0x1d9ffffd + _0xffffff8000eb92a5: lea edi, [r8 + rdi - 0x1d9ffffd] + _0xffffff8000eb92ad: mov r8d, edx + _0xffffff8000eb92b0: shr r8d, 0xe + _0xffffff8000eb92b4: and r8d, 0x1b32c + _0xffffff8000eb92bb: shr edx, 0xf + _0xffffff8000eb92be: add edx, 0x7680d996 + _0xffffff8000eb92c4: sub edx, r8d + _0xffffff8000eb92c7: xor edx, 0x7680d996 + _0xffffff8000eb92cd: or edx, edi + _0xffffff8000eb92cf: add edx, esi + _0xffffff8000eb92d1: mov edi, esi + _0xffffff8000eb92d3: xor edi, ecx + _0xffffff8000eb92d5: and edi, edx + _0xffffff8000eb92d7: lea r8d, [rdi + rdi] + _0xffffff8000eb92db: and r8d, 0x75febf8c + _0xffffff8000eb92e2: neg r8d + _0xffffff8000eb92e5: lea edi, [rdi + r8 + 0x3aff5fc6] + _0xffffff8000eb92ed: xor edi, ecx + _0xffffff8000eb92ef: xor edi, 0x3aff5fc6 + _0xffffff8000eb92f5: mov r8d, dword ptr [rbp - 0x94] + _0xffffff8000eb92fc: mov dword ptr [rbp - 0x370], r8d + _0xffffff8000eb9303: add edi, r8d + _0xffffff8000eb9306: mov r8d, edi + _0xffffff8000eb9309: xor r8d, 0x757ff5af + _0xffffff8000eb9310: add r8d, eax + _0xffffff8000eb9313: and edi, 0x757ff5af + _0xffffff8000eb9319: lea eax, [r8 + rdi*2 + 0x4c3dd93f] + _0xffffff8000eb9321: mov edi, eax + _0xffffff8000eb9323: shr edi, 9 + _0xffffff8000eb9326: and edi, 0x57aa92 + _0xffffff8000eb932c: mov r8d, eax + _0xffffff8000eb932f: shr r8d, 0xa + _0xffffff8000eb9333: add r8d, 0x712bd549 + _0xffffff8000eb933a: sub r8d, edi + _0xffffff8000eb933d: xor r8d, 0x712bd549 + _0xffffff8000eb9344: shl eax, 0x16 + _0xffffff8000eb9347: or eax, r8d + _0xffffff8000eb934a: add eax, edx + _0xffffff8000eb934c: mov edi, edx + _0xffffff8000eb934e: xor edi, esi + _0xffffff8000eb9350: and edi, eax + _0xffffff8000eb9352: lea r8d, [rdi + rdi] + _0xffffff8000eb9356: and r8d, 0x97bb5686 + _0xffffff8000eb935d: neg r8d + _0xffffff8000eb9360: lea edi, [rdi + r8 + 0x4bddab43] + _0xffffff8000eb9368: xor edi, esi + _0xffffff8000eb936a: xor edi, 0x4bddab43 + _0xffffff8000eb9370: mov r8d, dword ptr [rbp - 0x90] + _0xffffff8000eb9377: mov dword ptr [rbp - 0x378], r8d + _0xffffff8000eb937e: add edi, r8d + _0xffffff8000eb9381: mov r8d, edi + _0xffffff8000eb9384: xor r8d, 0x772772fb + _0xffffff8000eb938b: add r8d, ecx + _0xffffff8000eb938e: and edi, 0x772772fb + _0xffffff8000eb9394: lea ecx, [r8 + rdi*2 + 0x7e549cb4] + _0xffffff8000eb939c: mov edi, ecx + _0xffffff8000eb939e: shl edi, 8 + _0xffffff8000eb93a1: and edi, 0xffefdb00 + _0xffffff8000eb93a7: mov r8d, ecx + _0xffffff8000eb93aa: shl r8d, 7 + _0xffffff8000eb93ae: xor r8d, 0x7ff7edbf + _0xffffff8000eb93b5: lea edi, [r8 + rdi - 0x7ff7edbf] + _0xffffff8000eb93bd: mov r8d, ecx + _0xffffff8000eb93c0: shr r8d, 0x18 + _0xffffff8000eb93c4: and r8d, 0x38 + _0xffffff8000eb93c8: shr ecx, 0x19 + _0xffffff8000eb93cb: add ecx, 0x4370011c + _0xffffff8000eb93d1: sub ecx, r8d + _0xffffff8000eb93d4: xor ecx, 0x4370011c + _0xffffff8000eb93da: or ecx, edi + _0xffffff8000eb93dc: add ecx, eax + _0xffffff8000eb93de: mov edi, eax + _0xffffff8000eb93e0: xor edi, edx + _0xffffff8000eb93e2: and edi, ecx + _0xffffff8000eb93e4: lea r8d, [rdi + rdi] + _0xffffff8000eb93e8: and r8d, 0x30ab5ce8 + _0xffffff8000eb93ef: neg r8d + _0xffffff8000eb93f2: lea edi, [rdi + r8 + 0x1855ae74] + _0xffffff8000eb93fa: xor edi, edx + _0xffffff8000eb93fc: xor edi, 0x1855ae74 + _0xffffff8000eb9402: mov r8d, dword ptr [rbp - 0x8c] + _0xffffff8000eb9409: add edi, r8d + _0xffffff8000eb940c: mov r9d, edi + _0xffffff8000eb940f: xor r9d, 0x5eff5fff + _0xffffff8000eb9416: add r9d, esi + _0xffffff8000eb9419: and edi, 0x5eff5fff + _0xffffff8000eb941f: lea esi, [r9 + rdi*2 - 0x177799d5] + _0xffffff8000eb9427: mov edi, esi + _0xffffff8000eb9429: shl edi, 0xd + _0xffffff8000eb942c: and edi, 0x2ffbc000 + _0xffffff8000eb9432: mov r9d, esi + _0xffffff8000eb9435: shl r9d, 0xc + _0xffffff8000eb9439: xor r9d, 0x17fde775 + _0xffffff8000eb9440: lea edi, [r9 + rdi - 0x17fde775] + _0xffffff8000eb9448: mov r9d, esi + _0xffffff8000eb944b: shr r9d, 0x13 + _0xffffff8000eb944f: and r9d, 0x1812 + _0xffffff8000eb9456: shr esi, 0x14 + _0xffffff8000eb9459: add esi, 0x53c39c09 + _0xffffff8000eb945f: sub esi, r9d + _0xffffff8000eb9462: xor esi, 0x53c39c09 + _0xffffff8000eb9468: or esi, edi + _0xffffff8000eb946a: add esi, ecx + _0xffffff8000eb946c: mov edi, ecx + _0xffffff8000eb946e: xor edi, eax + _0xffffff8000eb9470: and edi, esi + _0xffffff8000eb9472: lea r9d, [rdi + rdi] + _0xffffff8000eb9476: and r9d, 0x78da716c + _0xffffff8000eb947d: neg r9d + _0xffffff8000eb9480: lea edi, [rdi + r9 + 0x3c6d38b6] + _0xffffff8000eb9488: xor edi, eax + _0xffffff8000eb948a: xor edi, 0x3c6d38b6 + _0xffffff8000eb9490: mov r9d, dword ptr [rbp - 0x88] + _0xffffff8000eb9497: add edi, r9d + _0xffffff8000eb949a: mov r10d, edi + _0xffffff8000eb949d: xor r10d, 0x7cdfa377 + _0xffffff8000eb94a4: add r10d, edx + _0xffffff8000eb94a7: and edi, 0x7cdfa377 + _0xffffff8000eb94ad: lea edx, [r10 + rdi*2 + 0x2b50a29c] + _0xffffff8000eb94b5: mov edi, edx + _0xffffff8000eb94b7: shr edi, 0xe + _0xffffff8000eb94ba: and edi, 0x8c82 + _0xffffff8000eb94c0: mov r10d, edx + _0xffffff8000eb94c3: shr r10d, 0xf + _0xffffff8000eb94c7: add r10d, 0x6ffa4641 + _0xffffff8000eb94ce: sub r10d, edi + _0xffffff8000eb94d1: xor r10d, 0x6ffa4641 + _0xffffff8000eb94d8: shl edx, 0x11 + _0xffffff8000eb94db: or edx, r10d + _0xffffff8000eb94de: add edx, esi + _0xffffff8000eb94e0: mov edi, esi + _0xffffff8000eb94e2: xor edi, ecx + _0xffffff8000eb94e4: and edi, edx + _0xffffff8000eb94e6: lea r10d, [rdi + rdi] + _0xffffff8000eb94ea: and r10d, 0x313e2d92 + _0xffffff8000eb94f1: neg r10d + _0xffffff8000eb94f4: lea edi, [rdi + r10 + 0x189f16c9] + _0xffffff8000eb94fc: xor edi, ecx + _0xffffff8000eb94fe: xor edi, 0x189f16c9 + _0xffffff8000eb9504: mov r10d, dword ptr [rbp - 0x84] + _0xffffff8000eb950b: mov dword ptr [rbp - 0x36c], r10d + _0xffffff8000eb9512: add edi, r10d + _0xffffff8000eb9515: mov r10d, edi + _0xffffff8000eb9518: xor r10d, 0x7bdfbf5e + _0xffffff8000eb951f: add r10d, eax + _0xffffff8000eb9522: and edi, 0x7bdfbf5e + _0xffffff8000eb9528: lea eax, [r10 + rdi*2 - 0x7e992a5d] + _0xffffff8000eb9530: mov edi, eax + _0xffffff8000eb9532: shl edi, 0x17 + _0xffffff8000eb9535: and edi, 0xdb800000 + _0xffffff8000eb953b: mov r10d, eax + _0xffffff8000eb953e: shl r10d, 0x16 + _0xffffff8000eb9542: xor r10d, 0x6debeb7e + _0xffffff8000eb9549: lea edi, [r10 + rdi - 0x6debeb7e] + _0xffffff8000eb9551: mov r10d, eax + _0xffffff8000eb9554: shr r10d, 9 + _0xffffff8000eb9558: and r10d, 0x1e48ce + _0xffffff8000eb955f: shr eax, 0xa + _0xffffff8000eb9562: add eax, 0x3a4f2467 + _0xffffff8000eb9567: sub eax, r10d + _0xffffff8000eb956a: xor eax, 0x3a4f2467 + _0xffffff8000eb956f: or eax, edi + _0xffffff8000eb9571: add eax, edx + _0xffffff8000eb9573: mov edi, edx + _0xffffff8000eb9575: xor edi, esi + _0xffffff8000eb9577: and edi, eax + _0xffffff8000eb9579: lea r10d, [rdi + rdi] + _0xffffff8000eb957d: and r10d, 0x705ad12a + _0xffffff8000eb9584: neg r10d + _0xffffff8000eb9587: lea edi, [rdi + r10 + 0x382d6895] + _0xffffff8000eb958f: xor edi, esi + _0xffffff8000eb9591: xor edi, 0x382d6895 + _0xffffff8000eb9597: mov r10d, dword ptr [rbp - 0x80] + _0xffffff8000eb959b: mov dword ptr [rbp - 0x37c], r10d + _0xffffff8000eb95a2: add edi, r10d + _0xffffff8000eb95a5: mov r10d, edi + _0xffffff8000eb95a8: xor r10d, 0x7fd74fe7 + _0xffffff8000eb95af: add r10d, ecx + _0xffffff8000eb95b2: and edi, 0x7fd74fe7 + _0xffffff8000eb95b8: lea ecx, [r10 + rdi*2 - 0x1656b70f] + _0xffffff8000eb95c0: mov edi, ecx + _0xffffff8000eb95c2: shr edi, 0x18 + _0xffffff8000eb95c5: and edi, 0xa2 + _0xffffff8000eb95cb: mov r10d, ecx + _0xffffff8000eb95ce: shr r10d, 0x19 + _0xffffff8000eb95d2: add r10d, 0x7ec2a0d1 + _0xffffff8000eb95d9: sub r10d, edi + _0xffffff8000eb95dc: xor r10d, 0x7ec2a0d1 + _0xffffff8000eb95e3: shl ecx, 7 + _0xffffff8000eb95e6: or ecx, r10d + _0xffffff8000eb95e9: add ecx, eax + _0xffffff8000eb95eb: mov edi, eax + _0xffffff8000eb95ed: xor edi, edx + _0xffffff8000eb95ef: and edi, ecx + _0xffffff8000eb95f1: lea r10d, [rdi + rdi] + _0xffffff8000eb95f5: and r10d, 0xb118b98c + _0xffffff8000eb95fc: neg r10d + _0xffffff8000eb95ff: lea edi, [rdi + r10 + 0x588c5cc6] + _0xffffff8000eb9607: xor edi, edx + _0xffffff8000eb9609: xor edi, 0x588c5cc6 + _0xffffff8000eb960f: mov r10d, dword ptr [rbp - 0x7c] + _0xffffff8000eb9613: add edi, r10d + _0xffffff8000eb9616: mov r11d, edi + _0xffffff8000eb9619: xor r11d, 0x725fcbfe + _0xffffff8000eb9620: add r11d, esi + _0xffffff8000eb9623: and edi, 0x725fcbfe + _0xffffff8000eb9629: lea esi, [r11 + rdi*2 + 0x18e52bb1] + _0xffffff8000eb9631: mov edi, esi + _0xffffff8000eb9633: shr edi, 0x13 + _0xffffff8000eb9636: and edi, 0x197a + _0xffffff8000eb963c: mov r11d, esi + _0xffffff8000eb963f: shr r11d, 0x14 + _0xffffff8000eb9643: add r11d, 0x4d602cbd + _0xffffff8000eb964a: sub r11d, edi + _0xffffff8000eb964d: xor r11d, 0x4d602cbd + _0xffffff8000eb9654: shl esi, 0xc + _0xffffff8000eb9657: or esi, r11d + _0xffffff8000eb965a: add esi, ecx + _0xffffff8000eb965c: mov edi, ecx + _0xffffff8000eb965e: xor edi, eax + _0xffffff8000eb9660: and edi, esi + _0xffffff8000eb9662: lea r11d, [rdi + rdi] + _0xffffff8000eb9666: and r11d, 0xacb77130 + _0xffffff8000eb966d: neg r11d + _0xffffff8000eb9670: lea edi, [rdi + r11 + 0x565bb898] + _0xffffff8000eb9678: xor edi, eax + _0xffffff8000eb967a: xor edi, 0x565bb898 + _0xffffff8000eb9680: mov r11d, dword ptr [rbp - 0x78] + _0xffffff8000eb9684: add edi, r11d + _0xffffff8000eb9687: mov ebx, edi + _0xffffff8000eb9689: xor ebx, 0x6d0af7ff + _0xffffff8000eb968f: add ebx, edx + _0xffffff8000eb9691: and edi, 0x6d0af7ff + _0xffffff8000eb9697: lea edx, [rbx + rdi*2 - 0x6d0b9c4e] + _0xffffff8000eb969e: mov edi, edx + _0xffffff8000eb96a0: shl edi, 0x12 + _0xffffff8000eb96a3: and edi, 0x43fc0000 + _0xffffff8000eb96a9: mov ebx, edx + _0xffffff8000eb96ab: shl ebx, 0x11 + _0xffffff8000eb96ae: xor ebx, 0x21fef9fb + _0xffffff8000eb96b4: lea edi, [rbx + rdi - 0x21fef9fb] + _0xffffff8000eb96bb: mov ebx, edx + _0xffffff8000eb96bd: shr ebx, 0xe + _0xffffff8000eb96c0: and ebx, 0x39168 + _0xffffff8000eb96c6: shr edx, 0xf + _0xffffff8000eb96c9: add edx, 0x3871c8b4 + _0xffffff8000eb96cf: sub edx, ebx + _0xffffff8000eb96d1: xor edx, 0x3871c8b4 + _0xffffff8000eb96d7: or edx, edi + _0xffffff8000eb96d9: add edx, esi + _0xffffff8000eb96db: mov edi, esi + _0xffffff8000eb96dd: xor edi, ecx + _0xffffff8000eb96df: and edi, edx + _0xffffff8000eb96e1: lea ebx, [rdi + rdi] + _0xffffff8000eb96e4: and ebx, 0xae9c67c2 + _0xffffff8000eb96ea: neg ebx + _0xffffff8000eb96ec: lea edi, [rdi + rbx + 0x574e33e1] + _0xffffff8000eb96f3: xor edi, ecx + _0xffffff8000eb96f5: xor edi, 0x574e33e1 + _0xffffff8000eb96fb: mov ebx, dword ptr [rbp - 0x74] + _0xffffff8000eb96fe: add edi, ebx + _0xffffff8000eb9700: mov r14d, edi + _0xffffff8000eb9703: xor r14d, 0x6ebfd6ff + _0xffffff8000eb970a: add r14d, eax + _0xffffff8000eb970d: and edi, 0x6ebfd6ff + _0xffffff8000eb9713: lea eax, [r14 + rdi*2 + 0x1a9d00bf] + _0xffffff8000eb971b: mov edi, eax + _0xffffff8000eb971d: shr edi, 9 + _0xffffff8000eb9720: and edi, 0x576e28 + _0xffffff8000eb9726: mov r14d, eax + _0xffffff8000eb9729: shr r14d, 0xa + _0xffffff8000eb972d: add r14d, 0x792bb714 + _0xffffff8000eb9734: sub r14d, edi + _0xffffff8000eb9737: xor r14d, 0x792bb714 + _0xffffff8000eb973e: shl eax, 0x16 + _0xffffff8000eb9741: or eax, r14d + _0xffffff8000eb9744: add eax, edx + _0xffffff8000eb9746: mov edi, edx + _0xffffff8000eb9748: xor edi, esi + _0xffffff8000eb974a: and edi, eax + _0xffffff8000eb974c: lea r14d, [rdi + rdi] + _0xffffff8000eb9750: and r14d, 0xcc12f898 + _0xffffff8000eb9757: neg r14d + _0xffffff8000eb975a: lea edi, [rdi + r14 + 0x66097c4c] + _0xffffff8000eb9762: xor edi, esi + _0xffffff8000eb9764: xor edi, 0x66097c4c + _0xffffff8000eb976a: mov r14d, dword ptr [rbp - 0x70] + _0xffffff8000eb976e: mov dword ptr [rbp - 0x374], r14d + _0xffffff8000eb9775: add edi, r14d + _0xffffff8000eb9778: mov r14d, edi + _0xffffff8000eb977b: xor r14d, 0x75dfffbd + _0xffffff8000eb9782: add r14d, ecx + _0xffffff8000eb9785: and edi, 0x75dfffbd + _0xffffff8000eb978b: lea ecx, [r14 + rdi*2 - 0xa4fee9b] + _0xffffff8000eb9793: mov edi, ecx + _0xffffff8000eb9795: shr edi, 0x18 + _0xffffff8000eb9798: and edi, 0x42 + _0xffffff8000eb979b: mov r14d, ecx + _0xffffff8000eb979e: shr r14d, 0x19 + _0xffffff8000eb97a2: add r14d, 0x11b0c7a1 + _0xffffff8000eb97a9: sub r14d, edi + _0xffffff8000eb97ac: xor r14d, 0x11b0c7a1 + _0xffffff8000eb97b3: shl ecx, 7 + _0xffffff8000eb97b6: or ecx, r14d + _0xffffff8000eb97b9: add ecx, eax + _0xffffff8000eb97bb: mov edi, eax + _0xffffff8000eb97bd: xor edi, edx + _0xffffff8000eb97bf: and edi, ecx + _0xffffff8000eb97c1: lea r14d, [rdi + rdi] + _0xffffff8000eb97c5: and r14d, 0x15eac9a8 + _0xffffff8000eb97cc: neg r14d + _0xffffff8000eb97cf: lea edi, [rdi + r14 + 0xaf564d4] + _0xffffff8000eb97d7: xor edi, edx + _0xffffff8000eb97d9: xor edi, 0xaf564d4 + _0xffffff8000eb97df: mov r14d, dword ptr [rbp - 0x6c] + _0xffffff8000eb97e3: add edi, r14d + _0xffffff8000eb97e6: mov r15d, edi + _0xffffff8000eb97e9: xor r15d, 0x3febcd65 + _0xffffff8000eb97f0: add r15d, esi + _0xffffff8000eb97f3: and edi, 0x3febcd65 + _0xffffff8000eb97f9: lea esi, [r15 + rdi*2 - 0x42535bd2] + _0xffffff8000eb9801: mov edi, esi + _0xffffff8000eb9803: shr edi, 0x13 + _0xffffff8000eb9806: and edi, 0x153c + _0xffffff8000eb980c: mov r15d, esi + _0xffffff8000eb980f: shr r15d, 0x14 + _0xffffff8000eb9813: add r15d, 0x119a9e + _0xffffff8000eb981a: sub r15d, edi + _0xffffff8000eb981d: xor r15d, 0x119a9e + _0xffffff8000eb9824: shl esi, 0xc + _0xffffff8000eb9827: or esi, r15d + _0xffffff8000eb982a: add esi, ecx + _0xffffff8000eb982c: mov edi, ecx + _0xffffff8000eb982e: xor edi, eax + _0xffffff8000eb9830: and edi, esi + _0xffffff8000eb9832: lea r15d, [rdi + rdi] + _0xffffff8000eb9836: and r15d, 0x8e5ea05c + _0xffffff8000eb983d: neg r15d + _0xffffff8000eb9840: lea edi, [rdi + r15 + 0x472f502e] + _0xffffff8000eb9848: xor edi, eax + _0xffffff8000eb984a: xor edi, 0x472f502e + _0xffffff8000eb9850: mov r15d, dword ptr [rbp - 0x68] + _0xffffff8000eb9854: add edi, r15d + _0xffffff8000eb9857: mov r12d, edi + _0xffffff8000eb985a: xor r12d, 0x3fe7d132 + _0xffffff8000eb9861: add r12d, edx + _0xffffff8000eb9864: and edi, 0x3fe7d132 + _0xffffff8000eb986a: lea edx, [r12 + rdi*2 + 0x6691725c] + _0xffffff8000eb9872: mov edi, edx + _0xffffff8000eb9874: shr edi, 0xe + _0xffffff8000eb9877: and edi, 0x23240 + _0xffffff8000eb987d: mov r12d, edx + _0xffffff8000eb9880: shr r12d, 0xf + _0xffffff8000eb9884: add r12d, 0x7b791920 + _0xffffff8000eb988b: sub r12d, edi + _0xffffff8000eb988e: xor r12d, 0x7b791920 + _0xffffff8000eb9895: shl edx, 0x11 + _0xffffff8000eb9898: or edx, r12d + _0xffffff8000eb989b: add edx, esi + _0xffffff8000eb989d: mov edi, esi + _0xffffff8000eb989f: xor edi, ecx + _0xffffff8000eb98a1: and edi, edx + _0xffffff8000eb98a3: lea r12d, [rdi + rdi] + _0xffffff8000eb98a7: and r12d, 0xe9dead2e + _0xffffff8000eb98ae: neg r12d + _0xffffff8000eb98b1: lea edi, [rdi + r12 + 0x74ef5697] + _0xffffff8000eb98b9: xor edi, ecx + _0xffffff8000eb98bb: xor edi, 0x74ef5697 + _0xffffff8000eb98c1: mov r12d, dword ptr [rbp - 0x64] + _0xffffff8000eb98c5: add edi, r12d + _0xffffff8000eb98c8: mov r13d, edi + _0xffffff8000eb98cb: xor r13d, 0x5fa3ffef + _0xffffff8000eb98d2: add r13d, eax + _0xffffff8000eb98d5: and edi, 0x5fa3ffef + _0xffffff8000eb98db: lea eax, [r13 + rdi*2 - 0x15eff7ce] + _0xffffff8000eb98e3: mov edi, eax + _0xffffff8000eb98e5: shl edi, 0x17 + _0xffffff8000eb98e8: and edi, 0xef800000 + _0xffffff8000eb98ee: mov r13d, eax + _0xffffff8000eb98f1: shl r13d, 0x16 + _0xffffff8000eb98f5: xor r13d, 0x77eb6bcf + _0xffffff8000eb98fc: lea edi, [r13 + rdi - 0x77eb6bcf] + _0xffffff8000eb9904: mov r13d, eax + _0xffffff8000eb9907: shr r13d, 9 + _0xffffff8000eb990b: and r13d, 0x6c6308 + _0xffffff8000eb9912: shr eax, 0xa + _0xffffff8000eb9915: add eax, 0x43363184 + _0xffffff8000eb991a: sub eax, r13d + _0xffffff8000eb991d: xor eax, 0x43363184 + _0xffffff8000eb9922: or eax, edi + _0xffffff8000eb9924: add eax, edx + _0xffffff8000eb9926: mov edi, eax + _0xffffff8000eb9928: xor edi, edx + _0xffffff8000eb992a: and edi, esi + _0xffffff8000eb992c: lea r13d, [rdi + rdi] + _0xffffff8000eb9930: and r13d, 0x1764f7a0 + _0xffffff8000eb9937: neg r13d + _0xffffff8000eb993a: lea edi, [rdi + r13 + 0xbb27bd0] + _0xffffff8000eb9942: xor edi, edx + _0xffffff8000eb9944: xor edi, 0xbb27bd0 + _0xffffff8000eb994a: add edi, dword ptr [rbp - 0x364] + _0xffffff8000eb9950: mov r13d, edi + _0xffffff8000eb9953: xor r13d, 0x6feff7f8 + _0xffffff8000eb995a: add r13d, ecx + _0xffffff8000eb995d: and edi, 0x6feff7f8 + _0xffffff8000eb9963: lea ecx, [r13 + rdi*2 - 0x79d1d296] + _0xffffff8000eb996b: mov edi, ecx + _0xffffff8000eb996d: shr edi, 0x1b + _0xffffff8000eb9970: mov r13d, edi + _0xffffff8000eb9973: or r13d, 0x6ffb6ff9 + _0xffffff8000eb997a: xor r13d, edi + _0xffffff8000eb997d: xor r13d, 0x6ffb6ff9 + _0xffffff8000eb9984: add r13d, r13d + _0xffffff8000eb9987: neg r13d + _0xffffff8000eb998a: lea edi, [rdi + r13 + 0x432967f9] + _0xffffff8000eb9992: xor edi, 0x432967f9 + _0xffffff8000eb9998: shl ecx, 5 + _0xffffff8000eb999b: or ecx, edi + _0xffffff8000eb999d: add ecx, eax + _0xffffff8000eb999f: mov edi, ecx + _0xffffff8000eb99a1: xor edi, eax + _0xffffff8000eb99a3: and edi, edx + _0xffffff8000eb99a5: lea r13d, [rdi + rdi] + _0xffffff8000eb99a9: and r13d, 0x4bd8d424 + _0xffffff8000eb99b0: neg r13d + _0xffffff8000eb99b3: lea edi, [rdi + r13 + 0x25ec6a12] + _0xffffff8000eb99bb: xor edi, eax + _0xffffff8000eb99bd: xor edi, 0x25ec6a12 + _0xffffff8000eb99c3: add edi, r9d + _0xffffff8000eb99c6: mov r9d, edi + _0xffffff8000eb99c9: xor r9d, 0x2fff9bf7 + _0xffffff8000eb99d0: add r9d, esi + _0xffffff8000eb99d3: and edi, 0x2fff9bf7 + _0xffffff8000eb99d9: lea esi, [r9 + rdi*2 - 0x6fbee8b7] + _0xffffff8000eb99e1: mov edi, esi + _0xffffff8000eb99e3: shl edi, 0xa + _0xffffff8000eb99e6: and edi, 0xcfcac400 + _0xffffff8000eb99ec: mov r9d, esi + _0xffffff8000eb99ef: shl r9d, 9 + _0xffffff8000eb99f3: xor r9d, 0x67e563bd + _0xffffff8000eb99fa: lea edi, [r9 + rdi - 0x67e563bd] + _0xffffff8000eb9a02: mov r9d, esi + _0xffffff8000eb9a05: shr r9d, 0x16 + _0xffffff8000eb9a09: and r9d, 0x1d4 + _0xffffff8000eb9a10: shr esi, 0x17 + _0xffffff8000eb9a13: add esi, 0x4c6318ea + _0xffffff8000eb9a19: sub esi, r9d + _0xffffff8000eb9a1c: xor esi, 0x4c6318ea + _0xffffff8000eb9a22: or esi, edi + _0xffffff8000eb9a24: add esi, ecx + _0xffffff8000eb9a26: mov edi, esi + _0xffffff8000eb9a28: xor edi, ecx + _0xffffff8000eb9a2a: and edi, eax + _0xffffff8000eb9a2c: lea r9d, [rdi + rdi] + _0xffffff8000eb9a30: and r9d, 0x4ef55154 + _0xffffff8000eb9a37: neg r9d + _0xffffff8000eb9a3a: lea edi, [rdi + r9 + 0x277aa8aa] + _0xffffff8000eb9a42: xor edi, ecx + _0xffffff8000eb9a44: xor edi, 0x277aa8aa + _0xffffff8000eb9a4a: add edi, ebx + _0xffffff8000eb9a4c: mov r9d, edi + _0xffffff8000eb9a4f: xor r9d, 0x3ddffb76 + _0xffffff8000eb9a56: add r9d, edx + _0xffffff8000eb9a59: and edi, 0x3ddffb76 + _0xffffff8000eb9a5f: lea edx, [r9 + rdi*2 - 0x1781a125] + _0xffffff8000eb9a67: mov edi, edx + _0xffffff8000eb9a69: shr edi, 0x11 + _0xffffff8000eb9a6c: and edi, 0xc42 + _0xffffff8000eb9a72: mov r9d, edx + _0xffffff8000eb9a75: shr r9d, 0x12 + _0xffffff8000eb9a79: add r9d, 0x4348c621 + _0xffffff8000eb9a80: sub r9d, edi + _0xffffff8000eb9a83: xor r9d, 0x4348c621 + _0xffffff8000eb9a8a: shl edx, 0xe + _0xffffff8000eb9a8d: or edx, r9d + _0xffffff8000eb9a90: add edx, esi + _0xffffff8000eb9a92: mov edi, edx + _0xffffff8000eb9a94: xor edi, esi + _0xffffff8000eb9a96: and edi, ecx + _0xffffff8000eb9a98: lea r9d, [rdi + rdi] + _0xffffff8000eb9a9c: and r9d, 0x4f55c8ba + _0xffffff8000eb9aa3: neg r9d + _0xffffff8000eb9aa6: lea edi, [rdi + r9 + 0x27aae45d] + _0xffffff8000eb9aae: xor edi, esi + _0xffffff8000eb9ab0: xor edi, 0x27aae45d + _0xffffff8000eb9ab6: add edi, dword ptr [rbp - 0x360] + _0xffffff8000eb9abc: mov r9d, edi + _0xffffff8000eb9abf: xor r9d, 0x6cfcd9fb + _0xffffff8000eb9ac6: add r9d, eax + _0xffffff8000eb9ac9: and edi, 0x6cfcd9fb + _0xffffff8000eb9acf: lea eax, [r9 + rdi*2 + 0x7cb9edaf] + _0xffffff8000eb9ad7: mov edi, eax + _0xffffff8000eb9ad9: shl edi, 0x15 + _0xffffff8000eb9adc: and edi, 0xbbe00000 + _0xffffff8000eb9ae2: mov r9d, eax + _0xffffff8000eb9ae5: shl r9d, 0x14 + _0xffffff8000eb9ae9: xor r9d, 0x5dff6afc + _0xffffff8000eb9af0: lea edi, [r9 + rdi - 0x5dff6afc] + _0xffffff8000eb9af8: mov r9d, eax + _0xffffff8000eb9afb: shr r9d, 0xb + _0xffffff8000eb9aff: and r9d, 0x10c80 + _0xffffff8000eb9b06: shr eax, 0xc + _0xffffff8000eb9b09: add eax, 0x4b208640 + _0xffffff8000eb9b0e: sub eax, r9d + _0xffffff8000eb9b11: xor eax, 0x4b208640 + _0xffffff8000eb9b16: or eax, edi + _0xffffff8000eb9b18: add eax, edx + _0xffffff8000eb9b1a: mov edi, eax + _0xffffff8000eb9b1c: xor edi, edx + _0xffffff8000eb9b1e: and edi, esi + _0xffffff8000eb9b20: lea r9d, [rdi + rdi] + _0xffffff8000eb9b24: and r9d, 0x7abd61f4 + _0xffffff8000eb9b2b: neg r9d + _0xffffff8000eb9b2e: lea edi, [rdi + r9 + 0x3d5eb0fa] + _0xffffff8000eb9b36: xor edi, edx + _0xffffff8000eb9b38: xor edi, 0x3d5eb0fa + _0xffffff8000eb9b3e: add edi, r8d + _0xffffff8000eb9b41: mov r8d, edi + _0xffffff8000eb9b44: xor r8d, 0x7e624ff7 + _0xffffff8000eb9b4b: add r8d, ecx + _0xffffff8000eb9b4e: and edi, 0x7e624ff7 + _0xffffff8000eb9b54: lea ecx, [r8 + rdi*2 + 0x57ccc066] + _0xffffff8000eb9b5c: mov edi, ecx + _0xffffff8000eb9b5e: shr edi, 0x1b + _0xffffff8000eb9b61: mov r8d, edi + _0xffffff8000eb9b64: or r8d, 0x7f69f6d7 + _0xffffff8000eb9b6b: xor r8d, edi + _0xffffff8000eb9b6e: xor r8d, 0x7f69f6d7 + _0xffffff8000eb9b75: add r8d, r8d + _0xffffff8000eb9b78: neg r8d + _0xffffff8000eb9b7b: lea edi, [rdi + r8 + 0x4201d6d7] + _0xffffff8000eb9b83: xor edi, 0x4201d6d7 + _0xffffff8000eb9b89: shl ecx, 5 + _0xffffff8000eb9b8c: or ecx, edi + _0xffffff8000eb9b8e: add ecx, eax + _0xffffff8000eb9b90: mov edi, ecx + _0xffffff8000eb9b92: xor edi, eax + _0xffffff8000eb9b94: and edi, edx + _0xffffff8000eb9b96: lea r8d, [rdi + rdi] + _0xffffff8000eb9b9a: and r8d, 0x1f9d2152 + _0xffffff8000eb9ba1: neg r8d + _0xffffff8000eb9ba4: lea edi, [rdi + r8 + 0xfce90a9] + _0xffffff8000eb9bac: xor edi, eax + _0xffffff8000eb9bae: xor edi, 0xfce90a9 + _0xffffff8000eb9bb4: add edi, r11d + _0xffffff8000eb9bb7: mov r8d, edi + _0xffffff8000eb9bba: xor r8d, 0x5bbf9cf7 + _0xffffff8000eb9bc1: add r8d, esi + _0xffffff8000eb9bc4: and edi, 0x5bbf9cf7 + _0xffffff8000eb9bca: lea esi, [r8 + rdi*2 - 0x597b88a4] + _0xffffff8000eb9bd2: mov edi, esi + _0xffffff8000eb9bd4: shl edi, 0xa + _0xffffff8000eb9bd7: and edi, 0x7b76f400 + _0xffffff8000eb9bdd: mov r8d, esi + _0xffffff8000eb9be0: shl r8d, 9 + _0xffffff8000eb9be4: xor r8d, 0x3dbb7b07 + _0xffffff8000eb9beb: lea edi, [r8 + rdi - 0x3dbb7b07] + _0xffffff8000eb9bf3: mov r8d, esi + _0xffffff8000eb9bf6: shr r8d, 0x16 + _0xffffff8000eb9bfa: and r8d, 0x3c8 + _0xffffff8000eb9c01: shr esi, 0x17 + _0xffffff8000eb9c04: add esi, 0x253695e4 + _0xffffff8000eb9c0a: sub esi, r8d + _0xffffff8000eb9c0d: xor esi, 0x253695e4 + _0xffffff8000eb9c13: or esi, edi + _0xffffff8000eb9c15: add esi, ecx + _0xffffff8000eb9c17: mov edi, esi + _0xffffff8000eb9c19: xor edi, ecx + _0xffffff8000eb9c1b: and edi, eax + _0xffffff8000eb9c1d: lea r8d, [rdi + rdi] + _0xffffff8000eb9c21: and r8d, 0x349a0f6 + _0xffffff8000eb9c28: neg r8d + _0xffffff8000eb9c2b: lea edi, [rdi + r8 + 0x1a4d07b] + _0xffffff8000eb9c33: xor edi, ecx + _0xffffff8000eb9c35: xor edi, 0x1a4d07b + _0xffffff8000eb9c3b: add edi, r12d + _0xffffff8000eb9c3e: mov r8d, edi + _0xffffff8000eb9c41: xor r8d, 0x7ff7ffee + _0xffffff8000eb9c48: add r8d, edx + _0xffffff8000eb9c4b: and edi, 0x7ff7ffee + _0xffffff8000eb9c51: lea edx, [r8 + rdi*2 + 0x58a9e693] + _0xffffff8000eb9c59: mov edi, edx + _0xffffff8000eb9c5b: shl edi, 0xf + _0xffffff8000eb9c5e: and edi, 0xc7a90000 + _0xffffff8000eb9c64: mov r8d, edx + _0xffffff8000eb9c67: shl r8d, 0xe + _0xffffff8000eb9c6b: xor r8d, 0x63d4b5fd + _0xffffff8000eb9c72: lea edi, [r8 + rdi - 0x63d4b5fd] + _0xffffff8000eb9c7a: mov r8d, edx + _0xffffff8000eb9c7d: shr r8d, 0x11 + _0xffffff8000eb9c81: and r8d, 0x5e38 + _0xffffff8000eb9c88: shr edx, 0x12 + _0xffffff8000eb9c8b: add edx, 0x65f2ef1c + _0xffffff8000eb9c91: sub edx, r8d + _0xffffff8000eb9c94: xor edx, 0x65f2ef1c + _0xffffff8000eb9c9a: or edx, edi + _0xffffff8000eb9c9c: add edx, esi + _0xffffff8000eb9c9e: mov edi, edx + _0xffffff8000eb9ca0: xor edi, esi + _0xffffff8000eb9ca2: and edi, ecx + _0xffffff8000eb9ca4: lea r8d, [rdi + rdi] + _0xffffff8000eb9ca8: and r8d, 0x994b17c + _0xffffff8000eb9caf: neg r8d + _0xffffff8000eb9cb2: lea edi, [rdi + r8 + 0x4ca58be] + _0xffffff8000eb9cba: xor edi, esi + _0xffffff8000eb9cbc: xor edi, 0x4ca58be + _0xffffff8000eb9cc2: add edi, dword ptr [rbp - 0x378] + _0xffffff8000eb9cc8: mov r8d, edi + _0xffffff8000eb9ccb: xor r8d, 0x5a5f7f5d + _0xffffff8000eb9cd2: add r8d, eax + _0xffffff8000eb9cd5: and edi, 0x5a5f7f5d + _0xffffff8000eb9cdb: lea eax, [r8 + rdi*2 - 0x728b8395] + _0xffffff8000eb9ce3: mov edi, eax + _0xffffff8000eb9ce5: shr edi, 0xb + _0xffffff8000eb9ce8: and edi, 0x1ff660 + _0xffffff8000eb9cee: mov r8d, eax + _0xffffff8000eb9cf1: shr r8d, 0xc + _0xffffff8000eb9cf5: add r8d, 0x17ffb30 + _0xffffff8000eb9cfc: sub r8d, edi + _0xffffff8000eb9cff: xor r8d, 0x17ffb30 + _0xffffff8000eb9d06: shl eax, 0x14 + _0xffffff8000eb9d09: or eax, r8d + _0xffffff8000eb9d0c: add eax, edx + _0xffffff8000eb9d0e: mov edi, eax + _0xffffff8000eb9d10: xor edi, edx + _0xffffff8000eb9d12: and edi, esi + _0xffffff8000eb9d14: lea r8d, [rdi + rdi] + _0xffffff8000eb9d18: and r8d, 0x63a14442 + _0xffffff8000eb9d1f: neg r8d + _0xffffff8000eb9d22: lea edi, [rdi + r8 + 0x31d0a221] + _0xffffff8000eb9d2a: xor edi, edx + _0xffffff8000eb9d2c: xor edi, 0x31d0a221 + _0xffffff8000eb9d32: add edi, r10d + _0xffffff8000eb9d35: mov r8d, edi + _0xffffff8000eb9d38: xor r8d, 0x36bfeeef + _0xffffff8000eb9d3f: add r8d, ecx + _0xffffff8000eb9d42: and edi, 0x36bfeeef + _0xffffff8000eb9d48: lea ecx, [r8 + rdi*2 - 0x14de2109] + _0xffffff8000eb9d50: mov edi, ecx + _0xffffff8000eb9d52: shr edi, 0x1b + _0xffffff8000eb9d55: mov r8d, edi + _0xffffff8000eb9d58: or r8d, 0x57fdfd5d + _0xffffff8000eb9d5f: xor r8d, edi + _0xffffff8000eb9d62: xor r8d, 0x57fdfd5d + _0xffffff8000eb9d69: add r8d, r8d + _0xffffff8000eb9d6c: neg r8d + _0xffffff8000eb9d6f: lea edi, [rdi + r8 + 0x10edfc5d] + _0xffffff8000eb9d77: xor edi, 0x10edfc5d + _0xffffff8000eb9d7d: shl ecx, 5 + _0xffffff8000eb9d80: or ecx, edi + _0xffffff8000eb9d82: add ecx, eax + _0xffffff8000eb9d84: mov edi, ecx + _0xffffff8000eb9d86: xor edi, eax + _0xffffff8000eb9d88: and edi, edx + _0xffffff8000eb9d8a: lea r8d, [rdi + rdi] + _0xffffff8000eb9d8e: and r8d, 0x4d3c4446 + _0xffffff8000eb9d95: neg r8d + _0xffffff8000eb9d98: lea edi, [rdi + r8 + 0x269e2223] + _0xffffff8000eb9da0: xor edi, eax + _0xffffff8000eb9da2: xor edi, 0x269e2223 + _0xffffff8000eb9da8: add edi, r15d + _0xffffff8000eb9dab: mov r8d, edi + _0xffffff8000eb9dae: xor r8d, 0x786fcd9d + _0xffffff8000eb9db5: add r8d, esi + _0xffffff8000eb9db8: and edi, 0x786fcd9d + _0xffffff8000eb9dbe: lea esi, [r8 + rdi*2 + 0x4ac73a39] + _0xffffff8000eb9dc6: mov edi, esi + _0xffffff8000eb9dc8: shr edi, 0x16 + _0xffffff8000eb9dcb: and edi, 0x33e + _0xffffff8000eb9dd1: mov r8d, esi + _0xffffff8000eb9dd4: shr r8d, 0x17 + _0xffffff8000eb9dd8: add r8d, 0x22178d9f + _0xffffff8000eb9ddf: sub r8d, edi + _0xffffff8000eb9de2: xor r8d, 0x22178d9f + _0xffffff8000eb9de9: shl esi, 9 + _0xffffff8000eb9dec: or esi, r8d + _0xffffff8000eb9def: add esi, ecx + _0xffffff8000eb9df1: mov edi, esi + _0xffffff8000eb9df3: xor edi, ecx + _0xffffff8000eb9df5: and edi, eax + _0xffffff8000eb9df7: lea r8d, [rdi + rdi] + _0xffffff8000eb9dfb: and r8d, 0xf918609c + _0xffffff8000eb9e02: neg r8d + _0xffffff8000eb9e05: lea edi, [rdi + r8 + 0x7c8c304e] + _0xffffff8000eb9e0d: xor edi, ecx + _0xffffff8000eb9e0f: xor edi, 0x7c8c304e + _0xffffff8000eb9e15: add edi, dword ptr [rbp - 0x370] + _0xffffff8000eb9e1b: mov r8d, edi + _0xffffff8000eb9e1e: xor r8d, 0x3eaffbfe + _0xffffff8000eb9e25: add r8d, edx + _0xffffff8000eb9e28: and edi, 0x3eaffbfe + _0xffffff8000eb9e2e: lea edx, [r8 + rdi*2 - 0x49daee77] + _0xffffff8000eb9e36: mov edi, edx + _0xffffff8000eb9e38: shl edi, 0xf + _0xffffff8000eb9e3b: and edi, 0xfbff8000 + _0xffffff8000eb9e41: mov r8d, edx + _0xffffff8000eb9e44: shl r8d, 0xe + _0xffffff8000eb9e48: xor r8d, 0x7dffdf5f + _0xffffff8000eb9e4f: lea edi, [r8 + rdi - 0x7dffdf5f] + _0xffffff8000eb9e57: mov r8d, edx + _0xffffff8000eb9e5a: shr r8d, 0x11 + _0xffffff8000eb9e5e: and r8d, 0x7e9a + _0xffffff8000eb9e65: shr edx, 0x12 + _0xffffff8000eb9e68: add edx, 0x6c9d7f4d + _0xffffff8000eb9e6e: sub edx, r8d + _0xffffff8000eb9e71: xor edx, 0x6c9d7f4d + _0xffffff8000eb9e77: or edx, edi + _0xffffff8000eb9e79: add edx, esi + _0xffffff8000eb9e7b: mov edi, edx + _0xffffff8000eb9e7d: xor edi, esi + _0xffffff8000eb9e7f: and edi, ecx + _0xffffff8000eb9e81: lea r8d, [rdi + rdi] + _0xffffff8000eb9e85: and r8d, 0x5810c772 + _0xffffff8000eb9e8c: neg r8d + _0xffffff8000eb9e8f: lea edi, [rdi + r8 + 0x2c0863b9] + _0xffffff8000eb9e97: xor edi, esi + _0xffffff8000eb9e99: xor edi, 0x2c0863b9 + _0xffffff8000eb9e9f: add edi, dword ptr [rbp - 0x37c] + _0xffffff8000eb9ea5: mov r8d, edi + _0xffffff8000eb9ea8: xor r8d, 0x127eef6f + _0xffffff8000eb9eaf: add r8d, eax + _0xffffff8000eb9eb2: and edi, 0x127eef6f + _0xffffff8000eb9eb8: lea eax, [r8 + rdi*2 + 0x32db257e] + _0xffffff8000eb9ec0: mov edi, eax + _0xffffff8000eb9ec2: shl edi, 0x15 + _0xffffff8000eb9ec5: and edi, 0xdde00000 + _0xffffff8000eb9ecb: mov r8d, eax + _0xffffff8000eb9ece: shl r8d, 0x14 + _0xffffff8000eb9ed2: xor r8d, 0x6ef6ebf4 + _0xffffff8000eb9ed9: lea edi, [r8 + rdi - 0x6ef6ebf4] + _0xffffff8000eb9ee1: mov r8d, eax + _0xffffff8000eb9ee4: shr r8d, 0xb + _0xffffff8000eb9ee8: and r8d, 0xbe694 + _0xffffff8000eb9eef: shr eax, 0xc + _0xffffff8000eb9ef2: add eax, 0xdb5f34a + _0xffffff8000eb9ef7: sub eax, r8d + _0xffffff8000eb9efa: xor eax, 0xdb5f34a + _0xffffff8000eb9eff: or eax, edi + _0xffffff8000eb9f01: add eax, edx + _0xffffff8000eb9f03: mov edi, eax + _0xffffff8000eb9f05: xor edi, edx + _0xffffff8000eb9f07: and edi, esi + _0xffffff8000eb9f09: lea r8d, [rdi + rdi] + _0xffffff8000eb9f0d: and r8d, 0xdf00ea82 + _0xffffff8000eb9f14: neg r8d + _0xffffff8000eb9f17: lea edi, [rdi + r8 + 0x6f807541] + _0xffffff8000eb9f1f: xor edi, edx + _0xffffff8000eb9f21: xor edi, 0x6f807541 + _0xffffff8000eb9f27: add edi, r14d + _0xffffff8000eb9f2a: mov r8d, edi + _0xffffff8000eb9f2d: xor r8d, 0x5fffc75c + _0xffffff8000eb9f34: add r8d, ecx + _0xffffff8000eb9f37: and edi, 0x5fffc75c + _0xffffff8000eb9f3d: lea ecx, [r8 + rdi*2 + 0x49e421a9] + _0xffffff8000eb9f45: mov edi, ecx + _0xffffff8000eb9f47: shl edi, 6 + _0xffffff8000eb9f4a: and edi, 0xdffe9cc0 + _0xffffff8000eb9f50: mov r8d, ecx + _0xffffff8000eb9f53: shl r8d, 5 + _0xffffff8000eb9f57: xor r8d, 0x6fff4e6b + _0xffffff8000eb9f5e: lea edi, [r8 + rdi - 0x6fff4e6b] + _0xffffff8000eb9f66: mov r8d, ecx + _0xffffff8000eb9f69: shr r8d, 0x1a + _0xffffff8000eb9f6d: and r8d, 0x22 + _0xffffff8000eb9f71: shr ecx, 0x1b + _0xffffff8000eb9f74: add ecx, 0x5abfd731 + _0xffffff8000eb9f7a: sub ecx, r8d + _0xffffff8000eb9f7d: xor ecx, 0x5abfd731 + _0xffffff8000eb9f83: or ecx, edi + _0xffffff8000eb9f85: add ecx, eax + _0xffffff8000eb9f87: mov edi, ecx + _0xffffff8000eb9f89: xor edi, eax + _0xffffff8000eb9f8b: and edi, edx + _0xffffff8000eb9f8d: lea r8d, [rdi + rdi] + _0xffffff8000eb9f91: and r8d, 0x965d5fde + _0xffffff8000eb9f98: neg r8d + _0xffffff8000eb9f9b: lea edi, [rdi + r8 + 0x4b2eafef] + _0xffffff8000eb9fa3: xor edi, eax + _0xffffff8000eb9fa5: xor edi, 0x4b2eafef + _0xffffff8000eb9fab: add edi, dword ptr [rbp - 0x368] + _0xffffff8000eb9fb1: mov r8d, edi + _0xffffff8000eb9fb4: xor r8d, 0x7a5b9dbf + _0xffffff8000eb9fbb: add r8d, esi + _0xffffff8000eb9fbe: and edi, 0x7a5b9dbf + _0xffffff8000eb9fc4: lea esi, [r8 + rdi*2 - 0x7d6bf9c7] + _0xffffff8000eb9fcc: mov edi, esi + _0xffffff8000eb9fce: shl edi, 0xa + _0xffffff8000eb9fd1: and edi, 0xc37f9c00 + _0xffffff8000eb9fd7: mov r8d, esi + _0xffffff8000eb9fda: shl r8d, 9 + _0xffffff8000eb9fde: xor r8d, 0x61bfcf77 + _0xffffff8000eb9fe5: lea edi, [r8 + rdi - 0x61bfcf77] + _0xffffff8000eb9fed: shr esi, 0x17 + _0xffffff8000eb9ff0: mov r8d, esi + _0xffffff8000eb9ff3: or r8d, 0x3f27fdd7 + _0xffffff8000eb9ffa: xor r8d, esi + _0xffffff8000eb9ffd: xor r8d, 0x3f27fdd7 + _0xffffff8000eba004: add r8d, r8d + _0xffffff8000eba007: neg r8d + _0xffffff8000eba00a: lea esi, [rsi + r8 + 0xf07edd7] + _0xffffff8000eba012: xor esi, 0xf07edd7 + _0xffffff8000eba018: or esi, edi + _0xffffff8000eba01a: add esi, ecx + _0xffffff8000eba01c: mov edi, esi + _0xffffff8000eba01e: xor edi, ecx + _0xffffff8000eba020: and edi, eax + _0xffffff8000eba022: lea r8d, [rdi + rdi] + _0xffffff8000eba026: and r8d, 0xbaa4ea0a + _0xffffff8000eba02d: neg r8d + _0xffffff8000eba030: lea edi, [rdi + r8 + 0x5d527505] + _0xffffff8000eba038: xor edi, ecx + _0xffffff8000eba03a: xor edi, 0x5d527505 + _0xffffff8000eba040: add edi, dword ptr [rbp - 0x36c] + _0xffffff8000eba046: mov r8d, edi + _0xffffff8000eba049: xor r8d, 0x7fd7f1fe + _0xffffff8000eba050: add r8d, edx + _0xffffff8000eba053: and edi, 0x7fd7f1fe + _0xffffff8000eba059: lea edx, [r8 + rdi*2 - 0x1868ef25] + _0xffffff8000eba061: mov edi, edx + _0xffffff8000eba063: shl edi, 0xf + _0xffffff8000eba066: and edi, 0xffa98000 + _0xffffff8000eba06c: mov r8d, edx + _0xffffff8000eba06f: shl r8d, 0xe + _0xffffff8000eba073: xor r8d, 0x7fd4fae1 + _0xffffff8000eba07a: lea edi, [r8 + rdi - 0x7fd4fae1] + _0xffffff8000eba082: mov r8d, edx + _0xffffff8000eba085: shr r8d, 0x11 + _0xffffff8000eba089: and r8d, 0x440c + _0xffffff8000eba090: shr edx, 0x12 + _0xffffff8000eba093: add edx, 0x57c4e206 + _0xffffff8000eba099: sub edx, r8d + _0xffffff8000eba09c: xor edx, 0x57c4e206 + _0xffffff8000eba0a2: or edx, edi + _0xffffff8000eba0a4: add edx, esi + _0xffffff8000eba0a6: mov edi, edx + _0xffffff8000eba0a8: xor edi, esi + _0xffffff8000eba0aa: mov r8d, edi + _0xffffff8000eba0ad: and r8d, ecx + _0xffffff8000eba0b0: lea r9d, [r8 + r8] + _0xffffff8000eba0b4: and r9d, 0x862aeb2c + _0xffffff8000eba0bb: neg r9d + _0xffffff8000eba0be: lea r8d, [r8 + r9 + 0x43157596] + _0xffffff8000eba0c6: xor r8d, esi + _0xffffff8000eba0c9: xor r8d, 0x43157596 + _0xffffff8000eba0d0: add r8d, dword ptr [rbp - 0x374] + _0xffffff8000eba0d7: mov r9d, r8d + _0xffffff8000eba0da: xor r9d, 0x767bdf3b + _0xffffff8000eba0e1: add r9d, eax + _0xffffff8000eba0e4: and r8d, 0x767bdf3b + _0xffffff8000eba0eb: lea eax, [r9 + r8*2 + 0x16ae6d4f] + _0xffffff8000eba0f3: mov r8d, eax + _0xffffff8000eba0f6: shr r8d, 0xb + _0xffffff8000eba0fa: and r8d, 0x17508c + _0xffffff8000eba101: mov r9d, eax + _0xffffff8000eba104: shr r9d, 0xc + _0xffffff8000eba108: add r9d, 0x569ba846 + _0xffffff8000eba10f: sub r9d, r8d + _0xffffff8000eba112: xor r9d, 0x569ba846 + _0xffffff8000eba119: shl eax, 0x14 + _0xffffff8000eba11c: or eax, r9d + _0xffffff8000eba11f: add eax, edx + _0xffffff8000eba121: mov r8b, al + _0xffffff8000eba124: add r8b, r8b + _0xffffff8000eba127: mov r9b, al + _0xffffff8000eba12a: add r9b, 0x12 + _0xffffff8000eba12e: and r8b, 0x14 + _0xffffff8000eba132: sub r9b, r8b + _0xffffff8000eba135: mov r8b, r9b + _0xffffff8000eba138: add r8b, r8b + _0xffffff8000eba13b: and r9b, 0xf + _0xffffff8000eba13f: xor r9b, 0x31 + _0xffffff8000eba143: and r8b, 6 + _0xffffff8000eba147: xor r8b, 4 + _0xffffff8000eba14b: add r8b, r9b + _0xffffff8000eba14e: add r8b, 0xef + _0xffffff8000eba152: movzx r8d, r8b + _0xffffff8000eba156: cmp r8b, 0x22 + _0xffffff8000eba15a: sbb r9, r9 + _0xffffff8000eba15d: and r9d, 0x100 + _0xffffff8000eba164: add r9d, r8d + _0xffffff8000eba167: lea r8d, [r9 + 0x7e887110] + _0xffffff8000eba16e: shl r8, 0x20 + _0xffffff8000eba172: movabs r10, 0x81778ece00000000 + _0xffffff8000eba17c: add r10, r8 + _0xffffff8000eba17f: mov r8, r10 + _0xffffff8000eba182: sar r8, 0x1f + _0xffffff8000eba186: movabs r11, 0x22ff51f13be579ee + _0xffffff8000eba190: and r11, r8 + _0xffffff8000eba193: sar r10, 0x20 + _0xffffff8000eba197: movabs r8, 0x317fa8f89df2bcf7 + _0xffffff8000eba1a1: xor r8, r10 + _0xffffff8000eba1a4: add r8, r11 + _0xffffff8000eba1a7: lea r8, [rbp + r8*4 - 0xa0] + _0xffffff8000eba1af: movabs r10, 0x3a015c1d88350c24 + _0xffffff8000eba1b9: mov r8d, dword ptr [r10 + r8] + _0xffffff8000eba1bd: mov r10b, cl + _0xffffff8000eba1c0: add r10b, r10b + _0xffffff8000eba1c3: and r10b, 2 + _0xffffff8000eba1c7: mov r11b, cl + _0xffffff8000eba1ca: sub r11b, r10b + _0xffffff8000eba1cd: inc r11b + _0xffffff8000eba1d0: mov r10b, r11b + _0xffffff8000eba1d3: add r10b, r10b + _0xffffff8000eba1d6: and r11b, 0xf + _0xffffff8000eba1da: xor r11b, 0x3e + _0xffffff8000eba1de: and r10b, 0x1e + _0xffffff8000eba1e2: xor r10b, 2 + _0xffffff8000eba1e6: add r10b, r11b + _0xffffff8000eba1e9: add r10b, 0xcd + _0xffffff8000eba1ed: movzx r10d, r10b + _0xffffff8000eba1f1: cmp r10b, 0xc + _0xffffff8000eba1f5: sbb r11, r11 + _0xffffff8000eba1f8: and r11d, 0x100 + _0xffffff8000eba1ff: add r11d, r10d + _0xffffff8000eba202: lea r10d, [r11 + 0x77ceb567] + _0xffffff8000eba209: shl r10, 0x20 + _0xffffff8000eba20d: movabs rbx, 0x88314a8d00000000 + _0xffffff8000eba217: add rbx, r10 + _0xffffff8000eba21a: mov r10, rbx + _0xffffff8000eba21d: sar r10, 0x1f + _0xffffff8000eba221: movabs r14, 0x3fb9dfbddfead6e6 + _0xffffff8000eba22b: and r14, r10 + _0xffffff8000eba22e: sar rbx, 0x20 + _0xffffff8000eba232: movabs r10, 0x3fdcefdeeff56b73 + _0xffffff8000eba23c: xor r10, rbx + _0xffffff8000eba23f: add r10, r14 + _0xffffff8000eba242: lea r10, [rbp + r10*4 - 0xa0] + _0xffffff8000eba24a: movabs rbx, 0x8c4084402a5234 + _0xffffff8000eba254: mov r10d, dword ptr [rbx + r10] + _0xffffff8000eba258: add r11d, 0x4833135c + _0xffffff8000eba25f: shl r11, 0x20 + _0xffffff8000eba263: movabs rbx, 0xb7ccec9800000000 + _0xffffff8000eba26d: add rbx, r11 + _0xffffff8000eba270: mov r11, rbx + _0xffffff8000eba273: sar r11, 0x1f + _0xffffff8000eba277: movabs r14, 0x346f7dded7f7b7ec + _0xffffff8000eba281: and r14, r11 + _0xffffff8000eba284: sar rbx, 0x20 + _0xffffff8000eba288: movabs r11, 0x1a37beef6bfbdbf6 + _0xffffff8000eba292: xor r11, rbx + _0xffffff8000eba295: add r11, r14 + _0xffffff8000eba298: lea r11, [rbp + r11*4 - 0xa0] + _0xffffff8000eba2a0: movabs rbx, 0x9721044250109028 + _0xffffff8000eba2aa: mov dword ptr [rbx + r11], r8d + _0xffffff8000eba2ae: add r9d, 0x3e5a7bdd + _0xffffff8000eba2b5: shl r9, 0x20 + _0xffffff8000eba2b9: movabs r8, 0xc1a5840100000000 + _0xffffff8000eba2c3: add r8, r9 + _0xffffff8000eba2c6: mov r9, r8 + _0xffffff8000eba2c9: sar r9, 0x1f + _0xffffff8000eba2cd: movabs r11, 0x2fbf5ef7eef7e9fa + _0xffffff8000eba2d7: and r11, r9 + _0xffffff8000eba2da: sar r8, 0x20 + _0xffffff8000eba2de: movabs r9, 0x37dfaf7bf77bf4fd + _0xffffff8000eba2e8: xor r9, r8 + _0xffffff8000eba2eb: add r9, r11 + _0xffffff8000eba2ee: lea r8, [rbp + r9*4 - 0xa0] + _0xffffff8000eba2f6: mov r9b, dl + _0xffffff8000eba2f9: add r9b, r9b + _0xffffff8000eba2fc: and r9b, 0x90 + _0xffffff8000eba300: xor r9b, 0x70 + _0xffffff8000eba304: mov r11b, dl + _0xffffff8000eba307: sub r11b, r9b + _0xffffff8000eba30a: add r11b, 8 + _0xffffff8000eba30e: mov r9b, r11b + _0xffffff8000eba311: add r9b, r9b + _0xffffff8000eba314: and r11b, 0xf + _0xffffff8000eba318: xor r11b, 0x35 + _0xffffff8000eba31c: and r9b, 0x1a + _0xffffff8000eba320: xor r9b, 0x10 + _0xffffff8000eba324: add r9b, r11b + _0xffffff8000eba327: add r9b, 0xec + _0xffffff8000eba32b: movzx r9d, r9b + _0xffffff8000eba32f: cmp r9b, 0x29 + _0xffffff8000eba333: sbb r11, r11 + _0xffffff8000eba336: and r11, 0x100 + _0xffffff8000eba33d: lea rbx, [r9 + r11] + _0xffffff8000eba341: add rbx, -0x29 + _0xffffff8000eba345: movabs r14, 0xfeeb7d9fba7bdbd + _0xffffff8000eba34f: and r14, rbx + _0xffffff8000eba352: movabs r15, 0x2feeb7d9fba7bdbd + _0xffffff8000eba35c: xor r15, rbx + _0xffffff8000eba35f: lea rbx, [r15 + r14*2] + _0xffffff8000eba363: lea rbx, [rbp + rbx*4 - 0xa0] + _0xffffff8000eba36b: movabs r14, 0x404520981161090c + _0xffffff8000eba375: mov ebx, dword ptr [r14 + rbx] + _0xffffff8000eba379: movabs r14, 0x2081421022102c0c + _0xffffff8000eba383: mov dword ptr [r14 + r8], ebx + _0xffffff8000eba387: movzx r8d, r9b + _0xffffff8000eba38b: add r11d, r8d + _0xffffff8000eba38e: add r11d, 0x31eebaff + _0xffffff8000eba395: shl r11, 0x20 + _0xffffff8000eba399: movabs r8, 0xce1144d800000000 + _0xffffff8000eba3a3: add r8, r11 + _0xffffff8000eba3a6: mov r9, r8 + _0xffffff8000eba3a9: sar r9, 0x1f + _0xffffff8000eba3ad: movabs r11, 0x33ff7bb6fb9ffafe + _0xffffff8000eba3b7: and r11, r9 + _0xffffff8000eba3ba: sar r8, 0x20 + _0xffffff8000eba3be: movabs r9, 0x39ffbddb7dcffd7f + _0xffffff8000eba3c8: xor r9, r8 + _0xffffff8000eba3cb: add r9, r11 + _0xffffff8000eba3ce: lea r8, [rbp + r9*4 - 0xa0] + _0xffffff8000eba3d6: mov r9b, sil + _0xffffff8000eba3d9: add r9b, r9b + _0xffffff8000eba3dc: mov r11b, sil + _0xffffff8000eba3df: add r11b, 0x17 + _0xffffff8000eba3e3: and r9b, 0xce + _0xffffff8000eba3e7: xor r9b, 0xb0 + _0xffffff8000eba3eb: sub r11b, r9b + _0xffffff8000eba3ee: mov r9b, r11b + _0xffffff8000eba3f1: add r9b, r9b + _0xffffff8000eba3f4: and r11b, 0xf + _0xffffff8000eba3f8: xor r11b, 0x64 + _0xffffff8000eba3fc: and r9b, 6 + _0xffffff8000eba400: xor r9b, 6 + _0xffffff8000eba404: add r9b, r11b + _0xffffff8000eba407: add r9b, 0xfe + _0xffffff8000eba40b: movzx r9d, r9b + _0xffffff8000eba40f: cmp r9b, 0x61 + _0xffffff8000eba413: sbb r11, r11 + _0xffffff8000eba416: and r11d, 0x100 + _0xffffff8000eba41d: add r11d, r9d + _0xffffff8000eba420: lea r9d, [r11 + 0x2de54db5] + _0xffffff8000eba427: shl r9, 0x20 + _0xffffff8000eba42b: movabs rbx, 0xd21ab1ea00000000 + _0xffffff8000eba435: add rbx, r9 + _0xffffff8000eba438: mov r9, rbx + _0xffffff8000eba43b: sar r9, 0x1f + _0xffffff8000eba43f: movabs r14, 0x2fbff2ffd2f75fc8 + _0xffffff8000eba449: and r14, r9 + _0xffffff8000eba44c: sar rbx, 0x20 + _0xffffff8000eba450: movabs r9, 0x17dff97fe97bafe4 + _0xffffff8000eba45a: xor r9, rbx + _0xffffff8000eba45d: add r9, r14 + _0xffffff8000eba460: lea r9, [rbp + r9*4 - 0xa0] + _0xffffff8000eba468: movabs rbx, 0xa0801a005a114070 + _0xffffff8000eba472: mov r9d, dword ptr [rbx + r9] + _0xffffff8000eba476: movabs rbx, 0x1801089208c00a04 + _0xffffff8000eba480: mov dword ptr [rbx + r8], r9d + _0xffffff8000eba484: add r11d, 0x2350e06d + _0xffffff8000eba48b: shl r11, 0x20 + _0xffffff8000eba48f: movabs r8, 0xdcaf1f3200000000 + _0xffffff8000eba499: add r8, r11 + _0xffffff8000eba49c: mov r9, r8 + _0xffffff8000eba49f: sar r9, 0x1f + _0xffffff8000eba4a3: movabs r11, 0x36ebfa25d7e37ffc + _0xffffff8000eba4ad: and r11, r9 + _0xffffff8000eba4b0: sar r8, 0x20 + _0xffffff8000eba4b4: movabs r9, 0x1b75fd12ebf1bffe + _0xffffff8000eba4be: xor r9, r8 + _0xffffff8000eba4c1: add r9, r11 + _0xffffff8000eba4c4: lea r8, [rbp + r9*4 - 0xa0] + _0xffffff8000eba4cc: mov r9d, ecx + _0xffffff8000eba4cf: shr r9d, 3 + _0xffffff8000eba4d3: and r9d, 0x98 + _0xffffff8000eba4da: mov r11d, ecx + _0xffffff8000eba4dd: shr r11d, 4 + _0xffffff8000eba4e1: add r11d, 0xc + _0xffffff8000eba4e5: sub r11d, r9d + _0xffffff8000eba4e8: xor r11d, 8 + _0xffffff8000eba4ec: mov r9b, r11b + _0xffffff8000eba4ef: add r9b, r9b + _0xffffff8000eba4f2: and r11b, 0xf + _0xffffff8000eba4f6: xor r11b, 0x33 + _0xffffff8000eba4fa: and r9b, 0xe + _0xffffff8000eba4fe: xor r9b, 8 + _0xffffff8000eba502: add r9b, r11b + _0xffffff8000eba505: add r9b, 0xfc + _0xffffff8000eba509: movzx r9d, r9b + _0xffffff8000eba50d: cmp r9b, 0x33 + _0xffffff8000eba511: sbb r11, r11 + _0xffffff8000eba514: and r11d, 0x100 + _0xffffff8000eba51b: add r11d, r9d + _0xffffff8000eba51e: lea r9d, [r11 + 0x606161f0] + _0xffffff8000eba525: shl r9, 0x20 + _0xffffff8000eba529: movabs rbx, 0x9f9e9ddd00000000 + _0xffffff8000eba533: add rbx, r9 + _0xffffff8000eba536: mov r9, rbx + _0xffffff8000eba539: sar r9, 0x1f + _0xffffff8000eba53d: movabs r14, 0x1bcfeb775f777d06 + _0xffffff8000eba547: and r14, r9 + _0xffffff8000eba54a: sar rbx, 0x20 + _0xffffff8000eba54e: movabs r9, 0x2de7f5bbafbbbe83 + _0xffffff8000eba558: xor r9, rbx + _0xffffff8000eba55b: add r9, r14 + _0xffffff8000eba55e: lea r9, [rbp + r9*4 - 0xa0] + _0xffffff8000eba566: movabs rbx, 0x48602911411105f4 + _0xffffff8000eba570: mov r9d, dword ptr [rbx + r9] + _0xffffff8000eba574: movabs rbx, 0x92280bb450390008 + _0xffffff8000eba57e: mov dword ptr [rbx + r8], r9d + _0xffffff8000eba582: mov r8d, eax + _0xffffff8000eba585: shr r8d, 3 + _0xffffff8000eba589: and r8d, 0x28 + _0xffffff8000eba58d: mov r9d, eax + _0xffffff8000eba590: shr r9d, 4 + _0xffffff8000eba594: add r9d, 0x14 + _0xffffff8000eba598: sub r9d, r8d + _0xffffff8000eba59b: xor r9d, 0xe + _0xffffff8000eba59f: mov r8b, r9b + _0xffffff8000eba5a2: add r8b, r8b + _0xffffff8000eba5a5: and r9b, 0xf + _0xffffff8000eba5a9: xor r9b, 0x74 + _0xffffff8000eba5ad: and r8b, 0x1c + _0xffffff8000eba5b1: xor r8b, 0x14 + _0xffffff8000eba5b5: add r8b, r9b + _0xffffff8000eba5b8: add r8b, 0xc0 + _0xffffff8000eba5bc: movzx r8d, r8b + _0xffffff8000eba5c0: cmp r8b, 0x3e + _0xffffff8000eba5c4: sbb r9, r9 + _0xffffff8000eba5c7: and r9, 0x100 + _0xffffff8000eba5ce: add r9, r8 + _0xffffff8000eba5d1: lea r8, [r9 + r9 - 0x7c] + _0xffffff8000eba5d6: movabs rbx, 0x39dd63bbbff7df7e + _0xffffff8000eba5e0: and rbx, r8 + _0xffffff8000eba5e3: add r9, -0x3e + _0xffffff8000eba5e7: movabs r14, 0x3ceeb1dddffbefbf + _0xffffff8000eba5f1: xor r14, r9 + _0xffffff8000eba5f4: add r14, rbx + _0xffffff8000eba5f7: lea rbx, [rbp + r14*4 - 0xa0] + _0xffffff8000eba5ff: movabs r14, 0xc45388880104104 + _0xffffff8000eba609: mov ebx, dword ptr [r14 + rbx] + _0xffffff8000eba60d: add r11d, 0x5a8127be + _0xffffff8000eba614: shl r11, 0x20 + _0xffffff8000eba618: movabs r14, 0xa57ed80f00000000 + _0xffffff8000eba622: add r14, r11 + _0xffffff8000eba625: mov r11, r14 + _0xffffff8000eba628: sar r11, 0x1f + _0xffffff8000eba62c: movabs r15, 0x3f37ef9fffefe6c4 + _0xffffff8000eba636: and r15, r11 + _0xffffff8000eba639: sar r14, 0x20 + _0xffffff8000eba63d: movabs r11, 0x3f9bf7cffff7f362 + _0xffffff8000eba647: xor r11, r14 + _0xffffff8000eba64a: add r11, r15 + _0xffffff8000eba64d: lea r11, [rbp + r11*4 - 0xa0] + _0xffffff8000eba655: movabs r14, 0x19020c000203278 + _0xffffff8000eba65f: mov dword ptr [r14 + r11], ebx + _0xffffff8000eba663: movabs r11, 0x2fe9ffa67f777f7c + _0xffffff8000eba66d: and r11, r8 + _0xffffff8000eba670: movabs r8, 0x37f4ffd33fbbbfbe + _0xffffff8000eba67a: xor r8, r9 + _0xffffff8000eba67d: add r8, r11 + _0xffffff8000eba680: lea r8, [rbp + r8*4 - 0xa0] + _0xffffff8000eba688: mov r9d, edx + _0xffffff8000eba68b: shr r9d, 3 + _0xffffff8000eba68f: and r9d, 0xe6 + _0xffffff8000eba696: mov r11d, edx + _0xffffff8000eba699: shr r11d, 4 + _0xffffff8000eba69d: add r11d, 3 + _0xffffff8000eba6a1: sub r11d, r9d + _0xffffff8000eba6a4: xor r11d, 6 + _0xffffff8000eba6a8: mov r9b, r11b + _0xffffff8000eba6ab: add r9b, r9b + _0xffffff8000eba6ae: and r11b, 0xf + _0xffffff8000eba6b2: xor r11b, 0x72 + _0xffffff8000eba6b6: and r9b, 0xe + _0xffffff8000eba6ba: xor r9b, 0xa + _0xffffff8000eba6be: add r9b, r11b + _0xffffff8000eba6c1: dec r9b + _0xffffff8000eba6c4: movzx r9d, r9b + _0xffffff8000eba6c8: cmp r9b, 0x76 + _0xffffff8000eba6cc: sbb r11, r11 + _0xffffff8000eba6cf: and r11d, 0x100 + _0xffffff8000eba6d6: add r11d, r9d + _0xffffff8000eba6d9: lea r9d, [r11 + 0x700401b5] + _0xffffff8000eba6e0: shl r9, 0x20 + _0xffffff8000eba6e4: movabs rbx, 0x8ffbfdd500000000 + _0xffffff8000eba6ee: add rbx, r9 + _0xffffff8000eba6f1: mov r9, rbx + _0xffffff8000eba6f4: sar r9, 0x1f + _0xffffff8000eba6f8: movabs r14, 0x37357e77afb9577e + _0xffffff8000eba702: and r14, r9 + _0xffffff8000eba705: sar rbx, 0x20 + _0xffffff8000eba709: movabs r9, 0x3b9abf3bd7dcabbf + _0xffffff8000eba713: xor r9, rbx + _0xffffff8000eba716: add r9, r14 + _0xffffff8000eba719: lea r9, [rbp + r9*4 - 0xa0] + _0xffffff8000eba721: movabs rbx, 0x11950310a08d5104 + _0xffffff8000eba72b: mov r9d, dword ptr [rbx + r9] + _0xffffff8000eba72f: movabs rbx, 0x202c00b301110108 + _0xffffff8000eba739: mov dword ptr [rbx + r8], r9d + _0xffffff8000eba73d: add r11d, 0x503ddfd5 + _0xffffff8000eba744: shl r11, 0x20 + _0xffffff8000eba748: movabs r8, 0xafc21fb500000000 + _0xffffff8000eba752: add r8, r11 + _0xffffff8000eba755: mov r9, r8 + _0xffffff8000eba758: sar r9, 0x1f + _0xffffff8000eba75c: movabs r11, 0x3eefef7fdd3e366a + _0xffffff8000eba766: and r11, r9 + _0xffffff8000eba769: sar r8, 0x20 + _0xffffff8000eba76d: movabs r9, 0x3f77f7bfee9f1b35 + _0xffffff8000eba777: xor r9, r8 + _0xffffff8000eba77a: add r9, r11 + _0xffffff8000eba77d: lea r8, [rbp + r9*4 - 0xa0] + _0xffffff8000eba785: mov r9d, esi + _0xffffff8000eba788: shr r9d, 3 + _0xffffff8000eba78c: and r9d, 0xee + _0xffffff8000eba793: mov r11d, esi + _0xffffff8000eba796: shr r11d, 4 + _0xffffff8000eba79a: add r11d, 0x17 + _0xffffff8000eba79e: sub r11d, r9d + _0xffffff8000eba7a1: xor r11d, 3 + _0xffffff8000eba7a5: mov r9b, r11b + _0xffffff8000eba7a8: add r9b, r9b + _0xffffff8000eba7ab: and r11b, 0xf + _0xffffff8000eba7af: xor r11b, 0x4a + _0xffffff8000eba7b3: and r9b, 0x1c + _0xffffff8000eba7b7: xor r9b, 8 + _0xffffff8000eba7bb: add r9b, r11b + _0xffffff8000eba7be: add r9b, 0xfc + _0xffffff8000eba7c2: movzx r9d, r9b + _0xffffff8000eba7c6: cmp r9b, 0x4a + _0xffffff8000eba7ca: sbb r11, r11 + _0xffffff8000eba7cd: and r11, 0x100 + _0xffffff8000eba7d4: mov ebx, r11d + _0xffffff8000eba7d7: add ebx, r9d + _0xffffff8000eba7da: add ebx, 0x3dc4982c + _0xffffff8000eba7e0: shl rbx, 0x20 + _0xffffff8000eba7e4: movabs r14, 0xc23b678a00000000 + _0xffffff8000eba7ee: add r14, rbx + _0xffffff8000eba7f1: mov rbx, r14 + _0xffffff8000eba7f4: sar rbx, 0x1f + _0xffffff8000eba7f8: movabs r15, 0x3ff3fb23ad7f3ff6 + _0xffffff8000eba802: and r15, rbx + _0xffffff8000eba805: sar r14, 0x20 + _0xffffff8000eba809: movabs rbx, 0x3ff9fd91d6bf9ffb + _0xffffff8000eba813: xor rbx, r14 + _0xffffff8000eba816: add rbx, r15 + _0xffffff8000eba819: lea rbx, [rbp + rbx*4 - 0xa0] + _0xffffff8000eba821: movabs r14, 0x1809b8a5018014 + _0xffffff8000eba82b: mov ebx, dword ptr [r14 + rbx] + _0xffffff8000eba82f: movabs r14, 0x22021004583932c + _0xffffff8000eba839: mov dword ptr [r14 + r8], ebx + _0xffffff8000eba83d: movzx r8d, r9b + _0xffffff8000eba841: add r8, r11 + _0xffffff8000eba844: add r8, -0x4a + _0xffffff8000eba848: movabs r9, 0x1fb57bfebffed2ff + _0xffffff8000eba852: and r9, r8 + _0xffffff8000eba855: movabs r11, 0x3fb57bfebffed2ff + _0xffffff8000eba85f: xor r11, r8 + _0xffffff8000eba862: lea r8, [r11 + r9*2] + _0xffffff8000eba866: lea r8, [rbp + r8*4 - 0xa0] + _0xffffff8000eba86e: movabs r9, 0x12a10050004b404 + _0xffffff8000eba878: mov dword ptr [r9 + r8], r10d + _0xffffff8000eba87c: xor edi, eax + _0xffffff8000eba87e: mov r8d, dword ptr [rbp - 0x8c] + _0xffffff8000eba885: mov dword ptr [rbp - 0x378], r8d + _0xffffff8000eba88c: add edi, r8d + _0xffffff8000eba88f: mov r8d, edi + _0xffffff8000eba892: xor r8d, 0x37d7ff5f + _0xffffff8000eba899: add r8d, ecx + _0xffffff8000eba89c: and edi, 0x37d7ff5f + _0xffffff8000eba8a2: lea ecx, [r8 + rdi*2 - 0x37ddc61d] + _0xffffff8000eba8aa: mov edi, ecx + _0xffffff8000eba8ac: shl edi, 5 + _0xffffff8000eba8af: and edi, 0x6bbbffe0 + _0xffffff8000eba8b5: mov r8d, ecx + _0xffffff8000eba8b8: shl r8d, 4 + _0xffffff8000eba8bc: xor r8d, 0x35ddfff6 + _0xffffff8000eba8c3: lea edi, [r8 + rdi - 0x35ddfff6] + _0xffffff8000eba8cb: shr ecx, 0x1c + _0xffffff8000eba8ce: mov r8d, ecx + _0xffffff8000eba8d1: or r8d, 0x767fdb77 + _0xffffff8000eba8d8: xor r8d, ecx + _0xffffff8000eba8db: xor r8d, 0x767fdb77 + _0xffffff8000eba8e2: add r8d, r8d + _0xffffff8000eba8e5: neg r8d + _0xffffff8000eba8e8: lea ecx, [rcx + r8 + 0x546bc377] + _0xffffff8000eba8f0: xor ecx, 0x546bc377 + _0xffffff8000eba8f6: or ecx, edi + _0xffffff8000eba8f8: add ecx, eax + _0xffffff8000eba8fa: mov edi, eax + _0xffffff8000eba8fc: xor edi, edx + _0xffffff8000eba8fe: xor edi, ecx + _0xffffff8000eba900: mov r8d, dword ptr [rbp - 0x80] + _0xffffff8000eba904: mov dword ptr [rbp - 0x368], r8d + _0xffffff8000eba90b: add edi, r8d + _0xffffff8000eba90e: mov r8d, edi + _0xffffff8000eba911: xor r8d, 0x6effffed + _0xffffff8000eba918: add r8d, esi + _0xffffff8000eba91b: and edi, 0x6effffed + _0xffffff8000eba921: lea esi, [r8 + rdi*2 + 0x1871f694] + _0xffffff8000eba929: mov edi, esi + _0xffffff8000eba92b: shr edi, 0x14 + _0xffffff8000eba92e: and edi, 0x1ae + _0xffffff8000eba934: mov r8d, esi + _0xffffff8000eba937: shr r8d, 0x15 + _0xffffff8000eba93b: add r8d, 0x209308d7 + _0xffffff8000eba942: sub r8d, edi + _0xffffff8000eba945: xor r8d, 0x209308d7 + _0xffffff8000eba94c: shl esi, 0xb + _0xffffff8000eba94f: or esi, r8d + _0xffffff8000eba952: add esi, ecx + _0xffffff8000eba954: mov edi, ecx + _0xffffff8000eba956: xor edi, eax + _0xffffff8000eba958: xor edi, esi + _0xffffff8000eba95a: mov r8d, dword ptr [rbp - 0x74] + _0xffffff8000eba95e: mov dword ptr [rbp - 0x360], r8d + _0xffffff8000eba965: add edi, r8d + _0xffffff8000eba968: mov r8d, edi + _0xffffff8000eba96b: xor r8d, 0x4dff5c34 + _0xffffff8000eba972: add r8d, edx + _0xffffff8000eba975: and edi, 0x4dff5c34 + _0xffffff8000eba97b: lea edx, [r8 + rdi*2 + 0x1f9e04ee] + _0xffffff8000eba983: mov edi, edx + _0xffffff8000eba985: shl edi, 0x11 + _0xffffff8000eba988: and edi, 0xf3fe0000 + _0xffffff8000eba98e: mov r8d, edx + _0xffffff8000eba991: shl r8d, 0x10 + _0xffffff8000eba995: xor r8d, 0x79ffdedb + _0xffffff8000eba99c: lea edi, [r8 + rdi - 0x79ffdedb] + _0xffffff8000eba9a4: mov r8d, edx + _0xffffff8000eba9a7: shr r8d, 0xf + _0xffffff8000eba9ab: and r8d, 0x10562 + _0xffffff8000eba9b2: shr edx, 0x10 + _0xffffff8000eba9b5: add edx, 0x6b9382b1 + _0xffffff8000eba9bb: sub edx, r8d + _0xffffff8000eba9be: xor edx, 0x6b9382b1 + _0xffffff8000eba9c4: or edx, edi + _0xffffff8000eba9c6: add edx, esi + _0xffffff8000eba9c8: mov edi, esi + _0xffffff8000eba9ca: xor edi, ecx + _0xffffff8000eba9cc: xor edi, edx + _0xffffff8000eba9ce: mov r8d, dword ptr [rbp - 0x68] + _0xffffff8000eba9d2: add edi, r8d + _0xffffff8000eba9d5: mov r9d, edi + _0xffffff8000eba9d8: xor r9d, 0x7dbaf45f + _0xffffff8000eba9df: add r9d, eax + _0xffffff8000eba9e2: and edi, 0x7dbaf45f + _0xffffff8000eba9e8: lea eax, [r9 + rdi*2 - 0x7fd5bc53] + _0xffffff8000eba9f0: mov edi, eax + _0xffffff8000eba9f2: shl edi, 0x18 + _0xffffff8000eba9f5: and edi, 0xfd000000 + _0xffffff8000eba9fb: mov r9d, eax + _0xffffff8000eba9fe: shl r9d, 0x17 + _0xffffff8000ebaa02: xor r9d, 0x7eaffbff + _0xffffff8000ebaa09: lea edi, [r9 + rdi - 0x7eaffbff] + _0xffffff8000ebaa11: mov r9d, eax + _0xffffff8000ebaa14: shr r9d, 8 + _0xffffff8000ebaa18: and r9d, 0x722962 + _0xffffff8000ebaa1f: shr eax, 9 + _0xffffff8000ebaa22: add eax, 0x70b914b1 + _0xffffff8000ebaa27: sub eax, r9d + _0xffffff8000ebaa2a: xor eax, 0x70b914b1 + _0xffffff8000ebaa2f: or eax, edi + _0xffffff8000ebaa31: add eax, edx + _0xffffff8000ebaa33: mov edi, edx + _0xffffff8000ebaa35: xor edi, esi + _0xffffff8000ebaa37: xor edi, eax + _0xffffff8000ebaa39: mov r9d, dword ptr [rbp - 0xa0] + _0xffffff8000ebaa40: mov r10d, dword ptr [rbp - 0x9c] + _0xffffff8000ebaa47: mov dword ptr [rbp - 0x374], r10d + _0xffffff8000ebaa4e: add edi, r10d + _0xffffff8000ebaa51: mov r10d, edi + _0xffffff8000ebaa54: xor r10d, 0x7979eedf + _0xffffff8000ebaa5b: add r10d, ecx + _0xffffff8000ebaa5e: and edi, 0x7979eedf + _0xffffff8000ebaa64: lea ecx, [r10 + rdi*2 + 0x2b44fb65] + _0xffffff8000ebaa6c: mov edi, ecx + _0xffffff8000ebaa6e: shl edi, 5 + _0xffffff8000ebaa71: and edi, 0xbbb1ef60 + _0xffffff8000ebaa77: mov r10d, ecx + _0xffffff8000ebaa7a: shl r10d, 4 + _0xffffff8000ebaa7e: xor r10d, 0x5dd8f7ba + _0xffffff8000ebaa85: lea edi, [r10 + rdi - 0x5dd8f7ba] + _0xffffff8000ebaa8d: shr ecx, 0x1c + _0xffffff8000ebaa90: or ecx, edi + _0xffffff8000ebaa92: add ecx, eax + _0xffffff8000ebaa94: mov edi, eax + _0xffffff8000ebaa96: xor edi, edx + _0xffffff8000ebaa98: xor edi, ecx + _0xffffff8000ebaa9a: mov r10d, dword ptr [rbp - 0x90] + _0xffffff8000ebaaa1: mov dword ptr [rbp - 0x364], r10d + _0xffffff8000ebaaa8: add edi, r10d + _0xffffff8000ebaaab: mov r10d, edi + _0xffffff8000ebaaae: xor r10d, 0x7ffdef74 + _0xffffff8000ebaab5: add r10d, esi + _0xffffff8000ebaab8: and edi, 0x7ffdef74 + _0xffffff8000ebaabe: lea esi, [r10 + rdi*2 - 0x341f1fcb] + _0xffffff8000ebaac6: mov edi, esi + _0xffffff8000ebaac8: shr edi, 0x14 + _0xffffff8000ebaacb: and edi, 0xcf2 + _0xffffff8000ebaad1: mov r10d, esi + _0xffffff8000ebaad4: shr r10d, 0x15 + _0xffffff8000ebaad8: add r10d, 0x54a1ee79 + _0xffffff8000ebaadf: sub r10d, edi + _0xffffff8000ebaae2: xor r10d, 0x54a1ee79 + _0xffffff8000ebaae9: shl esi, 0xb + _0xffffff8000ebaaec: or esi, r10d + _0xffffff8000ebaaef: add esi, ecx + _0xffffff8000ebaaf1: mov edi, ecx + _0xffffff8000ebaaf3: xor edi, eax + _0xffffff8000ebaaf5: xor edi, esi + _0xffffff8000ebaaf7: mov r10d, dword ptr [rbp - 0x84] + _0xffffff8000ebaafe: add edi, r10d + _0xffffff8000ebab01: mov r11d, edi + _0xffffff8000ebab04: xor r11d, 0x27fe0fe7 + _0xffffff8000ebab0b: add r11d, edx + _0xffffff8000ebab0e: and edi, 0x27fe0fe7 + _0xffffff8000ebab14: lea edx, [r11 + rdi*2 - 0x3142c487] + _0xffffff8000ebab1c: mov edi, edx + _0xffffff8000ebab1e: shl edi, 0x11 + _0xffffff8000ebab21: and edi, 0xd6c20000 + _0xffffff8000ebab27: mov r11d, edx + _0xffffff8000ebab2a: shl r11d, 0x10 + _0xffffff8000ebab2e: xor r11d, 0x6b61f77e + _0xffffff8000ebab35: lea edi, [r11 + rdi - 0x6b61f77e] + _0xffffff8000ebab3d: mov r11d, edx + _0xffffff8000ebab40: shr r11d, 0xf + _0xffffff8000ebab44: and r11d, 0x1d462 + _0xffffff8000ebab4b: shr edx, 0x10 + _0xffffff8000ebab4e: add edx, 0x6464ea31 + _0xffffff8000ebab54: sub edx, r11d + _0xffffff8000ebab57: xor edx, 0x6464ea31 + _0xffffff8000ebab5d: or edx, edi + _0xffffff8000ebab5f: add edx, esi + _0xffffff8000ebab61: mov edi, esi + _0xffffff8000ebab63: xor edi, ecx + _0xffffff8000ebab65: xor edi, edx + _0xffffff8000ebab67: mov r11d, dword ptr [rbp - 0x78] + _0xffffff8000ebab6b: add edi, r11d + _0xffffff8000ebab6e: mov ebx, edi + _0xffffff8000ebab70: xor ebx, 0x7b2ff9e6 + _0xffffff8000ebab76: add ebx, eax + _0xffffff8000ebab78: and edi, 0x7b2ff9e6 + _0xffffff8000ebab7e: lea eax, [rbx + rdi*2 + 0x438fc28a] + _0xffffff8000ebab85: mov edi, eax + _0xffffff8000ebab87: shl edi, 0x18 + _0xffffff8000ebab8a: and edi, 0xb3000000 + _0xffffff8000ebab90: mov ebx, eax + _0xffffff8000ebab92: shl ebx, 0x17 + _0xffffff8000ebab95: xor ebx, 0x59fefedf + _0xffffff8000ebab9b: lea edi, [rbx + rdi - 0x59fefedf] + _0xffffff8000ebaba2: mov ebx, eax + _0xffffff8000ebaba4: shr ebx, 8 + _0xffffff8000ebaba7: and ebx, 0x2718 + _0xffffff8000ebabad: shr eax, 9 + _0xffffff8000ebabb0: add eax, 0x6800138c + _0xffffff8000ebabb5: sub eax, ebx + _0xffffff8000ebabb7: xor eax, 0x6800138c + _0xffffff8000ebabbc: or eax, edi + _0xffffff8000ebabbe: add eax, edx + _0xffffff8000ebabc0: mov edi, edx + _0xffffff8000ebabc2: xor edi, esi + _0xffffff8000ebabc4: xor edi, eax + _0xffffff8000ebabc6: mov ebx, dword ptr [rbp - 0x6c] + _0xffffff8000ebabc9: mov dword ptr [rbp - 0x36c], ebx + _0xffffff8000ebabcf: add edi, ebx + _0xffffff8000ebabd1: mov ebx, edi + _0xffffff8000ebabd3: xor ebx, 0x5f9ab54b + _0xffffff8000ebabd9: add ebx, ecx + _0xffffff8000ebabdb: and edi, 0x5f9ab54b + _0xffffff8000ebabe1: lea ecx, [rbx + rdi*2 - 0x36ff3685] + _0xffffff8000ebabe8: mov edi, ecx + _0xffffff8000ebabea: shr edi, 0x1c + _0xffffff8000ebabed: mov ebx, edi + _0xffffff8000ebabef: or ebx, 0x6ff77ffe + _0xffffff8000ebabf5: xor ebx, edi + _0xffffff8000ebabf7: xor ebx, 0x6ff77ffe + _0xffffff8000ebabfd: add ebx, ebx + _0xffffff8000ebabff: neg ebx + _0xffffff8000ebac01: lea edi, [rdi + rbx + 0x4d6072de] + _0xffffff8000ebac08: xor edi, 0x4d6072de + _0xffffff8000ebac0e: shl ecx, 4 + _0xffffff8000ebac11: or ecx, edi + _0xffffff8000ebac13: add ecx, eax + _0xffffff8000ebac15: mov edi, eax + _0xffffff8000ebac17: xor edi, edx + _0xffffff8000ebac19: xor edi, ecx + _0xffffff8000ebac1b: add edi, r9d + _0xffffff8000ebac1e: mov ebx, edi + _0xffffff8000ebac20: xor ebx, 0x6ddffd4f + _0xffffff8000ebac26: add ebx, esi + _0xffffff8000ebac28: and edi, 0x6ddffd4f + _0xffffff8000ebac2e: lea esi, [rbx + rdi*2 + 0x7cc12aab] + _0xffffff8000ebac35: mov edi, esi + _0xffffff8000ebac37: shl edi, 0xc + _0xffffff8000ebac3a: and edi, 0xeafff000 + _0xffffff8000ebac40: mov ebx, esi + _0xffffff8000ebac42: shl ebx, 0xb + _0xffffff8000ebac45: xor ebx, 0x757fff1d + _0xffffff8000ebac4b: lea edi, [rbx + rdi - 0x757fff1d] + _0xffffff8000ebac52: mov ebx, esi + _0xffffff8000ebac54: shr ebx, 0x14 + _0xffffff8000ebac57: and ebx, 0x956 + _0xffffff8000ebac5d: shr esi, 0x15 + _0xffffff8000ebac60: add esi, 0xb14c4ab + _0xffffff8000ebac66: sub esi, ebx + _0xffffff8000ebac68: xor esi, 0xb14c4ab + _0xffffff8000ebac6e: or esi, edi + _0xffffff8000ebac70: add esi, ecx + _0xffffff8000ebac72: mov edi, ecx + _0xffffff8000ebac74: xor edi, eax + _0xffffff8000ebac76: xor edi, esi + _0xffffff8000ebac78: mov ebx, dword ptr [rbp - 0x94] + _0xffffff8000ebac7e: add edi, ebx + _0xffffff8000ebac80: mov r14d, edi + _0xffffff8000ebac83: xor r14d, 0x3b9bbdef + _0xffffff8000ebac8a: add r14d, edx + _0xffffff8000ebac8d: and edi, 0x3b9bbdef + _0xffffff8000ebac93: lea edx, [r14 + rdi*2 - 0x66ac8d6a] + _0xffffff8000ebac9b: mov edi, edx + _0xffffff8000ebac9d: shr edi, 0xf + _0xffffff8000ebaca0: and edi, 0x1d298 + _0xffffff8000ebaca6: mov r14d, edx + _0xffffff8000ebaca9: shr r14d, 0x10 + _0xffffff8000ebacad: add r14d, 0x9d9e94c + _0xffffff8000ebacb4: sub r14d, edi + _0xffffff8000ebacb7: xor r14d, 0x9d9e94c + _0xffffff8000ebacbe: shl edx, 0x10 + _0xffffff8000ebacc1: or edx, r14d + _0xffffff8000ebacc4: add edx, esi + _0xffffff8000ebacc6: mov edi, esi + _0xffffff8000ebacc8: xor edi, ecx + _0xffffff8000ebacca: xor edi, edx + _0xffffff8000ebaccc: mov r14d, dword ptr [rbp - 0x88] + _0xffffff8000ebacd3: add edi, r14d + _0xffffff8000ebacd6: mov r15d, edi + _0xffffff8000ebacd9: xor r15d, 0x5bafff5e + _0xffffff8000ebace0: add r15d, eax + _0xffffff8000ebace3: and edi, 0x5bafff5e + _0xffffff8000ebace9: lea eax, [r15 + rdi*2 - 0x5727e259] + _0xffffff8000ebacf1: mov edi, eax + _0xffffff8000ebacf3: shr edi, 8 + _0xffffff8000ebacf6: and edi, 0x1eab62 + _0xffffff8000ebacfc: mov r15d, eax + _0xffffff8000ebacff: shr r15d, 9 + _0xffffff8000ebad03: add r15d, 0x168f55b1 + _0xffffff8000ebad0a: sub r15d, edi + _0xffffff8000ebad0d: xor r15d, 0x168f55b1 + _0xffffff8000ebad14: shl eax, 0x17 + _0xffffff8000ebad17: or eax, r15d + _0xffffff8000ebad1a: add eax, edx + _0xffffff8000ebad1c: mov edi, edx + _0xffffff8000ebad1e: xor edi, esi + _0xffffff8000ebad20: xor edi, eax + _0xffffff8000ebad22: mov r15d, dword ptr [rbp - 0x7c] + _0xffffff8000ebad26: mov dword ptr [rbp - 0x370], r15d + _0xffffff8000ebad2d: add edi, r15d + _0xffffff8000ebad30: mov r15d, edi + _0xffffff8000ebad33: xor r15d, 0x7dfad573 + _0xffffff8000ebad3a: add r15d, ecx + _0xffffff8000ebad3d: and edi, 0x7dfad573 + _0xffffff8000ebad43: lea ecx, [r15 + rdi*2 + 0x5bd9fac6] + _0xffffff8000ebad4b: mov edi, ecx + _0xffffff8000ebad4d: shr edi, 0x1b + _0xffffff8000ebad50: and edi, 0x16 + _0xffffff8000ebad53: mov r15d, ecx + _0xffffff8000ebad56: shr r15d, 0x1c + _0xffffff8000ebad5a: add r15d, 0x4ab9a11b + _0xffffff8000ebad61: sub r15d, edi + _0xffffff8000ebad64: xor r15d, 0x4ab9a11b + _0xffffff8000ebad6b: shl ecx, 4 + _0xffffff8000ebad6e: or ecx, r15d + _0xffffff8000ebad71: add ecx, eax + _0xffffff8000ebad73: mov edi, eax + _0xffffff8000ebad75: xor edi, edx + _0xffffff8000ebad77: xor edi, ecx + _0xffffff8000ebad79: mov r15d, dword ptr [rbp - 0x70] + _0xffffff8000ebad7d: add edi, r15d + _0xffffff8000ebad80: mov r12d, edi + _0xffffff8000ebad83: xor r12d, 0x5deefbff + _0xffffff8000ebad8a: add r12d, esi + _0xffffff8000ebad8d: and edi, 0x5deefbff + _0xffffff8000ebad93: lea esi, [r12 + rdi*2 - 0x7713621a] + _0xffffff8000ebad9b: mov edi, esi + _0xffffff8000ebad9d: shl edi, 0xc + _0xffffff8000ebada0: and edi, 0xbbd5f000 + _0xffffff8000ebada6: mov r12d, esi + _0xffffff8000ebada9: shl r12d, 0xb + _0xffffff8000ebadad: xor r12d, 0x5deaffab + _0xffffff8000ebadb4: lea edi, [r12 + rdi - 0x5deaffab] + _0xffffff8000ebadbc: mov r12d, esi + _0xffffff8000ebadbf: shr r12d, 0x14 + _0xffffff8000ebadc3: and r12d, 0xd24 + _0xffffff8000ebadca: shr esi, 0x15 + _0xffffff8000ebadcd: add esi, 0x5e35ae92 + _0xffffff8000ebadd3: sub esi, r12d + _0xffffff8000ebadd6: xor esi, 0x5e35ae92 + _0xffffff8000ebaddc: or esi, edi + _0xffffff8000ebadde: add esi, ecx + _0xffffff8000ebade0: mov edi, ecx + _0xffffff8000ebade2: xor edi, eax + _0xffffff8000ebade4: xor edi, esi + _0xffffff8000ebade6: mov r12d, dword ptr [rbp - 0x64] + _0xffffff8000ebadea: add edi, r12d + _0xffffff8000ebaded: mov r13d, edi + _0xffffff8000ebadf0: xor r13d, 0x37fdcfbe + _0xffffff8000ebadf7: add r13d, edx + _0xffffff8000ebadfa: and edi, 0x37fdcfbe + _0xffffff8000ebae00: lea edx, [r13 + rdi*2 - 0x185b52c6] + _0xffffff8000ebae08: mov edi, edx + _0xffffff8000ebae0a: shl edi, 0x11 + _0xffffff8000ebae0d: and edi, 0x6f2e0000 + _0xffffff8000ebae13: mov r13d, edx + _0xffffff8000ebae16: shl r13d, 0x10 + _0xffffff8000ebae1a: xor r13d, 0x3797feef + _0xffffff8000ebae21: lea edi, [r13 + rdi - 0x3797feef] + _0xffffff8000ebae29: mov r13d, edx + _0xffffff8000ebae2c: shr r13d, 0xf + _0xffffff8000ebae30: and r13d, 0x4fa2 + _0xffffff8000ebae37: shr edx, 0x10 + _0xffffff8000ebae3a: add edx, 0x1daa27d1 + _0xffffff8000ebae40: sub edx, r13d + _0xffffff8000ebae43: xor edx, 0x1daa27d1 + _0xffffff8000ebae49: or edx, edi + _0xffffff8000ebae4b: add edx, esi + _0xffffff8000ebae4d: mov edi, esi + _0xffffff8000ebae4f: xor edi, ecx + _0xffffff8000ebae51: xor edi, edx + _0xffffff8000ebae53: mov r13d, dword ptr [rbp - 0x98] + _0xffffff8000ebae5a: mov dword ptr [rbp - 0x37c], r13d + _0xffffff8000ebae61: add edi, r13d + _0xffffff8000ebae64: mov r13d, edi + _0xffffff8000ebae67: xor r13d, 0x7a4e89db + _0xffffff8000ebae6e: add r13d, eax + _0xffffff8000ebae71: and edi, 0x7a4e89db + _0xffffff8000ebae77: lea eax, [r13 + rdi*2 + 0x4a5dcc8a] + _0xffffff8000ebae7f: mov edi, eax + _0xffffff8000ebae81: shl edi, 0x18 + _0xffffff8000ebae84: and edi, 0xfd000000 + _0xffffff8000ebae8a: mov r13d, eax + _0xffffff8000ebae8d: shl r13d, 0x17 + _0xffffff8000ebae91: xor r13d, 0x7efbb3fe + _0xffffff8000ebae98: lea edi, [r13 + rdi - 0x7efbb3fe] + _0xffffff8000ebaea0: mov r13d, eax + _0xffffff8000ebaea3: shr r13d, 8 + _0xffffff8000ebaea7: and r13d, 0xb5726e + _0xffffff8000ebaeae: shr eax, 9 + _0xffffff8000ebaeb1: add eax, 0x46dab937 + _0xffffff8000ebaeb6: sub eax, r13d + _0xffffff8000ebaeb9: xor eax, 0x46dab937 + _0xffffff8000ebaebe: or eax, edi + _0xffffff8000ebaec0: add eax, edx + _0xffffff8000ebaec2: mov edi, esi + _0xffffff8000ebaec4: not edi + _0xffffff8000ebaec6: or edi, eax + _0xffffff8000ebaec8: lea r13d, [rdi + rdi] + _0xffffff8000ebaecc: and r13d, 0xe4e9c480 + _0xffffff8000ebaed3: neg r13d + _0xffffff8000ebaed6: lea edi, [rdi + r13 + 0x7274e240] + _0xffffff8000ebaede: xor edi, edx + _0xffffff8000ebaee0: xor edi, 0x7274e240 + _0xffffff8000ebaee6: add edi, r9d + _0xffffff8000ebaee9: mov r9d, edi + _0xffffff8000ebaeec: xor r9d, 0x67ab7be6 + _0xffffff8000ebaef3: add r9d, ecx + _0xffffff8000ebaef6: and edi, 0x67ab7be6 + _0xffffff8000ebaefc: lea ecx, [r9 + rdi*2 - 0x738259a2] + _0xffffff8000ebaf04: mov edi, ecx + _0xffffff8000ebaf06: shr edi, 0x19 + _0xffffff8000ebaf09: and edi, 0x22 + _0xffffff8000ebaf0c: mov r9d, ecx + _0xffffff8000ebaf0f: shr r9d, 0x1a + _0xffffff8000ebaf13: add r9d, 0x6783fb11 + _0xffffff8000ebaf1a: sub r9d, edi + _0xffffff8000ebaf1d: xor r9d, 0x6783fb11 + _0xffffff8000ebaf24: shl ecx, 6 + _0xffffff8000ebaf27: or ecx, r9d + _0xffffff8000ebaf2a: add ecx, eax + _0xffffff8000ebaf2c: mov edi, edx + _0xffffff8000ebaf2e: not edi + _0xffffff8000ebaf30: or edi, ecx + _0xffffff8000ebaf32: lea r9d, [rdi + rdi] + _0xffffff8000ebaf36: and r9d, 0x1a6604d6 + _0xffffff8000ebaf3d: neg r9d + _0xffffff8000ebaf40: lea edi, [rdi + r9 + 0xd33026b] + _0xffffff8000ebaf48: xor edi, eax + _0xffffff8000ebaf4a: xor edi, 0xd33026b + _0xffffff8000ebaf50: add edi, r10d + _0xffffff8000ebaf53: mov r9d, edi + _0xffffff8000ebaf56: xor r9d, 0x7f6fffeb + _0xffffff8000ebaf5d: add r9d, esi + _0xffffff8000ebaf60: and edi, 0x7f6fffeb + _0xffffff8000ebaf66: lea esi, [r9 + rdi*2 - 0x3c450054] + _0xffffff8000ebaf6e: mov edi, esi + _0xffffff8000ebaf70: shl edi, 0xb + _0xffffff8000ebaf73: and edi, 0xddffa000 + _0xffffff8000ebaf79: mov r9d, esi + _0xffffff8000ebaf7c: shl r9d, 0xa + _0xffffff8000ebaf80: xor r9d, 0x6effd1d7 + _0xffffff8000ebaf87: lea edi, [r9 + rdi - 0x6effd1d7] + _0xffffff8000ebaf8f: mov r9d, esi + _0xffffff8000ebaf92: shr r9d, 0x15 + _0xffffff8000ebaf96: and r9d, 0x2fa + _0xffffff8000ebaf9d: shr esi, 0x16 + _0xffffff8000ebafa0: add esi, 0x7faa5d7d + _0xffffff8000ebafa6: sub esi, r9d + _0xffffff8000ebafa9: xor esi, 0x7faa5d7d + _0xffffff8000ebafaf: or esi, edi + _0xffffff8000ebafb1: add esi, ecx + _0xffffff8000ebafb3: mov edi, eax + _0xffffff8000ebafb5: not edi + _0xffffff8000ebafb7: or edi, esi + _0xffffff8000ebafb9: lea r9d, [rdi + rdi] + _0xffffff8000ebafbd: and r9d, 0xdc96938 + _0xffffff8000ebafc4: neg r9d + _0xffffff8000ebafc7: lea edi, [rdi + r9 + 0x6e4b49c] + _0xffffff8000ebafcf: xor edi, ecx + _0xffffff8000ebafd1: xor edi, 0x6e4b49c + _0xffffff8000ebafd7: add edi, r8d + _0xffffff8000ebafda: mov r8d, edi + _0xffffff8000ebafdd: xor r8d, 0x3eed76d5 + _0xffffff8000ebafe4: add r8d, edx + _0xffffff8000ebafe7: and edi, 0x3eed76d5 + _0xffffff8000ebafed: lea edx, [r8 + rdi*2 + 0x6ca6acd2] + _0xffffff8000ebaff5: mov edi, edx + _0xffffff8000ebaff7: shr edi, 0x10 + _0xffffff8000ebaffa: and edi, 0x8764 + _0xffffff8000ebb000: mov r8d, edx + _0xffffff8000ebb003: shr r8d, 0x11 + _0xffffff8000ebb007: add r8d, 0x32f0c3b2 + _0xffffff8000ebb00e: sub r8d, edi + _0xffffff8000ebb011: xor r8d, 0x32f0c3b2 + _0xffffff8000ebb018: shl edx, 0xf + _0xffffff8000ebb01b: or edx, r8d + _0xffffff8000ebb01e: add edx, esi + _0xffffff8000ebb020: mov edi, ecx + _0xffffff8000ebb022: not edi + _0xffffff8000ebb024: or edi, edx + _0xffffff8000ebb026: lea r8d, [rdi + rdi] + _0xffffff8000ebb02a: and r8d, 0x2f04e8a8 + _0xffffff8000ebb031: neg r8d + _0xffffff8000ebb034: lea edi, [rdi + r8 + 0x17827454] + _0xffffff8000ebb03c: xor edi, esi + _0xffffff8000ebb03e: xor edi, 0x17827454 + _0xffffff8000ebb044: add edi, dword ptr [rbp - 0x378] + _0xffffff8000ebb04a: mov r8d, edi + _0xffffff8000ebb04d: xor r8d, 0x7737c9df + _0xffffff8000ebb054: add r8d, eax + _0xffffff8000ebb057: and edi, 0x7737c9df + _0xffffff8000ebb05d: lea eax, [r8 + rdi*2 - 0x7aa429a6] + _0xffffff8000ebb065: mov edi, eax + _0xffffff8000ebb067: shr edi, 0xb + _0xffffff8000ebb06a: mov r8d, edi + _0xffffff8000ebb06d: or r8d, 0x7e77eb1b + _0xffffff8000ebb074: xor r8d, edi + _0xffffff8000ebb077: xor r8d, 0x7e77eb1b + _0xffffff8000ebb07e: add r8d, r8d + _0xffffff8000ebb081: neg r8d + _0xffffff8000ebb084: lea edi, [rdi + r8 + 0x3e77eb1b] + _0xffffff8000ebb08c: xor edi, 0x3e77eb1b + _0xffffff8000ebb092: shl eax, 0x15 + _0xffffff8000ebb095: or eax, edi + _0xffffff8000ebb097: add eax, edx + _0xffffff8000ebb099: mov edi, esi + _0xffffff8000ebb09b: not edi + _0xffffff8000ebb09d: or edi, eax + _0xffffff8000ebb09f: lea r8d, [rdi + rdi] + _0xffffff8000ebb0a3: and r8d, 0x19ad88c2 + _0xffffff8000ebb0aa: neg r8d + _0xffffff8000ebb0ad: lea edi, [rdi + r8 + 0xcd6c461] + _0xffffff8000ebb0b5: xor edi, edx + _0xffffff8000ebb0b7: xor edi, 0xcd6c461 + _0xffffff8000ebb0bd: add edi, r15d + _0xffffff8000ebb0c0: mov r8d, edi + _0xffffff8000ebb0c3: xor r8d, 0x2fd7dacf + _0xffffff8000ebb0ca: add r8d, ecx + _0xffffff8000ebb0cd: and edi, 0x2fd7dacf + _0xffffff8000ebb0d3: lea ecx, [r8 + rdi*2 + 0x35837ef4] + _0xffffff8000ebb0db: mov edi, ecx + _0xffffff8000ebb0dd: shr edi, 0x1a + _0xffffff8000ebb0e0: mov r8d, edi + _0xffffff8000ebb0e3: or r8d, 0x7fabeefe + _0xffffff8000ebb0ea: xor r8d, edi + _0xffffff8000ebb0ed: xor r8d, 0x7fabeefe + _0xffffff8000ebb0f4: add r8d, r8d + _0xffffff8000ebb0f7: neg r8d + _0xffffff8000ebb0fa: lea edi, [rdi + r8 + 0x5fa246fe] + _0xffffff8000ebb102: xor edi, 0x5fa246fe + _0xffffff8000ebb108: shl ecx, 6 + _0xffffff8000ebb10b: or ecx, edi + _0xffffff8000ebb10d: add ecx, eax + _0xffffff8000ebb10f: mov edi, edx + _0xffffff8000ebb111: not edi + _0xffffff8000ebb113: or edi, ecx + _0xffffff8000ebb115: lea r8d, [rdi + rdi] + _0xffffff8000ebb119: and r8d, 0x79a5b91c + _0xffffff8000ebb120: neg r8d + _0xffffff8000ebb123: lea edi, [rdi + r8 + 0x3cd2dc8e] + _0xffffff8000ebb12b: xor edi, eax + _0xffffff8000ebb12d: xor edi, 0x3cd2dc8e + _0xffffff8000ebb133: add edi, ebx + _0xffffff8000ebb135: mov r8d, edi + _0xffffff8000ebb138: xor r8d, 0x5beffff6 + _0xffffff8000ebb13f: add r8d, esi + _0xffffff8000ebb142: and edi, 0x5beffff6 + _0xffffff8000ebb148: lea esi, [r8 + rdi*2 + 0x331ccc9c] + _0xffffff8000ebb150: mov edi, esi + _0xffffff8000ebb152: shl edi, 0xb + _0xffffff8000ebb155: and edi, 0xe7b9f800 + _0xffffff8000ebb15b: mov r8d, esi + _0xffffff8000ebb15e: shl r8d, 0xa + _0xffffff8000ebb162: xor r8d, 0x73dcfe7f + _0xffffff8000ebb169: lea edi, [r8 + rdi - 0x73dcfe7f] + _0xffffff8000ebb171: mov r8d, esi + _0xffffff8000ebb174: shr r8d, 0x15 + _0xffffff8000ebb178: and r8d, 0x5b8 + _0xffffff8000ebb17f: shr esi, 0x16 + _0xffffff8000ebb182: add esi, 0x16fbcadc + _0xffffff8000ebb188: sub esi, r8d + _0xffffff8000ebb18b: xor esi, 0x16fbcadc + _0xffffff8000ebb191: or esi, edi + _0xffffff8000ebb193: add esi, ecx + _0xffffff8000ebb195: mov edi, eax + _0xffffff8000ebb197: not edi + _0xffffff8000ebb199: or edi, esi + _0xffffff8000ebb19b: lea r8d, [rdi + rdi] + _0xffffff8000ebb19f: and r8d, 0x7f6815f8 + _0xffffff8000ebb1a6: neg r8d + _0xffffff8000ebb1a9: lea edi, [rdi + r8 + 0x3fb40afc] + _0xffffff8000ebb1b1: xor edi, ecx + _0xffffff8000ebb1b3: xor edi, 0x3fb40afc + _0xffffff8000ebb1b9: add edi, r11d + _0xffffff8000ebb1bc: mov r8d, edi + _0xffffff8000ebb1bf: xor r8d, 0x7f6f776d + _0xffffff8000ebb1c6: add r8d, edx + _0xffffff8000ebb1c9: and edi, 0x7f6f776d + _0xffffff8000ebb1cf: lea edx, [r8 + rdi*2 - 0x7f7f82f0] + _0xffffff8000ebb1d7: mov edi, edx + _0xffffff8000ebb1d9: shl edi, 0x10 + _0xffffff8000ebb1dc: and edi, 0xffa70000 + _0xffffff8000ebb1e2: mov r8d, edx + _0xffffff8000ebb1e5: shl r8d, 0xf + _0xffffff8000ebb1e9: xor r8d, 0x7fd3af1e + _0xffffff8000ebb1f0: lea edi, [r8 + rdi - 0x7fd3af1e] + _0xffffff8000ebb1f8: mov r8d, edx + _0xffffff8000ebb1fb: shr r8d, 0x10 + _0xffffff8000ebb1ff: and r8d, 0x71bc + _0xffffff8000ebb206: shr edx, 0x11 + _0xffffff8000ebb209: add edx, 0x623c38de + _0xffffff8000ebb20f: sub edx, r8d + _0xffffff8000ebb212: xor edx, 0x623c38de + _0xffffff8000ebb218: or edx, edi + _0xffffff8000ebb21a: add edx, esi + _0xffffff8000ebb21c: mov edi, ecx + _0xffffff8000ebb21e: not edi + _0xffffff8000ebb220: or edi, edx + _0xffffff8000ebb222: lea r8d, [rdi + rdi] + _0xffffff8000ebb226: and r8d, 0xb915b0b6 + _0xffffff8000ebb22d: neg r8d + _0xffffff8000ebb230: lea edi, [rdi + r8 + 0x5c8ad85b] + _0xffffff8000ebb238: xor edi, esi + _0xffffff8000ebb23a: xor edi, 0x5c8ad85b + _0xffffff8000ebb240: add edi, dword ptr [rbp - 0x374] + _0xffffff8000ebb246: mov r8d, edi + _0xffffff8000ebb249: xor r8d, 0x223df3ff + _0xffffff8000ebb250: add r8d, eax + _0xffffff8000ebb253: and edi, 0x223df3ff + _0xffffff8000ebb259: lea eax, [r8 + rdi*2 + 0x634669d2] + _0xffffff8000ebb261: mov edi, eax + _0xffffff8000ebb263: shr edi, 0xa + _0xffffff8000ebb266: and edi, 0x396c4 + _0xffffff8000ebb26c: mov r8d, eax + _0xffffff8000ebb26f: shr r8d, 0xb + _0xffffff8000ebb273: add r8d, 0x12c1cb62 + _0xffffff8000ebb27a: sub r8d, edi + _0xffffff8000ebb27d: xor r8d, 0x12c1cb62 + _0xffffff8000ebb284: shl eax, 0x15 + _0xffffff8000ebb287: or eax, r8d + _0xffffff8000ebb28a: add eax, edx + _0xffffff8000ebb28c: mov edi, esi + _0xffffff8000ebb28e: not edi + _0xffffff8000ebb290: or edi, eax + _0xffffff8000ebb292: lea r8d, [rdi + rdi] + _0xffffff8000ebb296: and r8d, 0xf55ed942 + _0xffffff8000ebb29d: neg r8d + _0xffffff8000ebb2a0: lea edi, [rdi + r8 + 0x7aaf6ca1] + _0xffffff8000ebb2a8: xor edi, edx + _0xffffff8000ebb2aa: xor edi, 0x7aaf6ca1 + _0xffffff8000ebb2b0: add edi, dword ptr [rbp - 0x368] + _0xffffff8000ebb2b6: mov r8d, edi + _0xffffff8000ebb2b9: xor r8d, 0x7f7f7629 + _0xffffff8000ebb2c0: add r8d, ecx + _0xffffff8000ebb2c3: and edi, 0x7f7f7629 + _0xffffff8000ebb2c9: lea ecx, [r8 + rdi*2 - 0xfd6f7da] + _0xffffff8000ebb2d1: mov edi, ecx + _0xffffff8000ebb2d3: shl edi, 7 + _0xffffff8000ebb2d6: and edi, 0x7effdc80 + _0xffffff8000ebb2dc: mov r8d, ecx + _0xffffff8000ebb2df: shl r8d, 6 + _0xffffff8000ebb2e3: xor r8d, 0x3f7fee76 + _0xffffff8000ebb2ea: lea edi, [r8 + rdi - 0x3f7fee76] + _0xffffff8000ebb2f2: mov r8d, ecx + _0xffffff8000ebb2f5: shr r8d, 0x19 + _0xffffff8000ebb2f9: and r8d, 0x2c + _0xffffff8000ebb2fd: shr ecx, 0x1a + _0xffffff8000ebb300: add ecx, 0x4124be96 + _0xffffff8000ebb306: sub ecx, r8d + _0xffffff8000ebb309: xor ecx, 0x4124be96 + _0xffffff8000ebb30f: or ecx, edi + _0xffffff8000ebb311: add ecx, eax + _0xffffff8000ebb313: mov edi, edx + _0xffffff8000ebb315: not edi + _0xffffff8000ebb317: or edi, ecx + _0xffffff8000ebb319: lea r8d, [rdi + rdi] + _0xffffff8000ebb31d: and r8d, 0x575dca5e + _0xffffff8000ebb324: neg r8d + _0xffffff8000ebb327: lea edi, [rdi + r8 + 0x2baee52f] + _0xffffff8000ebb32f: xor edi, eax + _0xffffff8000ebb331: xor edi, 0x2baee52f + _0xffffff8000ebb337: add edi, r12d + _0xffffff8000ebb33a: mov r8d, edi + _0xffffff8000ebb33d: xor r8d, 0x7f7cef7f + _0xffffff8000ebb344: add r8d, esi + _0xffffff8000ebb347: and edi, 0x7f7cef7f + _0xffffff8000ebb34d: lea esi, [r8 + rdi*2 + 0x7eaff761] + _0xffffff8000ebb355: mov edi, esi + _0xffffff8000ebb357: shl edi, 0xb + _0xffffff8000ebb35a: and edi, 0xbffef800 + _0xffffff8000ebb360: mov r8d, esi + _0xffffff8000ebb363: shl r8d, 0xa + _0xffffff8000ebb367: xor r8d, 0x5fff7efe + _0xffffff8000ebb36e: lea edi, [r8 + rdi - 0x5fff7efe] + _0xffffff8000ebb376: mov r8d, esi + _0xffffff8000ebb379: shr r8d, 0x15 + _0xffffff8000ebb37d: and r8d, 0x3b8 + _0xffffff8000ebb384: shr esi, 0x16 + _0xffffff8000ebb387: add esi, 0x1aa33ddc + _0xffffff8000ebb38d: sub esi, r8d + _0xffffff8000ebb390: xor esi, 0x1aa33ddc + _0xffffff8000ebb396: or esi, edi + _0xffffff8000ebb398: add esi, ecx + _0xffffff8000ebb39a: mov edi, eax + _0xffffff8000ebb39c: not edi + _0xffffff8000ebb39e: or edi, esi + _0xffffff8000ebb3a0: lea r8d, [rdi + rdi] + _0xffffff8000ebb3a4: and r8d, 0x2ccca8c + _0xffffff8000ebb3ab: neg r8d + _0xffffff8000ebb3ae: lea edi, [rdi + r8 + 0x1666546] + _0xffffff8000ebb3b6: xor edi, ecx + _0xffffff8000ebb3b8: xor edi, 0x1666546 + _0xffffff8000ebb3be: add edi, r14d + _0xffffff8000ebb3c1: mov r8d, edi + _0xffffff8000ebb3c4: xor r8d, 0x6ffdfb7e + _0xffffff8000ebb3cb: add r8d, edx + _0xffffff8000ebb3ce: and edi, 0x6ffdfb7e + _0xffffff8000ebb3d4: lea edx, [r8 + rdi*2 + 0x33034796] + _0xffffff8000ebb3dc: mov edi, edx + _0xffffff8000ebb3de: shr edi, 0x10 + _0xffffff8000ebb3e1: and edi, 0xaa1e + _0xffffff8000ebb3e7: mov r8d, edx + _0xffffff8000ebb3ea: shr r8d, 0x11 + _0xffffff8000ebb3ee: add r8d, 0x577b550f + _0xffffff8000ebb3f5: sub r8d, edi + _0xffffff8000ebb3f8: xor r8d, 0x577b550f + _0xffffff8000ebb3ff: shl edx, 0xf + _0xffffff8000ebb402: or edx, r8d + _0xffffff8000ebb405: add edx, esi + _0xffffff8000ebb407: mov edi, ecx + _0xffffff8000ebb409: not edi + _0xffffff8000ebb40b: or edi, edx + _0xffffff8000ebb40d: lea r8d, [rdi + rdi] + _0xffffff8000ebb411: and r8d, 0xee777a72 + _0xffffff8000ebb418: neg r8d + _0xffffff8000ebb41b: lea edi, [rdi + r8 + 0x773bbd39] + _0xffffff8000ebb423: xor edi, esi + _0xffffff8000ebb425: xor edi, 0x773bbd39 + _0xffffff8000ebb42b: add edi, dword ptr [rbp - 0x36c] + _0xffffff8000ebb431: mov r8d, edi + _0xffffff8000ebb434: xor r8d, 0x76bb7f37 + _0xffffff8000ebb43b: add r8d, eax + _0xffffff8000ebb43e: and edi, 0x76bb7f37 + _0xffffff8000ebb444: lea eax, [r8 + rdi*2 - 0x28b36d96] + _0xffffff8000ebb44c: mov edi, eax + _0xffffff8000ebb44e: shr edi, 0xa + _0xffffff8000ebb451: and edi, 0x1a7066 + _0xffffff8000ebb457: mov r8d, eax + _0xffffff8000ebb45a: shr r8d, 0xb + _0xffffff8000ebb45e: add r8d, 0x6dad3833 + _0xffffff8000ebb465: sub r8d, edi + _0xffffff8000ebb468: xor r8d, 0x6dad3833 + _0xffffff8000ebb46f: shl eax, 0x15 + _0xffffff8000ebb472: or eax, r8d + _0xffffff8000ebb475: add eax, edx + _0xffffff8000ebb477: mov edi, esi + _0xffffff8000ebb479: not edi + _0xffffff8000ebb47b: or edi, eax + _0xffffff8000ebb47d: lea r8d, [rdi + rdi] + _0xffffff8000ebb481: and r8d, 0x10925bf6 + _0xffffff8000ebb488: neg r8d + _0xffffff8000ebb48b: lea edi, [rdi + r8 + 0x8492dfb] + _0xffffff8000ebb493: xor edi, edx + _0xffffff8000ebb495: xor edi, 0x8492dfb + _0xffffff8000ebb49b: add edi, dword ptr [rbp - 0x364] + _0xffffff8000ebb4a1: mov r8d, edi + _0xffffff8000ebb4a4: xor r8d, 0x5fafd7bf + _0xffffff8000ebb4ab: add r8d, ecx + _0xffffff8000ebb4ae: and edi, 0x5fafd7bf + _0xffffff8000ebb4b4: lea ecx, [r8 + rdi*2 - 0x685c593d] + _0xffffff8000ebb4bc: mov edi, ecx + _0xffffff8000ebb4be: shl edi, 7 + _0xffffff8000ebb4c1: and edi, 0xf7ffbb80 + _0xffffff8000ebb4c7: mov r8d, ecx + _0xffffff8000ebb4ca: shl r8d, 6 + _0xffffff8000ebb4ce: xor r8d, 0x7bffddd7 + _0xffffff8000ebb4d5: lea edi, [r8 + rdi - 0x7bffddd7] + _0xffffff8000ebb4dd: mov r8d, ecx + _0xffffff8000ebb4e0: shr r8d, 0x19 + _0xffffff8000ebb4e4: and r8d, 0x18 + _0xffffff8000ebb4e8: shr ecx, 0x1a + _0xffffff8000ebb4eb: add ecx, 0x22bc3d8c + _0xffffff8000ebb4f1: sub ecx, r8d + _0xffffff8000ebb4f4: xor ecx, 0x22bc3d8c + _0xffffff8000ebb4fa: or ecx, edi + _0xffffff8000ebb4fc: add ecx, eax + _0xffffff8000ebb4fe: mov rdi, qword ptr [rbp - 0x60] + _0xffffff8000ebb502: add dword ptr [rdi], ecx + _0xffffff8000ebb504: mov edi, edx + _0xffffff8000ebb506: not edi + _0xffffff8000ebb508: or edi, ecx + _0xffffff8000ebb50a: lea r8d, [rdi + rdi] + _0xffffff8000ebb50e: and r8d, 0xdbedf26 + _0xffffff8000ebb515: neg r8d + _0xffffff8000ebb518: lea edi, [rdi + r8 + 0x6df6f93] + _0xffffff8000ebb520: xor edi, eax + _0xffffff8000ebb522: xor edi, 0x6df6f93 + _0xffffff8000ebb528: add edi, dword ptr [rbp - 0x360] + _0xffffff8000ebb52e: mov r8d, edi + _0xffffff8000ebb531: xor r8d, 0x7ddefdb2 + _0xffffff8000ebb538: add r8d, esi + _0xffffff8000ebb53b: and edi, 0x7ddefdb2 + _0xffffff8000ebb541: lea esi, [r8 + rdi*2 + 0x3f5bf483] + _0xffffff8000ebb549: mov edi, esi + _0xffffff8000ebb54b: shl edi, 0xb + _0xffffff8000ebb54e: and edi, 0x9bdbc800 + _0xffffff8000ebb554: mov r8d, esi + _0xffffff8000ebb557: shl r8d, 0xa + _0xffffff8000ebb55b: xor r8d, 0x4dede5ff + _0xffffff8000ebb562: lea edi, [r8 + rdi - 0x4dede5ff] + _0xffffff8000ebb56a: mov r8d, esi + _0xffffff8000ebb56d: shr r8d, 0x15 + _0xffffff8000ebb571: and r8d, 0x1ae + _0xffffff8000ebb578: shr esi, 0x16 + _0xffffff8000ebb57b: add esi, 0x7091b0d7 + _0xffffff8000ebb581: sub esi, r8d + _0xffffff8000ebb584: xor esi, 0x7091b0d7 + _0xffffff8000ebb58a: or esi, edi + _0xffffff8000ebb58c: add esi, ecx + _0xffffff8000ebb58e: mov edi, eax + _0xffffff8000ebb590: not edi + _0xffffff8000ebb592: or edi, esi + _0xffffff8000ebb594: lea r8d, [rdi + rdi] + _0xffffff8000ebb598: and r8d, 0xe66a206a + _0xffffff8000ebb59f: neg r8d + _0xffffff8000ebb5a2: lea edi, [rdi + r8 + 0x73351035] + _0xffffff8000ebb5aa: xor edi, ecx + _0xffffff8000ebb5ac: xor edi, 0x73351035 + _0xffffff8000ebb5b2: add edi, dword ptr [rbp - 0x37c] + _0xffffff8000ebb5b8: mov r8d, edi + _0xffffff8000ebb5bb: xor r8d, 0x7af3fcda + _0xffffff8000ebb5c2: add r8d, edx + _0xffffff8000ebb5c5: and edi, 0x7af3fcda + _0xffffff8000ebb5cb: lea edx, [r8 + rdi*2 - 0x501c2a1f] + _0xffffff8000ebb5d3: mov edi, edx + _0xffffff8000ebb5d5: shr edi, 0x10 + _0xffffff8000ebb5d8: and edi, 0xb842 + _0xffffff8000ebb5de: mov r8d, edx + _0xffffff8000ebb5e1: shr r8d, 0x11 + _0xffffff8000ebb5e5: add r8d, 0x2a0adc21 + _0xffffff8000ebb5ec: sub r8d, edi + _0xffffff8000ebb5ef: xor r8d, 0x2a0adc21 + _0xffffff8000ebb5f6: shl edx, 0xf + _0xffffff8000ebb5f9: or edx, r8d + _0xffffff8000ebb5fc: add edx, esi + _0xffffff8000ebb5fe: not ecx + _0xffffff8000ebb600: or ecx, edx + _0xffffff8000ebb602: lea edi, [rcx + rcx] + _0xffffff8000ebb605: and edi, 0xd7ac14ac + _0xffffff8000ebb60b: neg edi + _0xffffff8000ebb60d: lea ecx, [rcx + rdi + 0x6bd60a56] + _0xffffff8000ebb614: xor ecx, esi + _0xffffff8000ebb616: xor ecx, 0x6bd60a56 + _0xffffff8000ebb61c: add ecx, dword ptr [rbp - 0x370] + _0xffffff8000ebb622: mov edi, ecx + _0xffffff8000ebb624: xor edi, 0x5efdfabd + _0xffffff8000ebb62a: add edi, eax + _0xffffff8000ebb62c: and ecx, 0x5efdfabd + _0xffffff8000ebb632: lea eax, [rdi + rcx*2 - 0x7377272c] + _0xffffff8000ebb639: mov ecx, eax + _0xffffff8000ebb63b: shl ecx, 0x16 + _0xffffff8000ebb63e: and ecx, 0xdec00000 + _0xffffff8000ebb644: mov edi, eax + _0xffffff8000ebb646: shl edi, 0x15 + _0xffffff8000ebb649: xor edi, 0x6f6b6bbf + _0xffffff8000ebb64f: lea ecx, [rdi + rcx - 0x6f6b6bbf] + _0xffffff8000ebb656: mov edi, eax + _0xffffff8000ebb658: shr edi, 0xa + _0xffffff8000ebb65b: and edi, 0x306ce2 + _0xffffff8000ebb661: shr eax, 0xb + _0xffffff8000ebb664: add eax, 0x79183671 + _0xffffff8000ebb669: sub eax, edi + _0xffffff8000ebb66b: xor eax, 0x79183671 + _0xffffff8000ebb670: or eax, ecx + _0xffffff8000ebb672: mov rcx, qword ptr [rbp - 0x60] + _0xffffff8000ebb676: mov edi, dword ptr [rcx + 4] + _0xffffff8000ebb679: add edi, edx + _0xffffff8000ebb67b: add edi, eax + _0xffffff8000ebb67d: mov dword ptr [rcx + 4], edi + _0xffffff8000ebb680: mov rcx, qword ptr [rbp - 0x60] + _0xffffff8000ebb684: add dword ptr [rcx + 8], edx + _0xffffff8000ebb687: mov rcx, qword ptr [rbp - 0x60] + _0xffffff8000ebb68b: add dword ptr [rcx + 0xc], esi + _0xffffff8000ebb68e: jmp _0xffffff8000ebe494 + _0xffffff8000ebb693: lea rdx, [rip + jumptbl_0xffffff8000ebb693] + _0xffffff8000ebb69a: movsxd rax, dword ptr [rdx + rax*4] + _0xffffff8000ebb69e: add rax, rdx + _0xffffff8000ebb6a1: jmp rax + _0xffffff8000ebb6a3: cmp eax, 0x50a14890 + _0xffffff8000ebb6a8: jmp _0xffffff8000ebc574 + _0xffffff8000ebb6ad: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebb6b3: lea edx, [rax - 0x30389c1a] + _0xffffff8000ebb6b9: lea eax, [rax - 0x191bcdc7] + _0xffffff8000ebb6bf: cmp byte ptr [rbp - 0x2c], 4 + _0xffffff8000ebb6c3: jmp _0xffffff8000ebd382 + _0xffffff8000ebb6c8: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebb6ce: lea edx, [rax - 0x335afe93] + _0xffffff8000ebb6d4: lea r14d, [rax - 0x501958d] + _0xffffff8000ebb6db: lea r15d, [rax + 0x191bcdab] + _0xffffff8000ebb6e2: lea eax, [rax - 0x171cce65] + _0xffffff8000ebb6e8: cmp byte ptr [rbp - 0x2b], 9 + _0xffffff8000ebb6ec: jmp _0xffffff8000ebd434 + _0xffffff8000ebb6f1: mov al, byte ptr [rbp - 0xe1] + _0xffffff8000ebb6f7: mov byte ptr [rbp - 0x1a2], al + _0xffffff8000ebb6fd: mov rax, qword ptr [rbp - 0x328] + _0xffffff8000ebb704: mov qword ptr [rax], rax + _0xffffff8000ebb707: mov qword ptr [rax + 8], rax + _0xffffff8000ebb70b: mov qword ptr [rbp - 0x2c0], rax + _0xffffff8000ebb712: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebb718: lea r14d, [rdx - 0x191bcdb7] + _0xffffff8000ebb71f: lea r15d, [rdx - 0x4c9a9326] + _0xffffff8000ebb726: lea r12d, [rdx - 0x191bcdc4] + _0xffffff8000ebb72d: lea edx, [rdx - 0x184ccdca] + _0xffffff8000ebb733: mov r13, qword ptr [rax] + _0xffffff8000ebb736: cmp rax, qword ptr [r13 + 8] + _0xffffff8000ebb73a: mov rax, rsi + _0xffffff8000ebb73d: cmove rax, rcx + _0xffffff8000ebb741: mov eax, dword ptr [rax] + _0xffffff8000ebb743: cmove edx, r12d + _0xffffff8000ebb747: mov r12, qword ptr [rbp - 0x358] + _0xffffff8000ebb74e: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebb755: mov dword ptr [r13], edx + _0xffffff8000ebb759: cmovne r14d, r15d + _0xffffff8000ebb75d: mov dword ptr [r12], r14d + _0xffffff8000ebb761: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebb767: jne _0xffffff8000ebbd8a + _0xffffff8000ebb76d: jmp _0xffffff8000ebc574 + _0xffffff8000ebb772: mov al, byte ptr [rbp - 0x41] + _0xffffff8000ebb775: mov byte ptr [rbp - 0x2b], al + _0xffffff8000ebb778: mov dword ptr [rbp - 0x1ec], 0xffff586f + _0xffffff8000ebb782: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebb788: lea edx, [rax - 0x171cce4c] + _0xffffff8000ebb78e: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebb795: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebb79c: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebb7a3: mov dword ptr [r12], edx + _0xffffff8000ebb7a7: add eax, 0xe8e331a1 + _0xffffff8000ebb7ac: mov dword ptr [r15], eax + _0xffffff8000ebb7af: mov dword ptr [rbp - 0x35c], r14d + _0xffffff8000ebb7b6: jmp _0xffffff8000ebbd8a + _0xffffff8000ebb7bb: mov rax, qword ptr [rbp - 0x198] + _0xffffff8000ebb7c2: movabs rdx, 0x33f4fff6df3fffdf + _0xffffff8000ebb7cc: xor rdx, rax + _0xffffff8000ebb7cf: lea r14, [rax + rax] + _0xffffff8000ebb7d3: movabs r15, 0x1be7fffbe + _0xffffff8000ebb7dd: and r15, r14 + _0xffffff8000ebb7e0: add r15, rdx + _0xffffff8000ebb7e3: shl r15, 2 + _0xffffff8000ebb7e7: add r15, qword ptr [rbp - 0x178] + _0xffffff8000ebb7ee: movabs rdx, 0x302c002483000084 + _0xffffff8000ebb7f8: mov edx, dword ptr [rdx + r15] + _0xffffff8000ebb7fc: movabs r15, 0x397ef77dfbfeb6fe + _0xffffff8000ebb806: xor r15, rax + _0xffffff8000ebb809: movabs r12, 0x1f7fd6dfc + _0xffffff8000ebb813: and r12, r14 + _0xffffff8000ebb816: add r12, r15 + _0xffffff8000ebb819: lea r14, [rbp + r12*4 - 0xa0] + _0xffffff8000ebb821: movabs r15, 0x1a04220810052408 + _0xffffff8000ebb82b: mov dword ptr [r15 + r14], edx + _0xffffff8000ebb82f: inc rax + _0xffffff8000ebb832: mov qword ptr [rbp - 0x198], rax + _0xffffff8000ebb839: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebb83f: lea r14d, [rdx - 0x26478940] + _0xffffff8000ebb846: lea r15d, [rdx - 0x36fc16fb] + _0xffffff8000ebb84d: lea r12d, [rdx - 0xf] + _0xffffff8000ebb851: cmp rax, 0x10 + _0xffffff8000ebb855: mov rax, rsi + _0xffffff8000ebb858: cmove rax, rcx + _0xffffff8000ebb85c: mov eax, dword ptr [rax] + _0xffffff8000ebb85e: cmove r12d, r15d + _0xffffff8000ebb862: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebb869: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebb870: mov dword ptr [r13], r12d + _0xffffff8000ebb874: cmovne r14d, edx + _0xffffff8000ebb878: mov dword ptr [r15], r14d + _0xffffff8000ebb87b: jmp _0xffffff8000ebbab6 + _0xffffff8000ebb880: mov al, byte ptr [rbp - 0x2c] + _0xffffff8000ebb883: mov dl, byte ptr [rbp - 0x2d] + _0xffffff8000ebb886: mov byte ptr [rbp - 0x41], dl + _0xffffff8000ebb889: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebb88f: lea r14d, [rdx - 0x28785803] + _0xffffff8000ebb896: lea r15d, [rdx - 0x8b04eed] + _0xffffff8000ebb89d: lea r12d, [rdx - 0x171cce62] + _0xffffff8000ebb8a4: lea edx, [rdx - 0x16] + _0xffffff8000ebb8a7: cmp al, 4 + _0xffffff8000ebb8a9: mov rax, rsi + _0xffffff8000ebb8ac: cmove rax, rcx + _0xffffff8000ebb8b0: mov eax, dword ptr [rax] + _0xffffff8000ebb8b2: cmovne edx, r12d + _0xffffff8000ebb8b6: mov r12, qword ptr [rbp - 0x358] + _0xffffff8000ebb8bd: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebb8c4: mov dword ptr [r13], edx + _0xffffff8000ebb8c8: cmovne r14d, r15d + _0xffffff8000ebb8cc: mov dword ptr [r12], r14d + _0xffffff8000ebb8d0: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebb8d6: je _0xffffff8000ebbd8a + _0xffffff8000ebb8dc: jmp _0xffffff8000ebc574 + _0xffffff8000ebb8e1: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebb8e7: lea edx, [rax + 0x240a5ff0] + _0xffffff8000ebb8ed: lea r14d, [rax + 0x490804e7] + _0xffffff8000ebb8f4: lea r15d, [rax + 0x30389c1a] + _0xffffff8000ebb8fb: lea eax, [rax + 0x30389c17] + _0xffffff8000ebb901: cmp dword ptr [rbp - 0x34], 3 + _0xffffff8000ebb905: jmp _0xffffff8000ebc976 + _0xffffff8000ebb90a: mov rcx, qword ptr [rdi + 8] + _0xffffff8000ebb90e: mov dword ptr [rcx], 0xb9f3dcdc + _0xffffff8000ebb914: mov dword ptr [rcx + 4], 0xfbdc740b + _0xffffff8000ebb91b: mov dword ptr [rcx + 8], 0x60f77f86 + _0xffffff8000ebb922: mov dword ptr [rcx + 0xc], 0x51907216 + _0xffffff8000ebb929: mov dword ptr [rcx + 0x10], 0 + _0xffffff8000ebb930: mov dword ptr [rcx + 0x14], 0 + _0xffffff8000ebb937: jmp _0xffffff8000ebe494 + _0xffffff8000ebb93c: movsxd rax, dword ptr [r8 + rax*4] + _0xffffff8000ebb940: add rax, r8 + _0xffffff8000ebb943: jmp rax + _0xffffff8000ebb945: cmp eax, 0x37857ad5 + _0xffffff8000ebb94a: jmp _0xffffff8000ebbabc + _0xffffff8000ebb94f: mov rcx, qword ptr [rbp - 0x1e8] + _0xffffff8000ebb956: mov dword ptr [rcx + 0x14], 0 + _0xffffff8000ebb95d: jmp _0xffffff8000ebe494 + _0xffffff8000ebb962: mov rcx, qword ptr [rbp - 0x180] + _0xffffff8000ebb969: mov eax, dword ptr [rbp - 0x1e0] + _0xffffff8000ebb96f: mov dword ptr [rcx + 0x10], eax + _0xffffff8000ebb972: jmp _0xffffff8000ebe494 + _0xffffff8000ebb977: mov rcx, qword ptr [rbp - 0xd0] + _0xffffff8000ebb97e: mov eax, dword ptr [rbp - 0x12c] + _0xffffff8000ebb984: mov dword ptr [rcx + 0x20], eax + _0xffffff8000ebb987: jmp _0xffffff8000ebe494 + _0xffffff8000ebb98c: mov rcx, qword ptr [rbp - 0x120] + _0xffffff8000ebb993: mov eax, dword ptr [rbp - 0x1e0] + _0xffffff8000ebb999: mov dword ptr [rcx + 4], eax + _0xffffff8000ebb99c: jmp _0xffffff8000ebe494 + _0xffffff8000ebb9a1: mov eax, dword ptr [rdi + 4] + _0xffffff8000ebb9a4: mov ecx, eax + _0xffffff8000ebb9a6: shr ecx, 0x17 + _0xffffff8000ebb9a9: and ecx, 0x2a + _0xffffff8000ebb9ac: mov edx, eax + _0xffffff8000ebb9ae: shr edx, 0x18 + _0xffffff8000ebb9b1: add edx, 0x95 + _0xffffff8000ebb9b7: sub edx, ecx + _0xffffff8000ebb9b9: xor edx, 0x94 + _0xffffff8000ebb9bf: xor dl, 1 + _0xffffff8000ebb9c2: mov rcx, qword ptr [rdi + 8] + _0xffffff8000ebb9c6: mov byte ptr [rcx], dl + _0xffffff8000ebb9c8: mov edx, eax + _0xffffff8000ebb9ca: shr edx, 0xf + _0xffffff8000ebb9cd: and edx, 0x7a + _0xffffff8000ebb9d0: mov esi, eax + _0xffffff8000ebb9d2: shr esi, 0x10 + _0xffffff8000ebb9d5: add esi, 0x3d + _0xffffff8000ebb9d8: sub esi, edx + _0xffffff8000ebb9da: xor esi, 0x40 + _0xffffff8000ebb9dd: xor sil, 0x7d + _0xffffff8000ebb9e1: mov byte ptr [rcx + 1], sil + _0xffffff8000ebb9e5: mov edx, eax + _0xffffff8000ebb9e7: shr edx, 7 + _0xffffff8000ebb9ea: and edx, 0xc6 + _0xffffff8000ebb9f0: mov esi, eax + _0xffffff8000ebb9f2: shr esi, 8 + _0xffffff8000ebb9f5: add esi, 0xe3 + _0xffffff8000ebb9fb: sub esi, edx + _0xffffff8000ebb9fd: xor esi, 0xa0 + _0xffffff8000ebba03: xor sil, 0x43 + _0xffffff8000ebba07: mov byte ptr [rcx + 2], sil + _0xffffff8000ebba0b: mov dl, al + _0xffffff8000ebba0d: or dl, 0x59 + _0xffffff8000ebba10: xor dl, al + _0xffffff8000ebba12: xor dl, 0x59 + _0xffffff8000ebba15: add dl, dl + _0xffffff8000ebba17: add al, 0x59 + _0xffffff8000ebba19: sub al, dl + _0xffffff8000ebba1b: xor al, 0x59 + _0xffffff8000ebba1d: mov byte ptr [rcx + 3], al + _0xffffff8000ebba20: jmp _0xffffff8000ebe494 + _0xffffff8000ebba25: mov rcx, qword ptr [rbp - 0x178] + _0xffffff8000ebba2c: mov dword ptr [rcx], 0x2fc320a1 + _0xffffff8000ebba32: mov rcx, qword ptr [rbp - 0x120] + _0xffffff8000ebba39: mov dword ptr [rcx + 4], 0 + _0xffffff8000ebba40: jmp _0xffffff8000ebe494 + _0xffffff8000ebba45: nop dword ptr [rax + rax] + _0xffffff8000ebba4a: nop word ptr [rax + rax] + nop + nop + _0xffffff8000ebba50: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebba56: lea edx, [rax + 0x21f64f37] + _0xffffff8000ebba5c: lea r14d, [rax - 0x2611b751] + _0xffffff8000ebba63: lea r15d, [rax + 0x2ee20b1] + _0xffffff8000ebba6a: lea eax, [rax - 0x171cce4d] + _0xffffff8000ebba70: cmp byte ptr [rbp - 0x29], 4 + _0xffffff8000ebba74: jmp _0xffffff8000ebbb97 + _0xffffff8000ebba79: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebba7f: lea edx, [rax + 0x89c32ba] + _0xffffff8000ebba85: lea r14d, [rax - 0x1722871e] + _0xffffff8000ebba8c: lea r15d, [rax + 0x1f9d4823] + _0xffffff8000ebba93: lea eax, [rax + 2] + _0xffffff8000ebba96: cmp dword ptr [rbp - 0x34], 1 + _0xffffff8000ebba9a: jmp _0xffffff8000ebd434 + _0xffffff8000ebba9f: mov eax, dword ptr [rbp - 0x344] + _0xffffff8000ebbaa5: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebbaab: jmp _0xffffff8000ebc574 + _0xffffff8000ebbab0: mov eax, dword ptr [rbp - 0x344] + _0xffffff8000ebbab6: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebbabc: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbac2: mov edx, 0xaf5eb78d + _0xffffff8000ebbac7: add eax, edx + _0xffffff8000ebbac9: cmp eax, 0x1d + _0xffffff8000ebbacc: ja _0xffffff8000ebbd8a + _0xffffff8000ebbad2: lea rdx, [rip + jumptbl_0xffffff8000ebbad2] + _0xffffff8000ebbad9: movsxd rax, dword ptr [rdx + rax*4] + _0xffffff8000ebbadd: add rax, rdx + _0xffffff8000ebbae0: jmp rax + _0xffffff8000ebbae2: mov rax, qword ptr [rbp - 0x108] + _0xffffff8000ebbae9: mov qword ptr [rbp - 0xc0], rax + _0xffffff8000ebbaf0: movzx edx, byte ptr [rbp - 0xf9] + _0xffffff8000ebbaf7: mov byte ptr [rbp - 0x1a3], dl + _0xffffff8000ebbafd: add rax, rax + _0xffffff8000ebbb00: mov qword ptr [rbp - 0xb8], rax + _0xffffff8000ebbb07: mov qword ptr [rbp - 0xb0], rdx + _0xffffff8000ebbb0e: mov qword ptr [rbp - 0xf0], 0 + _0xffffff8000ebbb19: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbb1f: lea edx, [rax + 0x191bcdb6] + _0xffffff8000ebbb25: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebbb2c: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebbb33: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebbb3a: mov dword ptr [r12], edx + _0xffffff8000ebbb3e: add eax, 0xb + _0xffffff8000ebbb41: jmp _0xffffff8000ebb7ac + _0xffffff8000ebbb46: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbb4c: lea rdx, [rip + 0x30ba] + _0xffffff8000ebbb53: mov rdx, qword ptr [rdx] + // Inject value + //mov rdx, 0x8b48c0ff483a0c88 + _0xffffff8000ebbb56: lea edx, [rax + 7] + _0xffffff8000ebbb59: lea r14d, [rax + 0x52838b1e] + _0xffffff8000ebbb60: lea r15d, [rax + 5] + _0xffffff8000ebbb64: lea eax, [rax + 0x30389c10] + _0xffffff8000ebbb6a: cmp dword ptr [rbp - 0x34], 2 + _0xffffff8000ebbb6e: jmp _0xffffff8000ebd434 + _0xffffff8000ebbb73: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbb79: lea edx, [rax - 0x3aad281c] + _0xffffff8000ebbb7f: lea r14d, [rax - 0xbc21b6b] + _0xffffff8000ebbb86: lea r15d, [rax + 0x14cebb60] + _0xffffff8000ebbb8d: lea eax, [rax - 0x30389c15] + _0xffffff8000ebbb93: cmp byte ptr [rbp - 0x29], 1 + _0xffffff8000ebbb97: mov r12, rsi + _0xffffff8000ebbb9a: cmove r12, rcx + _0xffffff8000ebbb9e: mov r12d, dword ptr [r12] + _0xffffff8000ebbba2: cmove eax, r15d + _0xffffff8000ebbba6: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebbbad: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebbbb4: mov dword ptr [r13], eax + _0xffffff8000ebbbb8: cmovne edx, r14d + _0xffffff8000ebbbbc: mov dword ptr [r15], edx + _0xffffff8000ebbbbf: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebbbc6: je _0xffffff8000ebbd8a + _0xffffff8000ebbbcc: jmp _0xffffff8000ebc574 + _0xffffff8000ebbbd1: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbbd7: lea edx, [rax - 0xb] + _0xffffff8000ebbbda: lea r14d, [rax + 0x30389c0b] + _0xffffff8000ebbbe1: lea r15d, [rax + 0x171cce59] + _0xffffff8000ebbbe8: lea eax, [rax - 0xc] + _0xffffff8000ebbbeb: cmp dword ptr [rbp - 0x34], 7 + _0xffffff8000ebbbef: jmp _0xffffff8000ebd48c + _0xffffff8000ebbbf4: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbbfa: lea edx, [rax + 0x171cce54] + _0xffffff8000ebbc00: lea r14d, [rax + 0x30389c1a] + _0xffffff8000ebbc07: lea eax, [rax + 0x30389c0b] + _0xffffff8000ebbc0d: cmp dword ptr [rbp - 0x34], 6 + _0xffffff8000ebbc11: mov r15, rsi + _0xffffff8000ebbc14: cmovl r15, rcx + _0xffffff8000ebbc18: mov r15d, dword ptr [r15] + _0xffffff8000ebbc1b: cmovl eax, r14d + _0xffffff8000ebbc1f: mov r14, qword ptr [rbp - 0x358] + _0xffffff8000ebbc26: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebbc2d: mov dword ptr [r12], eax + _0xffffff8000ebbc31: mov dword ptr [r14], edx + _0xffffff8000ebbc34: mov dword ptr [rbp - 0x35c], r15d + _0xffffff8000ebbc3b: jl _0xffffff8000ebbd8a + _0xffffff8000ebbc41: jmp _0xffffff8000ebc574 + _0xffffff8000ebbc46: mov dword ptr [rbp - 0x1ec], 0xffff586f + _0xffffff8000ebbc50: mov al, byte ptr [rbp - 0x1a2] + _0xffffff8000ebbc56: mov byte ptr [rbp - 0x2a], al + _0xffffff8000ebbc59: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbc5f: lea edx, [rax - 0x171cce59] + _0xffffff8000ebbc65: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebbc6c: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebbc73: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebbc7a: mov dword ptr [r12], edx + _0xffffff8000ebbc7e: add eax, 7 + _0xffffff8000ebbc81: mov dword ptr [r15], eax + _0xffffff8000ebbc84: mov dword ptr [rbp - 0x35c], r14d + _0xffffff8000ebbc8b: jmp _0xffffff8000ebbabc + _0xffffff8000ebbc90: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbc96: lea edx, [rax - 0x30389c0f] + _0xffffff8000ebbc9c: lea r14d, [rax - 0x191bcdb5] + _0xffffff8000ebbca3: cmp dword ptr [rbp - 0x34], 9 + _0xffffff8000ebbca7: mov r15, rsi + _0xffffff8000ebbcaa: cmovl r15, rcx + _0xffffff8000ebbcae: mov r15d, dword ptr [r15] + _0xffffff8000ebbcb1: cmovl r14d, edx + _0xffffff8000ebbcb5: mov rdx, qword ptr [rbp - 0x358] + _0xffffff8000ebbcbc: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebbcc3: mov dword ptr [r12], r14d + _0xffffff8000ebbcc7: add eax, 0xe6e43244 + _0xffffff8000ebbccc: mov dword ptr [rdx], eax + _0xffffff8000ebbcce: mov dword ptr [rbp - 0x35c], r15d + _0xffffff8000ebbcd5: jmp _0xffffff8000ebc574 + _0xffffff8000ebbcda: mov al, byte ptr [rbp - 0x2c] + _0xffffff8000ebbcdd: mov dl, byte ptr [rbp - 0x2d] + _0xffffff8000ebbce0: mov byte ptr [rbp - 0x41], dl + _0xffffff8000ebbce3: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebbce9: lea r14d, [rdx - 0x12f3d70b] + _0xffffff8000ebbcf0: lea r15d, [rdx - 0x36c3535e] + _0xffffff8000ebbcf7: lea r12d, [rdx - 0x191bcdce] + _0xffffff8000ebbcfe: lea edx, [rdx - 0x30389c1a] + _0xffffff8000ebbd04: dec al + _0xffffff8000ebbd06: cmp al, 2 + _0xffffff8000ebbd08: mov rax, rsi + _0xffffff8000ebbd0b: cmovb rax, rcx + _0xffffff8000ebbd0f: mov eax, dword ptr [rax] + _0xffffff8000ebbd11: cmovb edx, r12d + _0xffffff8000ebbd15: mov r12, qword ptr [rbp - 0x358] + _0xffffff8000ebbd1c: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebbd23: mov dword ptr [r13], edx + _0xffffff8000ebbd27: cmovae r14d, r15d + _0xffffff8000ebbd2b: mov dword ptr [r12], r14d + _0xffffff8000ebbd2f: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebbd35: jae _0xffffff8000ebc574 + _0xffffff8000ebbd3b: jmp _0xffffff8000ebbd8a + _0xffffff8000ebbd3d: nop dword ptr [rax] + nop + _0xffffff8000ebbd40: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbd46: lea edx, [rax - 8] + _0xffffff8000ebbd49: lea r14d, [rax + 0x191bcdc8] + _0xffffff8000ebbd50: lea eax, [rax + 0x191bcdb9] + _0xffffff8000ebbd56: cmp dword ptr [rbp - 0x34], 4 + _0xffffff8000ebbd5a: mov r15, rsi + _0xffffff8000ebbd5d: cmovl r15, rcx + _0xffffff8000ebbd61: mov r15d, dword ptr [r15] + _0xffffff8000ebbd64: cmovl eax, r14d + _0xffffff8000ebbd68: mov r14, qword ptr [rbp - 0x358] + _0xffffff8000ebbd6f: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebbd76: mov dword ptr [r12], eax + _0xffffff8000ebbd7a: mov dword ptr [r14], edx + _0xffffff8000ebbd7d: mov dword ptr [rbp - 0x35c], r15d + _0xffffff8000ebbd84: jl _0xffffff8000ebc574 + _0xffffff8000ebbd8a: lea rax, [rip + 0xb65a] + _0xffffff8000ebbd91: mov rax, qword ptr [rax] + // Inject value + //mov rax, 0xa075894c9865894c + _0xffffff8000ebbd94: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbd9a: cmp eax, 0x50a1488f + _0xffffff8000ebbd9f: jg _0xffffff8000ebb6a3 + _0xffffff8000ebbda5: add eax, 0xc87a8548 + _0xffffff8000ebbdaa: cmp eax, 0x1d + _0xffffff8000ebbdad: ja _0xffffff8000ebc574 + _0xffffff8000ebbdb3: jmp _0xffffff8000ebb693 + _0xffffff8000ebbdb8: movzx eax, byte ptr [rbp - 0xc2] + _0xffffff8000ebbdbf: mov edx, eax + _0xffffff8000ebbdc1: and edx, 0x2b + _0xffffff8000ebbdc4: xor eax, 0x77fbbf2b + _0xffffff8000ebbdc9: lea eax, [rax + rdx*2 - 0x77fbbf2b] + _0xffffff8000ebbdd0: movzx edx, byte ptr [rbp - 0xc1] + _0xffffff8000ebbdd7: mov r14d, edx + _0xffffff8000ebbdda: and r14d, 0xef + _0xffffff8000ebbde1: xor edx, 0x697cffef + _0xffffff8000ebbde7: lea edx, [rdx + r14*2 - 0x697cffef] + _0xffffff8000ebbdef: imul edx, eax + _0xffffff8000ebbdf2: lea eax, [rdx*4] + _0xffffff8000ebbdf9: xor eax, 0x7dbff7bd + _0xffffff8000ebbdfe: mov dword ptr [rbp - 0x1ec], eax + _0xffffff8000ebbe04: shl edx, 3 + _0xffffff8000ebbe07: and edx, 0xfb7fef78 + _0xffffff8000ebbe0d: mov dword ptr [rbp - 0x1e0], edx + _0xffffff8000ebbe13: movzx eax, byte ptr [rbp - 0xc2] + _0xffffff8000ebbe1a: mov dword ptr [rbp - 0x12c], 0 + _0xffffff8000ebbe24: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebbe2a: lea r14d, [rdx - 0x1515310] + _0xffffff8000ebbe31: lea r15d, [rdx - 0x11aab8b5] + _0xffffff8000ebbe38: lea r12d, [rdx + 0x26f7146a] + _0xffffff8000ebbe3f: lea edx, [rdx - 0x191bcdb8] + _0xffffff8000ebbe45: mov r13d, eax + _0xffffff8000ebbe48: xor r13d, 0x3dba7edf + _0xffffff8000ebbe4f: lea eax, [rax + rax] + _0xffffff8000ebbe52: add r13d, 0xc2458121 + _0xffffff8000ebbe59: and eax, 0x1be + _0xffffff8000ebbe5e: neg eax + _0xffffff8000ebbe60: cmp r13d, eax + _0xffffff8000ebbe63: mov rax, rsi + _0xffffff8000ebbe66: cmove rax, rcx + _0xffffff8000ebbe6a: mov eax, dword ptr [rax] + _0xffffff8000ebbe6c: cmove edx, r12d + _0xffffff8000ebbe70: jmp _0xffffff8000ebb8b6 + _0xffffff8000ebbe75: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbe7b: lea edx, [rax + 7] + _0xffffff8000ebbe7e: lea r14d, [rax + 0x19] + _0xffffff8000ebbe82: lea r15d, [rax + 6] + _0xffffff8000ebbe86: lea eax, [rax + 0x17] + _0xffffff8000ebbe89: cmp byte ptr [rbp - 0x2b], 3 + _0xffffff8000ebbe8d: mov r12, rsi + _0xffffff8000ebbe90: cmovl r12, rcx + _0xffffff8000ebbe94: mov r12d, dword ptr [r12] + _0xffffff8000ebbe98: cmovl eax, r15d + _0xffffff8000ebbe9c: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebbea3: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebbeaa: mov dword ptr [r13], eax + _0xffffff8000ebbeae: cmovge edx, r14d + _0xffffff8000ebbeb2: mov dword ptr [r15], edx + _0xffffff8000ebbeb5: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebbebc: jmp _0xffffff8000ebc574 + _0xffffff8000ebbec1: mov qword ptr [rbp - 0x1f8], rdi + _0xffffff8000ebbec8: mov qword ptr [rbp - 0x120], rdi + _0xffffff8000ebbecf: mov rax, qword ptr [rdi + 8] + _0xffffff8000ebbed3: mov qword ptr [rbp - 0x340], rax + _0xffffff8000ebbeda: mov rax, qword ptr [rdi + 0x18] + _0xffffff8000ebbede: mov qword ptr [rbp - 0x178], rax + _0xffffff8000ebbee5: mov rax, qword ptr [rbp - 0x120] + _0xffffff8000ebbeec: mov rax, qword ptr [rax + 0x10] + _0xffffff8000ebbef0: mov qword ptr [rbp - 0x170], rax + _0xffffff8000ebbef7: cmp qword ptr [rbp - 0x178], 0 + _0xffffff8000ebbeff: sete dl + _0xffffff8000ebbf02: cmp qword ptr [rbp - 0x340], 0 + _0xffffff8000ebbf0a: sete r14b + _0xffffff8000ebbf0e: or r14b, dl + _0xffffff8000ebbf11: test rax, rax + _0xffffff8000ebbf14: sete al + _0xffffff8000ebbf17: or al, r14b + _0xffffff8000ebbf1a: mov byte ptr [rbp - 0x1d9], al + _0xffffff8000ebbf20: lea rax, [rbp - 0x2b8] + _0xffffff8000ebbf27: mov qword ptr [rbp - 0x2c0], rax + _0xffffff8000ebbf2e: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ebbf35: mov qword ptr [rbp - 0x2b0], rax + _0xffffff8000ebbf3c: lea rax, [rbp - 0x2a8] + _0xffffff8000ebbf43: mov qword ptr [rbp - 0x338], rax + _0xffffff8000ebbf4a: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ebbf51: mov qword ptr [rbp - 0x2a0], rax + _0xffffff8000ebbf58: lea rax, [rbp - 0x298] + _0xffffff8000ebbf5f: mov qword ptr [rbp - 0x160], rax + _0xffffff8000ebbf66: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebbf6d: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ebbf74: lea rdx, [rax + 8] + _0xffffff8000ebbf78: mov qword ptr [rbp - 0x118], rdx + _0xffffff8000ebbf7f: mov rax, qword ptr [rax + 8] + _0xffffff8000ebbf83: mov rdx, qword ptr [rbp - 0x160] + _0xffffff8000ebbf8a: mov qword ptr [rdx + 8], rax + _0xffffff8000ebbf8e: mov qword ptr [rbp - 0x40], rax + _0xffffff8000ebbf92: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebbf98: lea edx, [rax - 0x191bcdb2] + _0xffffff8000ebbf9e: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebbfa5: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebbfac: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebbfb3: mov dword ptr [r12], edx + _0xffffff8000ebbfb7: add eax, 0xcfc763f8 + _0xffffff8000ebbfbc: jmp _0xffffff8000ebbc81 + _0xffffff8000ebbfc1: mov rax, qword ptr [rbp - 0x178] + _0xffffff8000ebbfc8: mov dword ptr [rax], 0 + _0xffffff8000ebbfce: mov rax, qword ptr [rbp - 0x340] + _0xffffff8000ebbfd5: mov dl, byte ptr [rax + 6] + _0xffffff8000ebbfd8: mov byte ptr [rbp - 0xc2], dl + _0xffffff8000ebbfde: mov al, byte ptr [rax + 7] + _0xffffff8000ebbfe1: mov byte ptr [rbp - 0xc1], al + _0xffffff8000ebbfe7: mov rax, qword ptr [rbp - 0x170] + _0xffffff8000ebbfee: mov al, byte ptr [rax + 9] + _0xffffff8000ebbff1: mov dword ptr [rbp - 0x12c], 0xffff586f + _0xffffff8000ebbffb: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebc001: lea r14d, [rdx - 0x171cce74] + _0xffffff8000ebc008: lea r15d, [rdx + 0x153f0426] + _0xffffff8000ebc00f: lea r12d, [rdx + 0x3f35f699] + _0xffffff8000ebc016: lea edx, [rdx - 0x18] + _0xffffff8000ebc019: cmp al, 1 + _0xffffff8000ebc01b: mov rax, rsi + _0xffffff8000ebc01e: cmove rax, rcx + _0xffffff8000ebc022: mov eax, dword ptr [rax] + _0xffffff8000ebc024: cmovne edx, r12d + _0xffffff8000ebc028: mov r12, qword ptr [rbp - 0x358] + _0xffffff8000ebc02f: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebc036: mov dword ptr [r13], edx + _0xffffff8000ebc03a: cmovne r14d, r15d + _0xffffff8000ebc03e: mov dword ptr [r12], r14d + _0xffffff8000ebc042: jmp _0xffffff8000ebca2d + _0xffffff8000ebc047: mov eax, dword ptr [rbp - 0x1e0] + _0xffffff8000ebc04d: mov edx, eax + _0xffffff8000ebc04f: shr edx, 0x17 + _0xffffff8000ebc052: and edx, 0x68 + _0xffffff8000ebc055: shr eax, 0x18 + _0xffffff8000ebc058: add eax, 0x34 + _0xffffff8000ebc05b: sub eax, edx + _0xffffff8000ebc05d: xor eax, 0x34 + _0xffffff8000ebc060: mov byte ptr [rbp - 0x12d], al + _0xffffff8000ebc066: mov eax, dword ptr [rbp - 0x1e0] + _0xffffff8000ebc06c: mov edx, eax + _0xffffff8000ebc06e: shr edx, 0xf + _0xffffff8000ebc071: and edx, 0xee + _0xffffff8000ebc077: shr eax, 0x10 + _0xffffff8000ebc07a: add eax, 0xf7 + _0xffffff8000ebc07f: sub eax, edx + _0xffffff8000ebc081: xor eax, 0xf7 + _0xffffff8000ebc086: mov byte ptr [rbp - 0x12e], al + _0xffffff8000ebc08c: mov eax, dword ptr [rbp - 0x1e0] + _0xffffff8000ebc092: mov edx, eax + _0xffffff8000ebc094: shr edx, 7 + _0xffffff8000ebc097: and edx, 0x40 + _0xffffff8000ebc09a: shr eax, 8 + _0xffffff8000ebc09d: add eax, 0x20 + _0xffffff8000ebc0a0: sub eax, edx + _0xffffff8000ebc0a2: xor eax, 0x20 + _0xffffff8000ebc0a5: mov byte ptr [rbp - 0x12f], al + _0xffffff8000ebc0ab: mov al, byte ptr [rbp - 0x1e0] + _0xffffff8000ebc0b1: mov byte ptr [rbp - 0x130], al + _0xffffff8000ebc0b7: mov dword ptr [rbp - 0x12c], 0 + _0xffffff8000ebc0c1: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc0c7: lea edx, [rax + 0x191bcdb2] + _0xffffff8000ebc0cd: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebc0d4: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc0db: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebc0e2: mov dword ptr [r12], edx + _0xffffff8000ebc0e6: add eax, 0xe8e331ae + _0xffffff8000ebc0eb: mov dword ptr [r15], eax + _0xffffff8000ebc0ee: mov dword ptr [rbp - 0x35c], r14d + _0xffffff8000ebc0f5: jmp _0xffffff8000ebc574 + _0xffffff8000ebc0fa: mov r14d, dword ptr [rdi] + _0xffffff8000ebc0fd: lea r15d, [r14 + 4] + _0xffffff8000ebc101: mov edx, 0x66666667 + _0xffffff8000ebc106: mov eax, r15d + _0xffffff8000ebc109: imul edx + _0xffffff8000ebc10b: mov eax, edx + _0xffffff8000ebc10d: shr eax, 0x1f + _0xffffff8000ebc110: sar edx, 2 + _0xffffff8000ebc113: add edx, eax + _0xffffff8000ebc115: imul eax, edx, 0xa + _0xffffff8000ebc118: neg eax + _0xffffff8000ebc11a: lea eax, [r14 + rax + 4] + _0xffffff8000ebc11f: cmp r15d, 0xa + _0xffffff8000ebc123: cmovl eax, r15d + _0xffffff8000ebc127: mov dword ptr [rbp - 0x34], eax + _0xffffff8000ebc12a: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc130: lea edx, [rax - 0x191bcdcc] + _0xffffff8000ebc136: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebc13d: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc144: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebc14b: mov dword ptr [r12], edx + _0xffffff8000ebc14f: add eax, 0xcfc763ea + _0xffffff8000ebc154: jmp _0xffffff8000ebbc81 + _0xffffff8000ebc159: mov qword ptr [rbp - 0x188], rdi + _0xffffff8000ebc160: mov qword ptr [rbp - 0xd0], rdi + _0xffffff8000ebc167: mov rax, qword ptr [rdi + 0x10] + _0xffffff8000ebc16b: mov qword ptr [rbp - 0x1f8], rax + _0xffffff8000ebc172: mov rax, qword ptr [rdi + 0x28] + _0xffffff8000ebc176: mov qword ptr [rbp - 0x340], rax + _0xffffff8000ebc17d: mov rax, qword ptr [rbp - 0xd0] + _0xffffff8000ebc184: mov rdx, qword ptr [rax + 0x18] + _0xffffff8000ebc188: mov qword ptr [rbp - 0x178], rdx + _0xffffff8000ebc18f: mov rax, qword ptr [rax + 8] + _0xffffff8000ebc193: mov qword ptr [rbp - 0x170], rax + _0xffffff8000ebc19a: cmp qword ptr [rbp - 0x340], 0 + _0xffffff8000ebc1a2: sete dl + _0xffffff8000ebc1a5: cmp qword ptr [rbp - 0x1f8], 0 + _0xffffff8000ebc1ad: sete r14b + _0xffffff8000ebc1b1: or r14b, dl + _0xffffff8000ebc1b4: cmp qword ptr [rbp - 0x178], 0 + _0xffffff8000ebc1bc: sete dl + _0xffffff8000ebc1bf: or dl, r14b + _0xffffff8000ebc1c2: test rax, rax + _0xffffff8000ebc1c5: sete al + _0xffffff8000ebc1c8: or al, dl + _0xffffff8000ebc1ca: mov byte ptr [rbp - 0xc3], al + _0xffffff8000ebc1d0: lea rax, [rbp - 0x2b8] + _0xffffff8000ebc1d7: mov qword ptr [rbp - 0x338], rax + _0xffffff8000ebc1de: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ebc1e5: mov qword ptr [rbp - 0x2b0], rax + _0xffffff8000ebc1ec: lea rax, [rbp - 0x2a8] + _0xffffff8000ebc1f3: mov qword ptr [rbp - 0x2c0], rax + _0xffffff8000ebc1fa: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ebc201: mov qword ptr [rbp - 0x2a0], rax + _0xffffff8000ebc208: lea rax, [rbp - 0x298] + _0xffffff8000ebc20f: mov qword ptr [rbp - 0x160], rax + _0xffffff8000ebc216: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebc21d: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ebc224: lea rdx, [rax + 8] + _0xffffff8000ebc228: mov qword ptr [rbp - 0x118], rdx + _0xffffff8000ebc22f: mov rax, qword ptr [rax + 8] + _0xffffff8000ebc233: mov rdx, qword ptr [rbp - 0x160] + _0xffffff8000ebc23a: mov qword ptr [rdx + 8], rax + _0xffffff8000ebc23e: mov qword ptr [rbp - 0x40], rax + _0xffffff8000ebc242: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc248: lea edx, [rax - 0xb] + _0xffffff8000ebc24b: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebc252: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc259: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebc260: mov dword ptr [r12], edx + _0xffffff8000ebc264: add eax, 0xe8e3319f + _0xffffff8000ebc269: jmp _0xffffff8000ebbc81 + _0xffffff8000ebc26e: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc274: lea edx, [rax - 0x191bcdcf] + _0xffffff8000ebc27a: lea r14d, [rax - 0x191bcdc2] + _0xffffff8000ebc281: lea eax, [rax - 0x191bcdbb] + _0xffffff8000ebc287: cmp dword ptr [rbp - 0x34], 2 + _0xffffff8000ebc28b: mov r15, rsi + _0xffffff8000ebc28e: cmovl r15, rcx + _0xffffff8000ebc292: mov r15d, dword ptr [r15] + _0xffffff8000ebc295: cmovl eax, r14d + _0xffffff8000ebc299: mov r14, qword ptr [rbp - 0x358] + _0xffffff8000ebc2a0: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebc2a7: mov dword ptr [r12], eax + _0xffffff8000ebc2ab: mov dword ptr [r14], edx + _0xffffff8000ebc2ae: mov dword ptr [rbp - 0x35c], r15d + _0xffffff8000ebc2b5: jl _0xffffff8000ebbabc + _0xffffff8000ebc2bb: jmp _0xffffff8000ebc574 + _0xffffff8000ebc2c0: mov rax, qword ptr [rbp - 0x170] + _0xffffff8000ebc2c7: movzx edx, byte ptr [rax] + _0xffffff8000ebc2ca: cmp edx, 0x32 + _0xffffff8000ebc2cd: sbb r14d, r14d + _0xffffff8000ebc2d0: and r14d, 0x100 + _0xffffff8000ebc2d7: add r14d, edx + _0xffffff8000ebc2da: add r14d, -0x32 + _0xffffff8000ebc2de: mov edx, dword ptr [rbp - 0x1ec] + _0xffffff8000ebc2e4: imul r14d, edx + _0xffffff8000ebc2e8: mov r15d, r14d + _0xffffff8000ebc2eb: and r15d, 0x2c + _0xffffff8000ebc2ef: and r14d, 0x3f + _0xffffff8000ebc2f3: xor r14d, 0x6fadb56c + _0xffffff8000ebc2fa: lea r14d, [r14 + r15*2 - 0x3042508] + _0xffffff8000ebc302: cmp r14d, 0x6ca99064 + _0xffffff8000ebc309: sbb r15, r15 + _0xffffff8000ebc30c: and r15d, 1 + _0xffffff8000ebc310: shl r15, 0x20 + _0xffffff8000ebc314: add r15, r14 + _0xffffff8000ebc317: movabs r14, 0x6f07f3f1f19bc5 + _0xffffff8000ebc321: add r15, r14 + _0xffffff8000ebc324: mov r14, r15 + _0xffffff8000ebc327: shl r14, 8 + _0xffffff8000ebc32b: add r14d, 0x8c2c9c6 + _0xffffff8000ebc332: shl r15, 9 + _0xffffff8000ebc336: add r15d, 0x49a7ae00 + _0xffffff8000ebc33d: and r15d, 0x47dde400 + _0xffffff8000ebc344: sub r14d, r15d + _0xffffff8000ebc347: mov r15d, r14d + _0xffffff8000ebc34a: xor r15d, 0xb3910d73 + _0xffffff8000ebc351: xor r14d, 0x6ef284 + _0xffffff8000ebc358: and r14d, 0x107fffb5 + _0xffffff8000ebc35f: lea r14d, [r15 + r14*2 - 0x304201] + _0xffffff8000ebc367: cmp r14d, 0x104fbdb4 + _0xffffff8000ebc36e: sbb r15, r15 + _0xffffff8000ebc371: and r15d, 1 + _0xffffff8000ebc375: shl r15, 0x20 + _0xffffff8000ebc379: add r15, r14 + _0xffffff8000ebc37c: mov r14, qword ptr [rbp - 0x340] + _0xffffff8000ebc383: lea r14, [r15 + r14 - 0x104fbdb4] + _0xffffff8000ebc38b: mov r15d, edx + _0xffffff8000ebc38e: shr r15d, 3 + _0xffffff8000ebc392: and r15d, 0xe0 + _0xffffff8000ebc399: mov r12d, edx + _0xffffff8000ebc39c: shr r12d, 4 + _0xffffff8000ebc3a0: add r12d, 0x70 + _0xffffff8000ebc3a4: sub r12d, r15d + _0xffffff8000ebc3a7: xor r12d, 0x70 + _0xffffff8000ebc3ab: mov r15b, r12b + _0xffffff8000ebc3ae: add r15b, r15b + _0xffffff8000ebc3b1: xor r12b, 0x57 + _0xffffff8000ebc3b5: and r15b, 0xae + _0xffffff8000ebc3b9: add r15b, r12b + _0xffffff8000ebc3bc: add r15b, 0xfe + _0xffffff8000ebc3c0: movzx r15d, r15b + _0xffffff8000ebc3c4: cmp r15b, 0x55 + _0xffffff8000ebc3c8: sbb r12, r12 + _0xffffff8000ebc3cb: and r12d, 0x100 + _0xffffff8000ebc3d2: add r12d, r15d + _0xffffff8000ebc3d5: add r12d, 0x3e2fd43a + _0xffffff8000ebc3dc: shl r12, 0x20 + _0xffffff8000ebc3e0: movabs r15, 0xc1d02b7100000000 + _0xffffff8000ebc3ea: add r12, r15 + _0xffffff8000ebc3ed: mov r15, r12 + _0xffffff8000ebc3f0: sar r15, 0x1f + _0xffffff8000ebc3f4: movabs r13, 0x139d75f5ab7dbfa + _0xffffff8000ebc3fe: and r15, r13 + _0xffffff8000ebc401: sar r12, 0x20 + _0xffffff8000ebc405: movabs r13, 0x19cebafad5bedfd + _0xffffff8000ebc40f: xor r12, r13 + _0xffffff8000ebc412: add r12, r15 + _0xffffff8000ebc415: shl r12, 7 + _0xffffff8000ebc419: add r12, rax + _0xffffff8000ebc41c: mov eax, edx + _0xffffff8000ebc41e: shr eax, 1 + _0xffffff8000ebc420: and eax, 6 + _0xffffff8000ebc423: mov r15d, edx + _0xffffff8000ebc426: shr r15d, 2 + _0xffffff8000ebc42a: and r15d, 3 + _0xffffff8000ebc42e: add r15d, 0x23 + _0xffffff8000ebc432: sub r15d, eax + _0xffffff8000ebc435: xor r15d, 0x23 + _0xffffff8000ebc439: mov al, r15b + _0xffffff8000ebc43c: add al, al + _0xffffff8000ebc43e: xor r15b, 0x7f + _0xffffff8000ebc442: add r15b, al + _0xffffff8000ebc445: add r15b, 0x8f + _0xffffff8000ebc449: movzx eax, r15b + _0xffffff8000ebc44d: cmp al, 0xe + _0xffffff8000ebc44f: sbb r15, r15 + _0xffffff8000ebc452: and r15d, 0x100 + _0xffffff8000ebc459: add r15d, eax + _0xffffff8000ebc45c: add r15d, 0x25b99f28 + _0xffffff8000ebc463: shl r15, 0x20 + _0xffffff8000ebc467: movabs rax, 0xda4660ca00000000 + _0xffffff8000ebc471: add r15, rax + _0xffffff8000ebc474: mov rax, r15 + _0xffffff8000ebc477: sar rax, 0x1f + _0xffffff8000ebc47b: movabs r13, 0x6dfaf7bff6ffffe + _0xffffff8000ebc485: and rax, r13 + _0xffffff8000ebc488: sar r15, 0x20 + _0xffffff8000ebc48c: movabs r13, 0x76fd7bdffb7ffff + _0xffffff8000ebc496: xor r15, r13 + _0xffffff8000ebc499: add r15, rax + _0xffffff8000ebc49c: shl r15, 5 + _0xffffff8000ebc4a0: add r15, r12 + _0xffffff8000ebc4a3: lea eax, [rdx + rdx] + _0xffffff8000ebc4a6: and al, 2 + _0xffffff8000ebc4a8: lea r12d, [rdx + 2] + _0xffffff8000ebc4ac: and r12b, 3 + _0xffffff8000ebc4b0: xor r12b, 0x2f + _0xffffff8000ebc4b4: add r12b, al + _0xffffff8000ebc4b7: movzx eax, r12b + _0xffffff8000ebc4bb: cmp al, 0x2d + _0xffffff8000ebc4bd: sbb r12, r12 + _0xffffff8000ebc4c0: and r12d, 0x100 + _0xffffff8000ebc4c7: add r12d, eax + _0xffffff8000ebc4ca: add r12d, 0x1fabbb8e + _0xffffff8000ebc4d1: shl r12, 0x20 + _0xffffff8000ebc4d5: movabs rax, 0xe054444500000000 + _0xffffff8000ebc4df: add r12, rax + _0xffffff8000ebc4e2: mov rax, r12 + _0xffffff8000ebc4e5: sar rax, 0x1f + _0xffffff8000ebc4e9: movabs r13, 0x1aedffdefdffbfb8 + _0xffffff8000ebc4f3: and rax, r13 + _0xffffff8000ebc4f6: sar r12, 0x20 + _0xffffff8000ebc4fa: movabs r13, 0xd76ffef7effdfdc + _0xffffff8000ebc504: xor r12, r13 + _0xffffff8000ebc507: add r12, rax + _0xffffff8000ebc50a: lea rax, [r15 + r12*8] + _0xffffff8000ebc50e: movabs r15, 0xd7d730ed630a02d0 + _0xffffff8000ebc518: mov qword ptr [r15 + rax], r14 + _0xffffff8000ebc51c: inc edx + _0xffffff8000ebc51e: mov dword ptr [rbp - 0x1ec], edx + _0xffffff8000ebc524: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc52a: lea r14d, [rax + 0x1dc20e13] + _0xffffff8000ebc531: lea r15d, [rax + 0x3e6b9fa] + _0xffffff8000ebc538: lea r12d, [rax + 0x30389bff] + _0xffffff8000ebc53f: cmp edx, 0x40 + _0xffffff8000ebc542: mov rdx, rsi + _0xffffff8000ebc545: cmove rdx, rcx + _0xffffff8000ebc549: mov edx, dword ptr [rdx] + _0xffffff8000ebc54b: cmove r12d, r15d + _0xffffff8000ebc54f: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc556: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebc55d: mov dword ptr [r13], r12d + _0xffffff8000ebc561: cmovne r14d, eax + _0xffffff8000ebc565: mov dword ptr [r15], r14d + _0xffffff8000ebc568: mov dword ptr [rbp - 0x35c], edx + _0xffffff8000ebc56e: je _0xffffff8000ebbabc + _0xffffff8000ebc574: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc57a: cmp eax, 0x37857ad4 + _0xffffff8000ebc57f: jg _0xffffff8000ebb945 + _0xffffff8000ebc585: add eax, 0xdf9753a1 + _0xffffff8000ebc58a: cmp eax, 0x16 + _0xffffff8000ebc58d: ja _0xffffff8000ebbabc + _0xffffff8000ebc593: jmp _0xffffff8000ebb93c + _0xffffff8000ebc598: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc59e: lea edx, [rax - 4] + _0xffffff8000ebc5a1: lea r14d, [rax - 0x2c5d8ee5] + _0xffffff8000ebc5a8: lea r15d, [rax + 0x191bcdae] + _0xffffff8000ebc5af: lea eax, [rax + 0x191bcdb1] + _0xffffff8000ebc5b5: cmp dword ptr [rbp - 0x34], 5 + _0xffffff8000ebc5b9: jmp _0xffffff8000ebc976 + _0xffffff8000ebc5be: mov rax, qword ptr [rbp - 0x198] + _0xffffff8000ebc5c5: add rax, rax + _0xffffff8000ebc5c8: mov qword ptr [rbp - 0xf8], rax + _0xffffff8000ebc5cf: mov qword ptr [rbp - 0x108], 0 + _0xffffff8000ebc5da: mov al, byte ptr [rbp - 0x1a1] + _0xffffff8000ebc5e0: mov byte ptr [rbp - 0xf9], al + _0xffffff8000ebc5e6: mov byte ptr [rbp - 0xe1], al + _0xffffff8000ebc5ec: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc5f2: lea edx, [rax + 0x171cce47] + _0xffffff8000ebc5f8: lea r14d, [rax - 0x1b0d6282] + _0xffffff8000ebc5ff: lea r15d, [rax - 2] + _0xffffff8000ebc603: lea eax, [rax + 0x171cce57] + _0xffffff8000ebc609: mov r12b, byte ptr [rbp - 0x1d9] + _0xffffff8000ebc610: test r12b, r12b + _0xffffff8000ebc613: mov r12, rsi + _0xffffff8000ebc616: cmovne r12, rcx + _0xffffff8000ebc61a: mov r12d, dword ptr [r12] + _0xffffff8000ebc61e: cmovne eax, r15d + _0xffffff8000ebc622: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc629: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebc630: mov dword ptr [r13], eax + _0xffffff8000ebc634: cmove edx, r14d + _0xffffff8000ebc638: mov dword ptr [r15], edx + _0xffffff8000ebc63b: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebc642: je _0xffffff8000ebbd8a + _0xffffff8000ebc648: jmp _0xffffff8000ebbabc + _0xffffff8000ebc64d: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc653: lea edx, [rax - 0x1a70dc98] + _0xffffff8000ebc659: lea r14d, [rax + 0x2e97a6c8] + _0xffffff8000ebc660: lea r15d, [rax + 0x30389c29] + _0xffffff8000ebc667: lea eax, [rax + 0x30389c13] + _0xffffff8000ebc66d: cmp dword ptr [rbp - 0x34], 9 + _0xffffff8000ebc671: jmp _0xffffff8000ebd434 + _0xffffff8000ebc676: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc67c: lea edx, [rax + 0x6f5b8db] + _0xffffff8000ebc682: lea r14d, [rax - 0x2a7cdda] + _0xffffff8000ebc689: lea r15d, [rax - 0x191bcdaa] + _0xffffff8000ebc690: lea eax, [rax - 0x30389c10] + _0xffffff8000ebc696: cmp byte ptr [rbp - 0x2a], 3 + _0xffffff8000ebc69a: mov r12, rsi + _0xffffff8000ebc69d: cmovb r12, rcx + _0xffffff8000ebc6a1: mov r12d, dword ptr [r12] + _0xffffff8000ebc6a5: cmovb eax, r15d + _0xffffff8000ebc6a9: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc6b0: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebc6b7: mov dword ptr [r13], eax + _0xffffff8000ebc6bb: cmovae edx, r14d + _0xffffff8000ebc6bf: mov dword ptr [r15], edx + _0xffffff8000ebc6c2: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebc6c9: jae _0xffffff8000ebbd8a + _0xffffff8000ebc6cf: jmp _0xffffff8000ebc574 + _0xffffff8000ebc6d4: cmp byte ptr [rbp - 0x2b], 9 + _0xffffff8000ebc6d8: mov rax, rsi + _0xffffff8000ebc6db: cmovl rax, rcx + _0xffffff8000ebc6df: mov eax, dword ptr [rax] + _0xffffff8000ebc6e1: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebc6e7: lea r14d, [rdx + 0x171cce60] + _0xffffff8000ebc6ee: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc6f5: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebc6fc: mov dword ptr [r12], r14d + _0xffffff8000ebc700: add edx, 0x171cce61 + _0xffffff8000ebc706: mov dword ptr [r15], edx + _0xffffff8000ebc709: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebc70f: jmp _0xffffff8000ebbd8a + _0xffffff8000ebc714: mov eax, dword ptr [rbp - 0x1e0] + _0xffffff8000ebc71a: mov dword ptr [rbp - 0x1ec], 0xffff586d + _0xffffff8000ebc724: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebc72a: lea r14d, [rdx + 0x3eb612df] + _0xffffff8000ebc731: lea r15d, [rdx + 0x32b5230b] + _0xffffff8000ebc738: lea r12d, [rdx - 0x18e5d2cb] + _0xffffff8000ebc73f: lea edx, [rdx + 0x30389c16] + _0xffffff8000ebc745: cmp eax, 0x1d51bce8 + _0xffffff8000ebc74a: mov rax, rsi + _0xffffff8000ebc74d: cmove rax, rcx + _0xffffff8000ebc751: mov eax, dword ptr [rax] + _0xffffff8000ebc753: cmovne edx, r12d + _0xffffff8000ebc757: mov r12, qword ptr [rbp - 0x358] + _0xffffff8000ebc75e: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebc765: mov dword ptr [r13], edx + _0xffffff8000ebc769: cmovne r14d, r15d + _0xffffff8000ebc76d: mov dword ptr [r12], r14d + _0xffffff8000ebc771: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebc777: jne _0xffffff8000ebc574 + _0xffffff8000ebc77d: jmp _0xffffff8000ebbabc + _0xffffff8000ebc782: mov rax, qword ptr [rbp - 0x178] + _0xffffff8000ebc789: mov dword ptr [rax], 0x2fc2e0a1 + _0xffffff8000ebc78f: mov rax, qword ptr [rbp - 0x170] + _0xffffff8000ebc796: mov al, byte ptr [rax + 9] + _0xffffff8000ebc799: mov dword ptr [rbp - 0x1ec], 0 + _0xffffff8000ebc7a3: mov dword ptr [rbp - 0x1e0], 0xffff586f + _0xffffff8000ebc7ad: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebc7b3: lea r14d, [rdx - 0x30389c17] + _0xffffff8000ebc7ba: lea r15d, [rdx + 0x2d7b6b9c] + _0xffffff8000ebc7c1: cmp al, 1 + _0xffffff8000ebc7c3: cmove r15d, r14d + _0xffffff8000ebc7c7: lea r14d, [rdx + 0x2d84c33e] + _0xffffff8000ebc7ce: lea edx, [rdx - 0x18] + _0xffffff8000ebc7d1: cmp al, 1 + _0xffffff8000ebc7d3: mov rax, rsi + _0xffffff8000ebc7d6: cmove rax, rcx + _0xffffff8000ebc7da: mov eax, dword ptr [rax] + _0xffffff8000ebc7dc: cmove r14d, edx + _0xffffff8000ebc7e0: mov rdx, qword ptr [rbp - 0x358] + _0xffffff8000ebc7e7: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebc7ee: mov dword ptr [r12], r14d + _0xffffff8000ebc7f2: mov dword ptr [rdx], r15d + _0xffffff8000ebc7f5: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebc7fb: jmp _0xffffff8000ebc574 + _0xffffff8000ebc800: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc806: lea edx, [rax + 0x4ec8007] + _0xffffff8000ebc80c: lea r14d, [rax + 0x386cfc6a] + _0xffffff8000ebc813: lea r15d, [rax + 0x30389c19] + _0xffffff8000ebc81a: lea eax, [rax + 0x30389c15] + _0xffffff8000ebc820: cmp dword ptr [rbp - 0x34], 6 + _0xffffff8000ebc824: jmp _0xffffff8000ebc976 + _0xffffff8000ebc829: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc82f: lea edx, [rax - 0x191bcdc4] + _0xffffff8000ebc835: lea r14d, [rax - 1] + _0xffffff8000ebc839: lea r15d, [rax + 3] + _0xffffff8000ebc83d: lea eax, [rax - 0x30389c20] + _0xffffff8000ebc843: cmp dword ptr [rbp - 0x34], 5 + _0xffffff8000ebc847: mov r12, rsi + _0xffffff8000ebc84a: cmovl r12, rcx + _0xffffff8000ebc84e: mov r12d, dword ptr [r12] + _0xffffff8000ebc852: cmovl eax, r15d + _0xffffff8000ebc856: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc85d: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebc864: mov dword ptr [r13], eax + _0xffffff8000ebc868: cmovge edx, r14d + _0xffffff8000ebc86c: mov dword ptr [r15], edx + _0xffffff8000ebc86f: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebc876: jl _0xffffff8000ebbd8a + _0xffffff8000ebc87c: jmp _0xffffff8000ebc574 + _0xffffff8000ebc881: movzx eax, byte ptr [rbp - 0xc1] + _0xffffff8000ebc888: mov edx, eax + _0xffffff8000ebc88a: xor edx, 0x556f3659 + _0xffffff8000ebc890: lea eax, [rax + rax] + _0xffffff8000ebc893: add edx, 0xaa90c9a7 + _0xffffff8000ebc899: and eax, 0xb2 + _0xffffff8000ebc89e: neg eax + _0xffffff8000ebc8a0: cmp edx, eax + _0xffffff8000ebc8a2: movzx eax, byte ptr [rbp - 0xc2] + _0xffffff8000ebc8a9: sete byte ptr [rbp - 0x1d9] + _0xffffff8000ebc8b0: movzx edx, byte ptr [rbp - 0xc1] + _0xffffff8000ebc8b7: mov r14d, edx + _0xffffff8000ebc8ba: and r14d, 0x59 + _0xffffff8000ebc8be: xor edx, 0x556f3659 + _0xffffff8000ebc8c4: lea r15d, [rdx + r14*2 - 0x556f365a] + _0xffffff8000ebc8cc: inc r15 + _0xffffff8000ebc8cf: lea edx, [rdx + r14*2 - 0x556f3659] + _0xffffff8000ebc8d7: cmp edx, 1 + _0xffffff8000ebc8da: mov edx, 1 + _0xffffff8000ebc8df: cmovbe r15, rdx + _0xffffff8000ebc8e3: mov qword ptr [rbp - 0xa8], r15 + _0xffffff8000ebc8ea: mov r14d, eax + _0xffffff8000ebc8ed: and r14d, 0xdf + _0xffffff8000ebc8f4: xor eax, 0x3dba7edf + _0xffffff8000ebc8f9: lea r15d, [rax + r14*2 - 0x3dba7ee0] + _0xffffff8000ebc901: inc r15 + _0xffffff8000ebc904: lea eax, [rax + r14*2 - 0x3dba7edf] + _0xffffff8000ebc90c: cmp eax, 1 + _0xffffff8000ebc90f: cmovbe r15, rdx + _0xffffff8000ebc913: mov qword ptr [rbp - 0x1a0], r15 + _0xffffff8000ebc91a: mov qword ptr [rbp - 0xe0], 0 + _0xffffff8000ebc925: mov byte ptr [rbp - 0xd1], 0x3a + _0xffffff8000ebc92c: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc932: lea edx, [rax + 0x15] + _0xffffff8000ebc935: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebc93c: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc943: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebc94a: mov dword ptr [r12], edx + _0xffffff8000ebc94e: add eax, 0x171cce61 + _0xffffff8000ebc953: jmp _0xffffff8000ebb7ac + _0xffffff8000ebc958: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebc95e: lea edx, [rax + 0xd] + _0xffffff8000ebc961: lea r14d, [rax - 0x1c9b5ed8] + _0xffffff8000ebc968: lea r15d, [rax + 0x10] + _0xffffff8000ebc96c: lea eax, [rax + 0x191bcdbd] + _0xffffff8000ebc972: cmp dword ptr [rbp - 0x34], 4 + _0xffffff8000ebc976: mov r12, rsi + _0xffffff8000ebc979: cmove r12, rcx + _0xffffff8000ebc97d: mov r12d, dword ptr [r12] + _0xffffff8000ebc981: cmove eax, r15d + _0xffffff8000ebc985: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebc98c: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebc993: mov dword ptr [r13], eax + _0xffffff8000ebc997: cmovne edx, r14d + _0xffffff8000ebc99b: mov dword ptr [r15], edx + _0xffffff8000ebc99e: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebc9a5: jne _0xffffff8000ebbd8a + _0xffffff8000ebc9ab: jmp _0xffffff8000ebbabc + _0xffffff8000ebc9b0: mov rax, qword ptr [rbp - 0xc0] + _0xffffff8000ebc9b7: inc rax + _0xffffff8000ebc9ba: mov rdx, qword ptr [rbp - 0xa8] + _0xffffff8000ebc9c1: mov r14b, byte ptr [rbp - 0x1a3] + _0xffffff8000ebc9c8: mov qword ptr [rbp - 0x108], rax + _0xffffff8000ebc9cf: add r14b, 4 + _0xffffff8000ebc9d3: mov byte ptr [rbp - 0xf9], r14b + _0xffffff8000ebc9da: mov byte ptr [rbp - 0xe1], r14b + _0xffffff8000ebc9e1: mov r14d, dword ptr [rbp - 0x35c] + _0xffffff8000ebc9e8: lea r15d, [r14 - 0x191bcdbb] + _0xffffff8000ebc9ef: lea r12d, [r14 + 0x1eb0cb0c] + _0xffffff8000ebc9f6: lea r13d, [r14 - 0x191bcdab] + _0xffffff8000ebc9fd: lea r14d, [r14 - 0x30389c04] + _0xffffff8000ebca04: cmp rax, rdx + _0xffffff8000ebca07: mov rax, rsi + _0xffffff8000ebca0a: cmove rax, rcx + _0xffffff8000ebca0e: mov eax, dword ptr [rax] + _0xffffff8000ebca10: cmovne r14d, r13d + _0xffffff8000ebca14: mov rdx, qword ptr [rbp - 0x358] + _0xffffff8000ebca1b: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebca22: mov dword ptr [r13], r14d + _0xffffff8000ebca26: cmovne r15d, r12d + _0xffffff8000ebca2a: mov dword ptr [rdx], r15d + _0xffffff8000ebca2d: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebca33: jne _0xffffff8000ebbd8a + _0xffffff8000ebca39: jmp _0xffffff8000ebbabc + _0xffffff8000ebca3e: mov qword ptr [rbp - 0x188], rdi + _0xffffff8000ebca45: mov qword ptr [rbp - 0x180], rdi + _0xffffff8000ebca4c: mov rax, qword ptr [rdi + 8] + _0xffffff8000ebca50: mov qword ptr [rbp - 0x340], rax + _0xffffff8000ebca57: mov rax, qword ptr [rdi + 0x18] + _0xffffff8000ebca5b: mov qword ptr [rbp - 0x178], rax + _0xffffff8000ebca62: mov rax, qword ptr [rbp - 0x180] + _0xffffff8000ebca69: mov rax, qword ptr [rax + 0x20] + _0xffffff8000ebca6d: mov qword ptr [rbp - 0x170], rax + _0xffffff8000ebca74: cmp qword ptr [rbp - 0x178], 0 + _0xffffff8000ebca7c: sete dl + _0xffffff8000ebca7f: cmp qword ptr [rbp - 0x340], 0 + _0xffffff8000ebca87: sete r14b + _0xffffff8000ebca8b: or r14b, dl + _0xffffff8000ebca8e: test rax, rax + _0xffffff8000ebca91: sete al + _0xffffff8000ebca94: or al, r14b + _0xffffff8000ebca97: mov byte ptr [rbp - 0x1d9], al + _0xffffff8000ebca9d: lea rax, [rbp - 0x2b8] + _0xffffff8000ebcaa4: mov qword ptr [rbp - 0x330], rax + _0xffffff8000ebcaab: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ebcab2: mov qword ptr [rbp - 0x2b0], rax + _0xffffff8000ebcab9: mov rax, qword ptr [rbp - 0x330] + _0xffffff8000ebcac0: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ebcac7: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebcacb: mov qword ptr [rbp - 0x2a0], rdx + _0xffffff8000ebcad2: lea r14, [rbp - 0x2a8] + _0xffffff8000ebcad9: mov qword ptr [rdx], r14 + _0xffffff8000ebcadc: mov qword ptr [rax + 8], r14 + _0xffffff8000ebcae0: lea rax, [rbp - 0x298] + _0xffffff8000ebcae7: mov qword ptr [rbp - 0x168], rax + _0xffffff8000ebcaee: mov rax, qword ptr [rbp - 0x330] + _0xffffff8000ebcaf5: mov qword ptr [rbp - 0x160], rax + _0xffffff8000ebcafc: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ebcb03: mov rax, qword ptr [rbp - 0x160] + _0xffffff8000ebcb0a: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebcb0e: mov r14, qword ptr [rbp - 0x168] + _0xffffff8000ebcb15: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebcb19: mov r14, qword ptr [rbp - 0x168] + _0xffffff8000ebcb20: mov qword ptr [rdx], r14 + _0xffffff8000ebcb23: mov rdx, qword ptr [rbp - 0x168] + _0xffffff8000ebcb2a: mov qword ptr [rax + 8], rdx + _0xffffff8000ebcb2e: lea rax, [rbp - 0x288] + _0xffffff8000ebcb35: mov qword ptr [rbp - 0x338], rax + _0xffffff8000ebcb3c: mov qword ptr [rbp - 0x288], rax + _0xffffff8000ebcb43: mov qword ptr [rbp - 0x280], rax + _0xffffff8000ebcb4a: lea rax, [rbp - 0x278] + _0xffffff8000ebcb51: mov qword ptr [rbp - 0x2c0], rax + _0xffffff8000ebcb58: mov qword ptr [rbp - 0x278], rax + _0xffffff8000ebcb5f: mov qword ptr [rbp - 0x270], rax + _0xffffff8000ebcb66: lea rax, [rbp - 0x268] + _0xffffff8000ebcb6d: mov qword ptr [rbp - 0x1d8], rax + _0xffffff8000ebcb74: mov qword ptr [rbp - 0x158], rax + _0xffffff8000ebcb7b: lea rax, [rbp - 0x338] + _0xffffff8000ebcb82: mov qword ptr [rbp - 0x150], rax + _0xffffff8000ebcb89: mov rdx, qword ptr [rbp - 0x338] + _0xffffff8000ebcb90: mov qword ptr [rbp - 0x268], rdx + _0xffffff8000ebcb97: mov r14, qword ptr [rdx + 8] + _0xffffff8000ebcb9b: mov r15, qword ptr [rbp - 0x1d8] + _0xffffff8000ebcba2: mov qword ptr [r15 + 8], r14 + _0xffffff8000ebcba6: mov r15, qword ptr [rbp - 0x1d8] + _0xffffff8000ebcbad: mov qword ptr [r14], r15 + _0xffffff8000ebcbb0: mov r14, qword ptr [rbp - 0x1d8] + _0xffffff8000ebcbb7: mov qword ptr [rdx + 8], r14 + _0xffffff8000ebcbbb: mov rdx, qword ptr [rbp - 0x338] + _0xffffff8000ebcbc2: mov qword ptr [rbp - 0x258], rdx + _0xffffff8000ebcbc9: mov r14, qword ptr [rdx + 8] + _0xffffff8000ebcbcd: mov qword ptr [rbp - 0x250], r14 + _0xffffff8000ebcbd4: lea r15, [rbp - 0x258] + _0xffffff8000ebcbdb: mov qword ptr [r14], r15 + _0xffffff8000ebcbde: mov qword ptr [rdx + 8], r15 + _0xffffff8000ebcbe2: lea rdx, [rbp - 0x248] + _0xffffff8000ebcbe9: mov qword ptr [rbp - 0x1c0], rdx + _0xffffff8000ebcbf0: mov qword ptr [rbp - 0x148], rdx + _0xffffff8000ebcbf7: mov qword ptr [rbp - 0x140], rax + _0xffffff8000ebcbfe: mov rdx, qword ptr [rbp - 0x338] + _0xffffff8000ebcc05: mov qword ptr [rbp - 0x248], rdx + _0xffffff8000ebcc0c: mov r14, qword ptr [rdx + 8] + _0xffffff8000ebcc10: mov r15, qword ptr [rbp - 0x1c0] + _0xffffff8000ebcc17: mov qword ptr [r15 + 8], r14 + _0xffffff8000ebcc1b: mov r15, qword ptr [rbp - 0x1c0] + _0xffffff8000ebcc22: mov qword ptr [r14], r15 + _0xffffff8000ebcc25: mov r14, qword ptr [rbp - 0x1c0] + _0xffffff8000ebcc2c: mov qword ptr [rdx + 8], r14 + _0xffffff8000ebcc30: mov rdx, qword ptr [rbp - 0x338] + _0xffffff8000ebcc37: mov qword ptr [rbp - 0x238], rdx + _0xffffff8000ebcc3e: mov r14, qword ptr [rdx + 8] + _0xffffff8000ebcc42: mov qword ptr [rbp - 0x230], r14 + _0xffffff8000ebcc49: lea r15, [rbp - 0x238] + _0xffffff8000ebcc50: mov qword ptr [r14], r15 + _0xffffff8000ebcc53: mov qword ptr [rdx + 8], r15 + _0xffffff8000ebcc57: lea rdx, [rbp - 0x228] + _0xffffff8000ebcc5e: mov qword ptr [rbp - 0x1b8], rdx + _0xffffff8000ebcc65: mov qword ptr [rbp - 0x138], rdx + _0xffffff8000ebcc6c: mov qword ptr [rbp - 0x190], rax + _0xffffff8000ebcc73: mov rdx, qword ptr [rbp - 0x338] + _0xffffff8000ebcc7a: mov qword ptr [rbp - 0x228], rdx + _0xffffff8000ebcc81: mov r14, qword ptr [rdx + 8] + _0xffffff8000ebcc85: mov r15, qword ptr [rbp - 0x1b8] + _0xffffff8000ebcc8c: mov qword ptr [r15 + 8], r14 + _0xffffff8000ebcc90: mov r15, qword ptr [rbp - 0x1b8] + _0xffffff8000ebcc97: mov qword ptr [r14], r15 + _0xffffff8000ebcc9a: mov r14, qword ptr [rbp - 0x1b8] + _0xffffff8000ebcca1: mov qword ptr [rdx + 8], r14 + _0xffffff8000ebcca5: mov rdx, qword ptr [rbp - 0x338] + _0xffffff8000ebccac: mov qword ptr [rbp - 0x218], rdx + _0xffffff8000ebccb3: mov r14, qword ptr [rdx + 8] + _0xffffff8000ebccb7: mov qword ptr [rbp - 0x210], r14 + _0xffffff8000ebccbe: lea r15, [rbp - 0x218] + _0xffffff8000ebccc5: mov qword ptr [r14], r15 + _0xffffff8000ebccc8: mov qword ptr [rdx + 8], r15 + _0xffffff8000ebcccc: lea rdx, [rbp - 0x208] + _0xffffff8000ebccd3: mov qword ptr [rbp - 0x1b0], rdx + _0xffffff8000ebccda: mov qword ptr [rbp - 0x1d0], rdx + _0xffffff8000ebcce1: mov qword ptr [rbp - 0x1c8], rax + _0xffffff8000ebcce8: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebccef: mov qword ptr [rbp - 0x208], rax + _0xffffff8000ebccf6: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebccfa: mov r14, qword ptr [rbp - 0x1b0] + _0xffffff8000ebcd01: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebcd05: mov r14, qword ptr [rbp - 0x1b0] + _0xffffff8000ebcd0c: mov qword ptr [rdx], r14 + _0xffffff8000ebcd0f: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebcd16: mov qword ptr [rax + 8], rdx + _0xffffff8000ebcd1a: mov dword ptr [rbp - 0x1e0], 0xffff586c + _0xffffff8000ebcd24: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebcd2a: lea edx, [rax - 0x1545f05f] + _0xffffff8000ebcd30: lea r14d, [rax + 0x1212f64e] + _0xffffff8000ebcd37: lea r15d, [rax + 0x22c7abca] + _0xffffff8000ebcd3e: lea eax, [rax - 4] + _0xffffff8000ebcd41: mov r12b, byte ptr [rbp - 0x1d9] + _0xffffff8000ebcd48: jmp _0xffffff8000ebe358 + _0xffffff8000ebcd4d: mov qword ptr [rbp - 0x1f8], rdi + _0xffffff8000ebcd54: mov qword ptr [rbp - 0x128], rdi + _0xffffff8000ebcd5b: mov eax, dword ptr [rdi + 8] + _0xffffff8000ebcd5e: mov dword ptr [rbp - 0x1e0], eax + _0xffffff8000ebcd64: mov rax, qword ptr [rdi + 0x10] + _0xffffff8000ebcd68: mov qword ptr [rbp - 0x340], rax + _0xffffff8000ebcd6f: test rax, rax + _0xffffff8000ebcd72: sete byte ptr [rbp - 0x1d9] + _0xffffff8000ebcd79: lea rax, [rbp - 0x2b8] + _0xffffff8000ebcd80: mov qword ptr [rbp - 0x2c0], rax + _0xffffff8000ebcd87: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ebcd8e: mov qword ptr [rbp - 0x2b0], rax + _0xffffff8000ebcd95: lea rax, [rbp - 0x2a8] + _0xffffff8000ebcd9c: mov qword ptr [rbp - 0x310], rax + _0xffffff8000ebcda3: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ebcdaa: mov qword ptr [rbp - 0x2a0], rax + _0xffffff8000ebcdb1: lea rax, [rbp - 0x298] + _0xffffff8000ebcdb8: mov qword ptr [rbp - 0x320], rax + _0xffffff8000ebcdbf: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ebcdc6: mov qword ptr [rbp - 0x290], rax + _0xffffff8000ebcdcd: lea rax, [rbp - 0x288] + _0xffffff8000ebcdd4: mov qword ptr [rbp - 0x1d8], rax + _0xffffff8000ebcddb: mov qword ptr [rbp - 0x330], rax + _0xffffff8000ebcde2: mov rax, qword ptr [rbp - 0x1d8] + _0xffffff8000ebcde9: mov qword ptr [rax], rax + _0xffffff8000ebcdec: mov rdx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebcdf3: mov qword ptr [rax + 8], rdx + _0xffffff8000ebcdf7: lea rax, [rbp - 0x278] + _0xffffff8000ebcdfe: mov qword ptr [rbp - 0x328], rax + _0xffffff8000ebce05: mov qword ptr [rbp - 0x278], rax + _0xffffff8000ebce0c: mov qword ptr [rbp - 0x270], rax + _0xffffff8000ebce13: mov rax, qword ptr [rbp - 0x320] + _0xffffff8000ebce1a: mov qword ptr [rbp - 0x268], rax + _0xffffff8000ebce21: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebce25: mov qword ptr [rbp - 0x260], rdx + _0xffffff8000ebce2c: lea r14, [rbp - 0x268] + _0xffffff8000ebce33: mov qword ptr [rdx], r14 + _0xffffff8000ebce36: mov qword ptr [rax + 8], r14 + _0xffffff8000ebce3a: lea rax, [rbp - 0x258] + _0xffffff8000ebce41: mov qword ptr [rbp - 0x1c0], rax + _0xffffff8000ebce48: mov rax, qword ptr [rbp - 0x320] + _0xffffff8000ebce4f: mov qword ptr [rbp - 0x258], rax + _0xffffff8000ebce56: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebce5a: mov r14, qword ptr [rbp - 0x1c0] + _0xffffff8000ebce61: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebce65: mov r14, qword ptr [rbp - 0x1c0] + _0xffffff8000ebce6c: mov qword ptr [rdx], r14 + _0xffffff8000ebce6f: mov rdx, qword ptr [rbp - 0x1c0] + _0xffffff8000ebce76: mov qword ptr [rax + 8], rdx + _0xffffff8000ebce7a: mov rax, qword ptr [rbp - 0x320] + _0xffffff8000ebce81: mov qword ptr [rbp - 0x248], rax + _0xffffff8000ebce88: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebce8c: mov qword ptr [rbp - 0x240], rdx + _0xffffff8000ebce93: lea r14, [rbp - 0x248] + _0xffffff8000ebce9a: mov qword ptr [rdx], r14 + _0xffffff8000ebce9d: mov qword ptr [rax + 8], r14 + _0xffffff8000ebcea1: lea rax, [rbp - 0x238] + _0xffffff8000ebcea8: mov qword ptr [rbp - 0x1b8], rax + _0xffffff8000ebceaf: mov rax, qword ptr [rbp - 0x330] + _0xffffff8000ebceb6: mov qword ptr [rbp - 0x238], rax + _0xffffff8000ebcebd: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebcec1: mov r14, qword ptr [rbp - 0x1b8] + _0xffffff8000ebcec8: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebcecc: mov r14, qword ptr [rbp - 0x1b8] + _0xffffff8000ebced3: mov qword ptr [rdx], r14 + _0xffffff8000ebced6: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebcedd: mov qword ptr [rax + 8], rdx + _0xffffff8000ebcee1: mov rax, qword ptr [rbp - 0x320] + _0xffffff8000ebcee8: mov qword ptr [rbp - 0x228], rax + _0xffffff8000ebceef: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebcef3: mov qword ptr [rbp - 0x220], rdx + _0xffffff8000ebcefa: lea r14, [rbp - 0x228] + _0xffffff8000ebcf01: mov qword ptr [rdx], r14 + _0xffffff8000ebcf04: mov qword ptr [rax + 8], r14 + _0xffffff8000ebcf08: lea rax, [rbp - 0x218] + _0xffffff8000ebcf0f: mov qword ptr [rbp - 0x1b0], rax + _0xffffff8000ebcf16: mov rax, qword ptr [rbp - 0x328] + _0xffffff8000ebcf1d: mov qword ptr [rbp - 0x218], rax + _0xffffff8000ebcf24: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebcf28: mov r14, qword ptr [rbp - 0x1b0] + _0xffffff8000ebcf2f: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebcf33: mov r14, qword ptr [rbp - 0x1b0] + _0xffffff8000ebcf3a: mov qword ptr [rdx], r14 + _0xffffff8000ebcf3d: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebcf44: mov qword ptr [rax + 8], rdx + _0xffffff8000ebcf48: mov rax, qword ptr [rbp - 0x328] + _0xffffff8000ebcf4f: mov qword ptr [rbp - 0x208], rax + _0xffffff8000ebcf56: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebcf5a: mov qword ptr [rbp - 0x200], rdx + _0xffffff8000ebcf61: lea r14, [rbp - 0x208] + _0xffffff8000ebcf68: mov qword ptr [rdx], r14 + _0xffffff8000ebcf6b: mov qword ptr [rax + 8], r14 + _0xffffff8000ebcf6f: mov dword ptr [rbp - 0x1ec], 0xffff586c + _0xffffff8000ebcf79: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebcf7f: lea edx, [rax + 0x21ba460] + _0xffffff8000ebcf85: lea r14d, [rax - 0x84af352] + _0xffffff8000ebcf8c: lea r15d, [rax - 0x504483e4] + _0xffffff8000ebcf93: lea eax, [rax - 0x30389c12] + _0xffffff8000ebcf99: jmp _0xffffff8000ebc609 + _0xffffff8000ebcf9e: mov rax, qword ptr [rbp - 0x1a0] + _0xffffff8000ebcfa5: mov rdx, qword ptr [rbp - 0x198] + _0xffffff8000ebcfac: mov dword ptr [rbp - 0x12c], 0 + _0xffffff8000ebcfb6: inc rdx + _0xffffff8000ebcfb9: mov qword ptr [rbp - 0xe0], rdx + _0xffffff8000ebcfc0: mov r14b, byte ptr [rbp - 0x1a2] + _0xffffff8000ebcfc7: mov byte ptr [rbp - 0xd1], r14b + _0xffffff8000ebcfce: mov r14d, dword ptr [rbp - 0x35c] + _0xffffff8000ebcfd5: lea r15d, [r14 + 0x2883e208] + _0xffffff8000ebcfdc: lea r12d, [r14 + 0x171cce4e] + _0xffffff8000ebcfe3: cmp rdx, rax + _0xffffff8000ebcfe6: cmove r12d, r15d + _0xffffff8000ebcfea: lea r15d, [r14 + 2] + _0xffffff8000ebcfee: lea r14d, [r14 + 0x51048958] + _0xffffff8000ebcff5: cmp rdx, rax + _0xffffff8000ebcff8: mov rax, rsi + _0xffffff8000ebcffb: cmove rax, rcx + _0xffffff8000ebcfff: mov eax, dword ptr [rax] + _0xffffff8000ebd001: cmove r15d, r14d + _0xffffff8000ebd005: mov rdx, qword ptr [rbp - 0x358] + _0xffffff8000ebd00c: mov r14, qword ptr [rbp - 0x350] + _0xffffff8000ebd013: mov dword ptr [r14], r15d + _0xffffff8000ebd016: mov dword ptr [rdx], r12d + _0xffffff8000ebd019: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebd01f: jmp _0xffffff8000ebbd8a + _0xffffff8000ebd024: mov r14, qword ptr [rbp - 0x170] + _0xffffff8000ebd02b: movzx eax, byte ptr [r14 + 1] + _0xffffff8000ebd030: cmp eax, 0x68 + _0xffffff8000ebd033: sbb edx, edx + _0xffffff8000ebd035: and edx, 0x100 + _0xffffff8000ebd03b: add edx, eax + _0xffffff8000ebd03d: add edx, -0x68 + _0xffffff8000ebd040: mov r15, qword ptr [rbp - 0xf0] + _0xffffff8000ebd047: mov rax, qword ptr [rbp - 0xb0] + _0xffffff8000ebd04e: add rax, r15 + _0xffffff8000ebd051: movzx r12d, al + _0xffffff8000ebd055: cmp al, 0x3a + _0xffffff8000ebd057: sbb eax, eax + _0xffffff8000ebd059: and eax, 0x100 + _0xffffff8000ebd05e: add eax, r12d + _0xffffff8000ebd061: add eax, -0x3a + _0xffffff8000ebd064: imul eax, edx + _0xffffff8000ebd067: mov edx, dword ptr [rbp - 0x1ec] + _0xffffff8000ebd06d: mov r12d, dword ptr [rbp - 0x1e0] + _0xffffff8000ebd074: lea r12d, [r12 + rdx - 0x7dbff7bd] + _0xffffff8000ebd07c: xor edx, edx + _0xffffff8000ebd07e: div r12d + _0xffffff8000ebd081: mov eax, edx + _0xffffff8000ebd083: and eax, 0x6fffffec + _0xffffff8000ebd088: xor edx, 0x6fffffec + _0xffffff8000ebd08e: lea eax, [rdx + rax*2 - 0x25101024] + _0xffffff8000ebd095: cmp eax, 0x4aefefc8 + _0xffffff8000ebd09a: sbb rdx, rdx + _0xffffff8000ebd09d: and edx, 1 + _0xffffff8000ebd0a0: shl rdx, 0x20 + _0xffffff8000ebd0a4: add rdx, rax + _0xffffff8000ebd0a7: movabs rax, 0x2a10a6d3cd3a69 + _0xffffff8000ebd0b1: add rax, rdx + _0xffffff8000ebd0b4: mov rdx, rax + _0xffffff8000ebd0b7: shl rdx, 0xa + _0xffffff8000ebd0bb: add edx, 0x2f830ab3 + _0xffffff8000ebd0c1: shl rax, 0xb + _0xffffff8000ebd0c5: add eax, 0x16ae7800 + _0xffffff8000ebd0ca: and eax, 0x48579800 + _0xffffff8000ebd0cf: sub edx, eax + _0xffffff8000ebd0d1: mov eax, edx + _0xffffff8000ebd0d3: xor eax, 0x7dd0707e + _0xffffff8000ebd0d8: xor edx, 0x2b8e81 + _0xffffff8000ebd0de: and edx, 0x59fbbecd + _0xffffff8000ebd0e4: lea eax, [rax + rdx*2 - 0xca0000] + _0xffffff8000ebd0eb: cmp eax, 0x5931becd + _0xffffff8000ebd0f0: sbb rdx, rdx + _0xffffff8000ebd0f3: and edx, 1 + _0xffffff8000ebd0f6: shl rdx, 0x20 + _0xffffff8000ebd0fa: add rdx, rax + _0xffffff8000ebd0fd: mov rax, qword ptr [rbp - 0x1f8] + _0xffffff8000ebd104: lea rax, [rdx + rax - 0x5931becd] + _0xffffff8000ebd10c: movabs rdx, 0x15f7f5bfc + _0xffffff8000ebd116: and rdx, qword ptr [rbp - 0xf8] + _0xffffff8000ebd11d: movabs r12, 0x1ff7f7dafbfadfe + _0xffffff8000ebd127: xor r12, qword ptr [rbp - 0x198] + _0xffffff8000ebd12e: add r12, rdx + _0xffffff8000ebd131: shl r12, 7 + _0xffffff8000ebd135: add r12, r14 + _0xffffff8000ebd138: mov edx, 0xd5f7f5fe + _0xffffff8000ebd13d: and rdx, qword ptr [rbp - 0xb8] + _0xffffff8000ebd144: movabs r14, 0x6faeb456afbfaff + _0xffffff8000ebd14e: xor r14, qword ptr [rbp - 0xc0] + _0xffffff8000ebd155: add r14, rdx + _0xffffff8000ebd158: shl r14, 5 + _0xffffff8000ebd15c: add r14, r12 + _0xffffff8000ebd15f: movabs rdx, 0x1ffe13f7fedffa7c + _0xffffff8000ebd169: xor rdx, r15 + _0xffffff8000ebd16c: mov r12d, r15d + _0xffffff8000ebd16f: and r12d, 0xfedffa7c + _0xffffff8000ebd176: lea rdx, [rdx + r12*2] + _0xffffff8000ebd17a: lea rdx, [r14 + rdx*8] + _0xffffff8000ebd17e: movabs r14, 0x20f238bac9a9cf50 + _0xffffff8000ebd188: mov qword ptr [r14 + rdx], rax + _0xffffff8000ebd18c: mov rax, qword ptr [rbp - 0x178] + _0xffffff8000ebd193: add dword ptr [rax], 0x400 + _0xffffff8000ebd199: inc r15 + _0xffffff8000ebd19c: mov qword ptr [rbp - 0xf0], r15 + _0xffffff8000ebd1a3: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebd1a9: lea edx, [rax - 0xb] + _0xffffff8000ebd1ac: lea r14d, [rax + 0x191bcdb4] + _0xffffff8000ebd1b3: lea r12d, [rax + 0x191bcdab] + _0xffffff8000ebd1ba: cmp r15, 4 + _0xffffff8000ebd1be: mov r15, rsi + _0xffffff8000ebd1c1: cmove r15, rcx + _0xffffff8000ebd1c5: mov r15d, dword ptr [r15] + _0xffffff8000ebd1c8: cmove r12d, r14d + _0xffffff8000ebd1cc: mov r14, qword ptr [rbp - 0x358] + _0xffffff8000ebd1d3: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebd1da: mov dword ptr [r13], r12d + _0xffffff8000ebd1de: cmovne edx, eax + _0xffffff8000ebd1e1: mov dword ptr [r14], edx + _0xffffff8000ebd1e4: mov dword ptr [rbp - 0x35c], r15d + _0xffffff8000ebd1eb: jne _0xffffff8000ebbd8a + _0xffffff8000ebd1f1: jmp _0xffffff8000ebbabc + _0xffffff8000ebd1f6: mov dword ptr [rbp - 0x1ec], 0xffff586f + _0xffffff8000ebd200: mov al, byte ptr [rbp - 0x1a3] + _0xffffff8000ebd206: mov byte ptr [rbp - 0x29], al + _0xffffff8000ebd209: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebd20f: lea edx, [rax + 0x30389c1c] + _0xffffff8000ebd215: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebd21c: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebd223: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebd22a: mov dword ptr [r12], edx + _0xffffff8000ebd22e: add eax, 0x171cce54 + _0xffffff8000ebd233: jmp _0xffffff8000ebb7ac + _0xffffff8000ebd238: mov rax, qword ptr [rbp - 0x198] + _0xffffff8000ebd23f: movabs rdx, 0x3f6f7bdaf5b5fda + _0xffffff8000ebd249: xor rdx, rax + _0xffffff8000ebd24c: mov r14d, eax + _0xffffff8000ebd24f: and r14d, 0xaf5b5fda + _0xffffff8000ebd256: lea rdx, [rdx + r14*2] + _0xffffff8000ebd25a: movabs r14, 0x9c09084250a4a026 + _0xffffff8000ebd264: add r14, rdx + _0xffffff8000ebd267: shl r14, 5 + _0xffffff8000ebd26b: mov rdx, qword ptr [rbp - 0x170] + _0xffffff8000ebd272: mov r15, qword ptr [rbp - 0x1f8] + _0xffffff8000ebd279: mov qword ptr [rdx + r14 + 0x410], r15 + _0xffffff8000ebd281: mov rdx, qword ptr [rbp - 0x178] + _0xffffff8000ebd288: add dword ptr [rdx], 0x100 + _0xffffff8000ebd28e: lea rdx, [r15 + 0x100] + _0xffffff8000ebd295: mov r12, qword ptr [rbp - 0x170] + _0xffffff8000ebd29c: mov qword ptr [r12 + r14 + 0x418], rdx + _0xffffff8000ebd2a4: mov rdx, qword ptr [rbp - 0x178] + _0xffffff8000ebd2ab: add dword ptr [rdx], 0x100 + _0xffffff8000ebd2b1: lea rdx, [r15 + 0x200] + _0xffffff8000ebd2b8: mov r12, qword ptr [rbp - 0x170] + _0xffffff8000ebd2bf: mov qword ptr [r12 + r14 + 0x420], rdx + _0xffffff8000ebd2c7: mov rdx, qword ptr [rbp - 0x178] + _0xffffff8000ebd2ce: add dword ptr [rdx], 0x100 + _0xffffff8000ebd2d4: lea rdx, [r15 + 0x300] + _0xffffff8000ebd2db: mov r12, qword ptr [rbp - 0x170] + _0xffffff8000ebd2e2: mov qword ptr [r12 + r14 + 0x428], rdx + _0xffffff8000ebd2ea: mov rdx, qword ptr [rbp - 0x178] + _0xffffff8000ebd2f1: add dword ptr [rdx], 0x100 + _0xffffff8000ebd2f7: inc rax + _0xffffff8000ebd2fa: mov qword ptr [rbp - 0x198], rax + _0xffffff8000ebd301: add r15, 0x400 + _0xffffff8000ebd308: mov qword ptr [rbp - 0x1f8], r15 + _0xffffff8000ebd30f: mov dword ptr [rbp - 0x1e0], 0 + _0xffffff8000ebd319: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebd31f: lea r14d, [rdx + 0x291f716a] + _0xffffff8000ebd326: lea r15d, [rdx + 0x5835bed] + _0xffffff8000ebd32d: lea r12d, [rdx - 0x191bcdaf] + _0xffffff8000ebd334: cmp rax, 4 + _0xffffff8000ebd338: mov rax, rsi + _0xffffff8000ebd33b: cmove rax, rcx + _0xffffff8000ebd33f: mov eax, dword ptr [rax] + _0xffffff8000ebd341: cmove r12d, r15d + _0xffffff8000ebd345: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebd34c: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebd353: mov dword ptr [r13], r12d + _0xffffff8000ebd357: cmovne r14d, edx + _0xffffff8000ebd35b: mov dword ptr [r15], r14d + _0xffffff8000ebd35e: mov dword ptr [rbp - 0x35c], eax + _0xffffff8000ebd364: je _0xffffff8000ebbd8a + _0xffffff8000ebd36a: jmp _0xffffff8000ebbabc + _0xffffff8000ebd36f: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebd375: lea edx, [rax - 0x171cce6f] + _0xffffff8000ebd37b: lea eax, [rax - 5] + _0xffffff8000ebd37e: cmp byte ptr [rbp - 0x29], 4 + _0xffffff8000ebd382: mov r14, rsi + _0xffffff8000ebd385: cmovl r14, rcx + _0xffffff8000ebd389: mov r14d, dword ptr [r14] + _0xffffff8000ebd38c: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebd393: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebd39a: mov dword ptr [r12], eax + _0xffffff8000ebd39e: mov dword ptr [r15], edx + _0xffffff8000ebd3a1: mov dword ptr [rbp - 0x35c], r14d + _0xffffff8000ebd3a8: jge _0xffffff8000ebbd8a + _0xffffff8000ebd3ae: jmp _0xffffff8000ebbabc + _0xffffff8000ebd3b3: mov rax, qword ptr [rbp - 0xe0] + _0xffffff8000ebd3ba: mov qword ptr [rbp - 0x198], rax + _0xffffff8000ebd3c1: mov al, byte ptr [rbp - 0xd1] + _0xffffff8000ebd3c7: mov byte ptr [rbp - 0x1a1], al + _0xffffff8000ebd3cd: mov rax, qword ptr [rbp - 0x2c0] + _0xffffff8000ebd3d4: mov qword ptr [rax], rax + _0xffffff8000ebd3d7: mov qword ptr [rax + 8], rax + _0xffffff8000ebd3db: mov qword ptr [rbp - 0x328], rax + _0xffffff8000ebd3e2: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebd3e8: lea edx, [rax - 8] + _0xffffff8000ebd3eb: lea r14d, [rax + 0x76e5dda] + _0xffffff8000ebd3f2: lea r15d, [rax + 0x191bcdb7] + _0xffffff8000ebd3f9: lea eax, [rax + 3] + _0xffffff8000ebd3fc: mov r12, qword ptr [rbp - 0x338] + _0xffffff8000ebd403: mov r13, qword ptr [r12] + _0xffffff8000ebd407: cmp r12, qword ptr [r13 + 8] + _0xffffff8000ebd40b: jmp _0xffffff8000ebe44a + _0xffffff8000ebd410: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebd416: lea edx, [rax + 0x2e44433] + _0xffffff8000ebd41c: lea r14d, [rax + 0xbba3fdf] + _0xffffff8000ebd423: lea r15d, [rax + 0x30389c1a] + _0xffffff8000ebd42a: lea eax, [rax + 0x30389c16] + _0xffffff8000ebd430: cmp dword ptr [rbp - 0x34], 7 + _0xffffff8000ebd434: mov r12, rsi + _0xffffff8000ebd437: cmove r12, rcx + _0xffffff8000ebd43b: mov r12d, dword ptr [r12] + _0xffffff8000ebd43f: cmove eax, r15d + _0xffffff8000ebd443: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebd44a: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebd451: mov dword ptr [r13], eax + _0xffffff8000ebd455: cmovne edx, r14d + _0xffffff8000ebd459: mov dword ptr [r15], edx + _0xffffff8000ebd45c: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebd463: jmp _0xffffff8000ebbd8a + _0xffffff8000ebd468: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebd46e: lea edx, [rax - 0x191bcdcb] + _0xffffff8000ebd474: lea r14d, [rax - 0x30389c23] + _0xffffff8000ebd47b: lea r15d, [rax - 0x191bcdbb] + _0xffffff8000ebd482: lea eax, [rax - 0x30389c18] + _0xffffff8000ebd488: cmp dword ptr [rbp - 0x34], 8 + _0xffffff8000ebd48c: mov r12, rsi + _0xffffff8000ebd48f: cmovl r12, rcx + _0xffffff8000ebd493: mov r12d, dword ptr [r12] + _0xffffff8000ebd497: cmovl eax, r15d + _0xffffff8000ebd49b: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebd4a2: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebd4a9: mov dword ptr [r13], eax + _0xffffff8000ebd4ad: cmovge edx, r14d + _0xffffff8000ebd4b1: mov dword ptr [r15], edx + _0xffffff8000ebd4b4: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebd4bb: jl _0xffffff8000ebc574 + _0xffffff8000ebd4c1: jmp _0xffffff8000ebbabc + _0xffffff8000ebd4c6: mov dword ptr [rbp - 0x1e0], 0xffff586c + _0xffffff8000ebd4d0: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebd4d6: lea edx, [rax + 0x3a33e243] + _0xffffff8000ebd4dc: lea r14d, [rax - 5] + _0xffffff8000ebd4e0: lea r15d, [rax + 0x5160a03d] + _0xffffff8000ebd4e7: lea eax, [rax + 8] + _0xffffff8000ebd4ea: mov r12b, byte ptr [rbp - 0x1d9] + _0xffffff8000ebd4f1: test r12b, r12b + _0xffffff8000ebd4f4: mov r12, rsi + _0xffffff8000ebd4f7: cmovne r12, rcx + _0xffffff8000ebd4fb: mov r12d, dword ptr [r12] + _0xffffff8000ebd4ff: cmovne eax, r15d + _0xffffff8000ebd503: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebd50a: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebd511: mov dword ptr [r13], eax + _0xffffff8000ebd515: cmove edx, r14d + _0xffffff8000ebd519: mov dword ptr [r15], edx + _0xffffff8000ebd51c: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebd523: jne _0xffffff8000ebc574 + _0xffffff8000ebd529: jmp _0xffffff8000ebbabc + _0xffffff8000ebd52e: mov rax, qword ptr [rbp - 0x1a0] + _0xffffff8000ebd535: mov rdx, qword ptr [rbp - 0x340] + _0xffffff8000ebd53c: mov r14b, byte ptr [rdx + rax*4] + _0xffffff8000ebd540: mov r15b, byte ptr [rdx + rax*4 + 3] + _0xffffff8000ebd545: mov byte ptr [rdx + rax*4], r15b + _0xffffff8000ebd549: mov r15b, r14b + _0xffffff8000ebd54c: add r15b, r15b + _0xffffff8000ebd54f: xor r14b, 0x6c + _0xffffff8000ebd553: and r15b, 0xd8 + _0xffffff8000ebd557: add r15b, r14b + _0xffffff8000ebd55a: add r15b, 0x94 + _0xffffff8000ebd55e: mov byte ptr [rdx + rax*4 + 3], r15b + _0xffffff8000ebd563: mov r14b, byte ptr [rdx + rax*4 + 1] + _0xffffff8000ebd568: mov r15b, byte ptr [rdx + rax*4 + 2] + _0xffffff8000ebd56d: mov byte ptr [rdx + rax*4 + 1], r15b + _0xffffff8000ebd572: mov r15b, r14b + _0xffffff8000ebd575: add r15b, r15b + _0xffffff8000ebd578: xor r14b, 0x6c + _0xffffff8000ebd57c: and r15b, 0xd8 + _0xffffff8000ebd580: add r15b, r14b + _0xffffff8000ebd583: add r15b, 0x94 + _0xffffff8000ebd587: mov byte ptr [rdx + rax*4 + 2], r15b + _0xffffff8000ebd58c: inc rax + _0xffffff8000ebd58f: mov rdx, qword ptr [rbp - 0x198] + _0xffffff8000ebd596: mov qword ptr [rbp - 0x1a0], rax + _0xffffff8000ebd59d: mov r14d, dword ptr [rbp - 0x35c] + _0xffffff8000ebd5a4: lea r15d, [r14 + 0x29f95eb2] + _0xffffff8000ebd5ab: cmp rax, rdx + _0xffffff8000ebd5ae: cmovne r15d, r14d + _0xffffff8000ebd5b2: lea r12d, [r14 + 0xb] + _0xffffff8000ebd5b6: lea r14d, [r14 + 0x15c31855] + _0xffffff8000ebd5bd: cmp rax, rdx + _0xffffff8000ebd5c0: mov rax, rsi + _0xffffff8000ebd5c3: cmove rax, rcx + _0xffffff8000ebd5c7: mov eax, dword ptr [rax] + _0xffffff8000ebd5c9: cmove r12d, r14d + _0xffffff8000ebd5cd: mov rdx, qword ptr [rbp - 0x358] + _0xffffff8000ebd5d4: mov r14, qword ptr [rbp - 0x350] + _0xffffff8000ebd5db: mov dword ptr [r14], r12d + _0xffffff8000ebd5de: mov dword ptr [rdx], r15d + _0xffffff8000ebd5e1: jmp _0xffffff8000ebbab6 + _0xffffff8000ebd5e6: mov rax, qword ptr [rbp - 0x340] + _0xffffff8000ebd5ed: mov dl, byte ptr [rax + 4] + _0xffffff8000ebd5f0: mov byte ptr [rbp - 0x1a3], dl + _0xffffff8000ebd5f6: mov al, byte ptr [rax + 5] + _0xffffff8000ebd5f9: mov byte ptr [rbp - 0x1a2], al + _0xffffff8000ebd5ff: mov rax, qword ptr [rbp - 0x340] + _0xffffff8000ebd606: mov dl, byte ptr [rax + 6] + _0xffffff8000ebd609: mov byte ptr [rbp - 0x2d], dl + _0xffffff8000ebd60c: mov byte ptr [rbp - 0x1a1], dl + _0xffffff8000ebd612: mov al, byte ptr [rax + 7] + _0xffffff8000ebd615: mov byte ptr [rbp - 0x2c], al + _0xffffff8000ebd618: mov dword ptr [rbp - 0x1ec], 0xffff586f + _0xffffff8000ebd622: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebd628: lea edx, [rax - 3] + _0xffffff8000ebd62b: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebd632: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebd639: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebd640: mov dword ptr [r12], edx + _0xffffff8000ebd644: add eax, 0xe6e43245 + _0xffffff8000ebd649: jmp _0xffffff8000ebbc81 + _0xffffff8000ebd64e: mov rax, qword ptr [rbp - 0x40] + _0xffffff8000ebd652: mov qword ptr [rbp - 0x110], rax + _0xffffff8000ebd659: mov rdx, qword ptr [rbp - 0x160] + _0xffffff8000ebd660: mov qword ptr [rax], rdx + _0xffffff8000ebd663: mov rax, qword ptr [rbp - 0x118] + _0xffffff8000ebd66a: mov rdx, qword ptr [rbp - 0x160] + _0xffffff8000ebd671: mov qword ptr [rax], rdx + _0xffffff8000ebd674: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebd67b: mov qword ptr [rbp - 0x288], rax + _0xffffff8000ebd682: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebd686: mov qword ptr [rbp - 0x280], rdx + _0xffffff8000ebd68d: lea r14, [rbp - 0x288] + _0xffffff8000ebd694: mov qword ptr [rdx], r14 + _0xffffff8000ebd697: mov qword ptr [rax + 8], r14 + _0xffffff8000ebd69b: lea rax, [rbp - 0x278] + _0xffffff8000ebd6a2: mov qword ptr [rbp - 0x1d8], rax + _0xffffff8000ebd6a9: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebd6b0: mov qword ptr [rbp - 0x278], rax + _0xffffff8000ebd6b7: lea rdx, [rax + 8] + _0xffffff8000ebd6bb: mov qword ptr [rbp - 0x158], rdx + _0xffffff8000ebd6c2: mov rax, qword ptr [rax + 8] + _0xffffff8000ebd6c6: mov rdx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebd6cd: mov qword ptr [rdx + 8], rax + _0xffffff8000ebd6d1: mov qword ptr [rbp - 0x150], rax + _0xffffff8000ebd6d8: mov rdx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebd6df: mov qword ptr [rax], rdx + _0xffffff8000ebd6e2: mov rax, qword ptr [rbp - 0x158] + _0xffffff8000ebd6e9: mov rdx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebd6f0: mov qword ptr [rax], rdx + _0xffffff8000ebd6f3: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebd6fa: mov qword ptr [rbp - 0x268], rax + _0xffffff8000ebd701: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebd705: mov qword ptr [rbp - 0x260], rdx + _0xffffff8000ebd70c: lea r14, [rbp - 0x268] + _0xffffff8000ebd713: mov qword ptr [rdx], r14 + _0xffffff8000ebd716: mov qword ptr [rax + 8], r14 + _0xffffff8000ebd71a: lea rax, [rbp - 0x258] + _0xffffff8000ebd721: mov qword ptr [rbp - 0x1c0], rax + _0xffffff8000ebd728: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebd72f: mov qword ptr [rbp - 0x258], rax + _0xffffff8000ebd736: lea rdx, [rax + 8] + _0xffffff8000ebd73a: mov qword ptr [rbp - 0x148], rdx + _0xffffff8000ebd741: mov rax, qword ptr [rax + 8] + _0xffffff8000ebd745: mov rdx, qword ptr [rbp - 0x1c0] + _0xffffff8000ebd74c: mov qword ptr [rdx + 8], rax + _0xffffff8000ebd750: mov qword ptr [rbp - 0x140], rax + _0xffffff8000ebd757: mov rdx, qword ptr [rbp - 0x1c0] + _0xffffff8000ebd75e: mov qword ptr [rax], rdx + _0xffffff8000ebd761: mov rax, qword ptr [rbp - 0x148] + _0xffffff8000ebd768: mov rdx, qword ptr [rbp - 0x1c0] + _0xffffff8000ebd76f: mov qword ptr [rax], rdx + _0xffffff8000ebd772: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebd779: mov qword ptr [rbp - 0x248], rax + _0xffffff8000ebd780: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebd784: mov qword ptr [rbp - 0x240], rdx + _0xffffff8000ebd78b: lea r14, [rbp - 0x248] + _0xffffff8000ebd792: mov qword ptr [rdx], r14 + _0xffffff8000ebd795: mov qword ptr [rax + 8], r14 + _0xffffff8000ebd799: lea rax, [rbp - 0x238] + _0xffffff8000ebd7a0: mov qword ptr [rbp - 0x1b8], rax + // Unused + _0xffffff8000ebd7a7: lea rax, [rip + 0x9ba5] + _0xffffff8000ebd7ae: mov rax, qword ptr [rax] + _0xffffff8000ebd7b1: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebd7b8: mov qword ptr [rbp - 0x238], rax + _0xffffff8000ebd7bf: lea rdx, [rax + 8] + _0xffffff8000ebd7c3: mov qword ptr [rbp - 0x138], rdx + _0xffffff8000ebd7ca: mov rax, qword ptr [rax + 8] + _0xffffff8000ebd7ce: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebd7d5: mov qword ptr [rdx + 8], rax + _0xffffff8000ebd7d9: mov qword ptr [rbp - 0x190], rax + _0xffffff8000ebd7e0: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebd7e7: mov qword ptr [rax], rdx + _0xffffff8000ebd7ea: mov rax, qword ptr [rbp - 0x138] + _0xffffff8000ebd7f1: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebd7f8: mov qword ptr [rax], rdx + _0xffffff8000ebd7fb: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebd802: mov qword ptr [rbp - 0x228], rax + _0xffffff8000ebd809: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebd80d: mov qword ptr [rbp - 0x220], rdx + _0xffffff8000ebd814: lea r14, [rbp - 0x228] + _0xffffff8000ebd81b: mov qword ptr [rdx], r14 + _0xffffff8000ebd81e: mov qword ptr [rax + 8], r14 + _0xffffff8000ebd822: lea rax, [rbp - 0x218] + _0xffffff8000ebd829: mov qword ptr [rbp - 0x1b0], rax + _0xffffff8000ebd830: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebd837: mov qword ptr [rbp - 0x218], rax + _0xffffff8000ebd83e: lea rdx, [rax + 8] + _0xffffff8000ebd842: mov qword ptr [rbp - 0x1d0], rdx + _0xffffff8000ebd849: mov rax, qword ptr [rax + 8] + _0xffffff8000ebd84d: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebd854: mov qword ptr [rdx + 8], rax + _0xffffff8000ebd858: mov qword ptr [rbp - 0x1c8], rax + _0xffffff8000ebd85f: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebd866: mov qword ptr [rax], rdx + _0xffffff8000ebd869: mov rax, qword ptr [rbp - 0x1d0] + _0xffffff8000ebd870: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebd877: mov qword ptr [rax], rdx + _0xffffff8000ebd87a: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebd881: mov qword ptr [rbp - 0x208], rax + _0xffffff8000ebd888: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebd88c: mov qword ptr [rbp - 0x200], rdx + _0xffffff8000ebd893: lea r14, [rbp - 0x208] + _0xffffff8000ebd89a: mov qword ptr [rdx], r14 + _0xffffff8000ebd89d: mov qword ptr [rax + 8], r14 + _0xffffff8000ebd8a1: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebd8a7: lea edx, [rax - 0x191bcda8] + _0xffffff8000ebd8ad: lea r14d, [rax + 0xf] + _0xffffff8000ebd8b1: lea r15d, [rax - 0x191bcdc0] + _0xffffff8000ebd8b8: lea eax, [rax - 0x30389c15] + _0xffffff8000ebd8be: cmp dword ptr [rbp - 0x34], 7 + _0xffffff8000ebd8c2: mov r12, rsi + _0xffffff8000ebd8c5: cmove r12, rcx + _0xffffff8000ebd8c9: mov r12d, dword ptr [r12] + _0xffffff8000ebd8cd: cmove eax, r15d + _0xffffff8000ebd8d1: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebd8d8: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebd8df: mov dword ptr [r13], eax + _0xffffff8000ebd8e3: cmove r14d, edx + _0xffffff8000ebd8e7: mov dword ptr [r15], r14d + _0xffffff8000ebd8ea: jmp _0xffffff8000ebbbbf + _0xffffff8000ebd8ef: mov eax, dword ptr [rbp - 0x12c] + _0xffffff8000ebd8f5: lea edx, [rax + rax] + _0xffffff8000ebd8f8: mov r14d, eax + _0xffffff8000ebd8fb: xor r14d, 0x37ffe43f + _0xffffff8000ebd902: mov r15d, edx + _0xffffff8000ebd905: and r15d, 0x6fffc87e + _0xffffff8000ebd90c: add r15d, r14d + _0xffffff8000ebd90f: lea r14d, [r15 + r15 - 0x6fffc87e] + _0xffffff8000ebd917: mov r15d, r14d + _0xffffff8000ebd91a: and r15d, 0x4fbffbfa + _0xffffff8000ebd921: xor r14d, 0x4fbffbfb + _0xffffff8000ebd928: lea r14d, [r14 + r15*2 - 0x4fbffbfb] + _0xffffff8000ebd930: movsxd r14, r14d + _0xffffff8000ebd933: mov r15, r14 + _0xffffff8000ebd936: and r15, r9 + _0xffffff8000ebd939: xor r14, r9 + _0xffffff8000ebd93c: lea r14, [r14 + r15*2] + _0xffffff8000ebd940: add r14, qword ptr [rbp - 0x340] + _0xffffff8000ebd947: mov r15d, eax + _0xffffff8000ebd94a: xor r15d, 0x7af5dfdd + _0xffffff8000ebd951: mov r12d, edx + _0xffffff8000ebd954: and r12d, 0xf5ebbfba + _0xffffff8000ebd95b: lea r15d, [r15 + r12 - 0x7af5dfdd] + _0xffffff8000ebd963: movsxd r15, r15d + _0xffffff8000ebd966: mov r12, r15 + _0xffffff8000ebd969: and r12, r10 + _0xffffff8000ebd96c: xor r15, r10 + _0xffffff8000ebd96f: lea r15, [r15 + r12*2] + _0xffffff8000ebd973: lea r15, [rbp + r15 - 0x130] + _0xffffff8000ebd97b: movabs r12, 0xc04011203ae40811 + _0xffffff8000ebd985: mov r15b, byte ptr [r12 + r15] + _0xffffff8000ebd989: and r15b, 0xf + _0xffffff8000ebd98d: movabs r12, 0xd000a40418680042 + _0xffffff8000ebd997: mov byte ptr [r12 + r14], r15b + _0xffffff8000ebd99b: mov r14d, eax + _0xffffff8000ebd99e: xor r14d, 0x4dbbbdff + _0xffffff8000ebd9a5: mov r15d, edx + _0xffffff8000ebd9a8: and r15d, 0x1b777bfe + _0xffffff8000ebd9af: add r15d, r14d + _0xffffff8000ebd9b2: lea r14d, [r15 + r15 + 0x64888402] + _0xffffff8000ebd9ba: mov r15d, r14d + _0xffffff8000ebd9bd: and r15d, 0x7efff97e + _0xffffff8000ebd9c4: xor r14d, 0x7efff97f + _0xffffff8000ebd9cb: lea r14d, [r14 + r15*2 - 0x7efff97e] + _0xffffff8000ebd9d3: movsxd r14, r14d + _0xffffff8000ebd9d6: mov r15, r14 + _0xffffff8000ebd9d9: and r15, r11 + _0xffffff8000ebd9dc: xor r14, r11 + _0xffffff8000ebd9df: lea r14, [r14 + r15*2] + _0xffffff8000ebd9e3: add r14, qword ptr [rbp - 0x340] + _0xffffff8000ebd9ea: mov r15d, eax + _0xffffff8000ebd9ed: xor r15d, 0x3ffcddbf + _0xffffff8000ebd9f4: and edx, 0x7ff9bb7e + _0xffffff8000ebd9fa: lea edx, [r15 + rdx - 0x3ffcddbf] + _0xffffff8000ebda02: movsxd rdx, edx + _0xffffff8000ebda05: mov r15, rdx + _0xffffff8000ebda08: and r15, rbx + _0xffffff8000ebda0b: xor rdx, rbx + _0xffffff8000ebda0e: lea rdx, [rdx + r15*2] + _0xffffff8000ebda12: lea rdx, [rbp + rdx - 0x130] + _0xffffff8000ebda1a: movabs r15, 0xa000489060880081 + _0xffffff8000ebda24: mov dl, byte ptr [r15 + rdx] + _0xffffff8000ebda28: mov r15b, dl + _0xffffff8000ebda2b: shr r15b, 3 + _0xffffff8000ebda2f: and r15b, 0xc + _0xffffff8000ebda33: shr dl, 4 + _0xffffff8000ebda36: add dl, 0xe + _0xffffff8000ebda39: sub dl, r15b + _0xffffff8000ebda3c: and dl, 0xf + _0xffffff8000ebda3f: xor dl, 0xe + _0xffffff8000ebda42: movabs r15, 0xd03a060121852019 + _0xffffff8000ebda4c: mov byte ptr [r15 + r14], dl + _0xffffff8000ebda50: inc eax + _0xffffff8000ebda52: mov dword ptr [rbp - 0x12c], eax + _0xffffff8000ebda58: mov dword ptr [rbp - 0x1ec], 0 + _0xffffff8000ebda62: mov edx, dword ptr [rbp - 0x35c] + _0xffffff8000ebda68: lea r14d, [rdx + 0x5f93f672] + _0xffffff8000ebda6f: lea r15d, [rdx + 0xe077de] + _0xffffff8000ebda76: lea r12d, [rdx + 0x30389c04] + _0xffffff8000ebda7d: cmp eax, 4 + _0xffffff8000ebda80: mov rax, rsi + _0xffffff8000ebda83: cmove rax, rcx + _0xffffff8000ebda87: mov eax, dword ptr [rax] + _0xffffff8000ebda89: cmove r12d, r15d + _0xffffff8000ebda8d: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebda94: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebda9b: mov dword ptr [r13], r12d + _0xffffff8000ebda9f: cmovne r14d, edx + _0xffffff8000ebdaa3: mov dword ptr [r15], r14d + _0xffffff8000ebdaa6: jmp _0xffffff8000ebc771 + _0xffffff8000ebdaab: mov qword ptr [rbp - 0x1f8], rdi + _0xffffff8000ebdab2: mov qword ptr [rbp - 0x1e8], rdi + _0xffffff8000ebdab9: mov rax, qword ptr [rdi + 8] + _0xffffff8000ebdabd: mov qword ptr [rbp - 0x340], rax + _0xffffff8000ebdac4: mov edx, dword ptr [rdi + 0x10] + _0xffffff8000ebdac7: mov dword ptr [rbp - 0x1e0], edx + _0xffffff8000ebdacd: test rax, rax + _0xffffff8000ebdad0: sete byte ptr [rbp - 0x1d9] + _0xffffff8000ebdad7: lea rax, [rbp - 0x2b8] + _0xffffff8000ebdade: mov qword ptr [rbp - 0x2c0], rax + _0xffffff8000ebdae5: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ebdaec: mov qword ptr [rbp - 0x2b0], rax + _0xffffff8000ebdaf3: lea rax, [rbp - 0x2a8] + _0xffffff8000ebdafa: mov qword ptr [rbp - 0x330], rax + _0xffffff8000ebdb01: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ebdb08: mov qword ptr [rbp - 0x2a0], rax + _0xffffff8000ebdb0f: lea rax, [rbp - 0x298] + _0xffffff8000ebdb16: mov qword ptr [rbp - 0x328], rax + _0xffffff8000ebdb1d: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ebdb24: mov qword ptr [rbp - 0x290], rax + _0xffffff8000ebdb2b: lea rax, [rbp - 0x288] + _0xffffff8000ebdb32: mov qword ptr [rbp - 0x1d8], rax + _0xffffff8000ebdb39: mov qword ptr [rbp - 0x1d0], rax + _0xffffff8000ebdb40: lea rax, [rbp - 0x330] + _0xffffff8000ebdb47: mov qword ptr [rbp - 0x1c8], rax + _0xffffff8000ebdb4e: mov rax, qword ptr [rbp - 0x330] + _0xffffff8000ebdb55: mov qword ptr [rbp - 0x288], rax + _0xffffff8000ebdb5c: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdb60: mov r14, qword ptr [rbp - 0x1d8] + _0xffffff8000ebdb67: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebdb6b: mov r14, qword ptr [rbp - 0x1d8] + _0xffffff8000ebdb72: mov qword ptr [rdx], r14 + _0xffffff8000ebdb75: mov rdx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebdb7c: mov qword ptr [rax + 8], rdx + _0xffffff8000ebdb80: lea rax, [rbp - 0x278] + _0xffffff8000ebdb87: mov qword ptr [rbp - 0x338], rax + _0xffffff8000ebdb8e: mov qword ptr [rbp - 0x278], rax + _0xffffff8000ebdb95: mov qword ptr [rbp - 0x270], rax + _0xffffff8000ebdb9c: lea rax, [rbp - 0x268] + _0xffffff8000ebdba3: mov qword ptr [rbp - 0x320], rax + _0xffffff8000ebdbaa: mov qword ptr [rbp - 0x268], rax + _0xffffff8000ebdbb1: mov qword ptr [rbp - 0x260], rax + _0xffffff8000ebdbb8: lea rax, [rbp - 0x258] + _0xffffff8000ebdbbf: mov qword ptr [rbp - 0x1c0], rax + _0xffffff8000ebdbc6: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebdbcd: mov qword ptr [rbp - 0x258], rax + _0xffffff8000ebdbd4: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdbd8: mov r14, qword ptr [rbp - 0x1c0] + _0xffffff8000ebdbdf: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebdbe3: mov r14, qword ptr [rbp - 0x1c0] + _0xffffff8000ebdbea: mov qword ptr [rdx], r14 + _0xffffff8000ebdbed: mov rdx, qword ptr [rbp - 0x1c0] + _0xffffff8000ebdbf4: mov qword ptr [rax + 8], rdx + _0xffffff8000ebdbf8: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebdbff: mov qword ptr [rbp - 0x248], rax + _0xffffff8000ebdc06: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdc0a: mov qword ptr [rbp - 0x240], rdx + _0xffffff8000ebdc11: lea r14, [rbp - 0x248] + _0xffffff8000ebdc18: mov qword ptr [rdx], r14 + _0xffffff8000ebdc1b: mov qword ptr [rax + 8], r14 + _0xffffff8000ebdc1f: lea rax, [rbp - 0x238] + _0xffffff8000ebdc26: mov qword ptr [rbp - 0x1b8], rax + _0xffffff8000ebdc2d: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebdc34: mov qword ptr [rbp - 0x238], rax + _0xffffff8000ebdc3b: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdc3f: mov r14, qword ptr [rbp - 0x1b8] + _0xffffff8000ebdc46: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebdc4a: mov r14, qword ptr [rbp - 0x1b8] + _0xffffff8000ebdc51: mov qword ptr [rdx], r14 + _0xffffff8000ebdc54: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebdc5b: mov qword ptr [rax + 8], rdx + _0xffffff8000ebdc5f: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebdc66: mov qword ptr [rbp - 0x228], rax + _0xffffff8000ebdc6d: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdc71: mov qword ptr [rbp - 0x220], rdx + _0xffffff8000ebdc78: lea r14, [rbp - 0x228] + _0xffffff8000ebdc7f: mov qword ptr [rdx], r14 + _0xffffff8000ebdc82: mov qword ptr [rax + 8], r14 + _0xffffff8000ebdc86: lea rax, [rbp - 0x218] + _0xffffff8000ebdc8d: mov qword ptr [rbp - 0x1b0], rax + _0xffffff8000ebdc94: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebdc9b: mov qword ptr [rbp - 0x218], rax + _0xffffff8000ebdca2: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdca6: mov r14, qword ptr [rbp - 0x1b0] + _0xffffff8000ebdcad: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebdcb1: mov r14, qword ptr [rbp - 0x1b0] + _0xffffff8000ebdcb8: mov qword ptr [rdx], r14 + _0xffffff8000ebdcbb: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebdcc2: mov qword ptr [rax + 8], rdx + _0xffffff8000ebdcc6: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebdccd: mov qword ptr [rbp - 0x208], rax + _0xffffff8000ebdcd4: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdcd8: mov qword ptr [rbp - 0x200], rdx + _0xffffff8000ebdcdf: lea r14, [rbp - 0x208] + _0xffffff8000ebdce6: mov qword ptr [rdx], r14 + _0xffffff8000ebdce9: mov qword ptr [rax + 8], r14 + _0xffffff8000ebdced: mov dword ptr [rbp - 0x1ec], 0xffff586c + _0xffffff8000ebdcf7: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebdcfd: lea edx, [rax + 0x44047cac] + _0xffffff8000ebdd03: lea r14d, [rax - 0x171cce64] + _0xffffff8000ebdd0a: lea r15d, [rax + 0x47a3a846] + _0xffffff8000ebdd11: lea eax, [rax + 0x191bcdb9] + _0xffffff8000ebdd17: mov r12b, byte ptr [rbp - 0x1d9] + _0xffffff8000ebdd1e: test r12b, r12b + _0xffffff8000ebdd21: mov r12, rsi + _0xffffff8000ebdd24: cmovne r12, rcx + _0xffffff8000ebdd28: mov r12d, dword ptr [r12] + _0xffffff8000ebdd2c: cmovne eax, r15d + _0xffffff8000ebdd30: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebdd37: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebdd3e: mov dword ptr [r13], eax + _0xffffff8000ebdd42: cmove edx, r14d + _0xffffff8000ebdd46: jmp _0xffffff8000ebbeb2 + _0xffffff8000ebdd4b: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebdd51: lea edx, [rax - 0x171cce50] + _0xffffff8000ebdd57: lea r14d, [rax + 3] + _0xffffff8000ebdd5b: lea r15d, [rax + 0x191bcdbe] + _0xffffff8000ebdd62: lea eax, [rax - 0x171cce57] + _0xffffff8000ebdd68: cmp dword ptr [rbp - 0x34], 3 + _0xffffff8000ebdd6c: mov r12, rsi + _0xffffff8000ebdd6f: cmovl r12, rcx + _0xffffff8000ebdd73: mov r12d, dword ptr [r12] + _0xffffff8000ebdd77: cmovl eax, r15d + _0xffffff8000ebdd7b: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebdd82: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebdd89: mov dword ptr [r13], eax + _0xffffff8000ebdd8d: cmovge edx, r14d + _0xffffff8000ebdd91: mov dword ptr [r15], edx + _0xffffff8000ebdd94: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebdd9b: jge _0xffffff8000ebbd8a + _0xffffff8000ebdda1: jmp _0xffffff8000ebbabc + _0xffffff8000ebdda6: mov qword ptr [rbp - 0x340], rdi + _0xffffff8000ebddad: mov rax, qword ptr [rdi + 8] + _0xffffff8000ebddb1: mov qword ptr [rbp - 0x60], rax + _0xffffff8000ebddb5: mov rax, qword ptr [rdi + 0x10] + _0xffffff8000ebddb9: mov qword ptr [rbp - 0x178], rax + _0xffffff8000ebddc0: lea rax, [rbp - 0x2b8] + _0xffffff8000ebddc7: mov qword ptr [rbp - 0x338], rax + _0xffffff8000ebddce: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ebddd5: mov qword ptr [rbp - 0x2b0], rax + _0xffffff8000ebdddc: lea rax, [rbp - 0x2a8] + _0xffffff8000ebdde3: mov qword ptr [rbp - 0x2c0], rax + _0xffffff8000ebddea: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ebddf1: mov qword ptr [rbp - 0x2a0], rax + _0xffffff8000ebddf8: lea rax, [rbp - 0x298] + _0xffffff8000ebddff: mov qword ptr [rbp - 0x328], rax + _0xffffff8000ebde06: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ebde0d: mov qword ptr [rbp - 0x290], rax + _0xffffff8000ebde14: lea rax, [rbp - 0x288] + _0xffffff8000ebde1b: mov qword ptr [rbp - 0x58], rax + _0xffffff8000ebde1f: mov qword ptr [rbp - 0x118], rax + _0xffffff8000ebde26: lea rax, [rbp - 0x338] + _0xffffff8000ebde2d: mov qword ptr [rbp - 0x110], rax + _0xffffff8000ebde34: mov rdx, qword ptr [rbp - 0x338] + _0xffffff8000ebde3b: mov qword ptr [rbp - 0x288], rdx + _0xffffff8000ebde42: mov r14, qword ptr [rdx + 8] + _0xffffff8000ebde46: mov r15, qword ptr [rbp - 0x58] + _0xffffff8000ebde4a: mov qword ptr [r15 + 8], r14 + _0xffffff8000ebde4e: mov r15, qword ptr [rbp - 0x58] + _0xffffff8000ebde52: mov qword ptr [r14], r15 + _0xffffff8000ebde55: mov r14, qword ptr [rbp - 0x58] + _0xffffff8000ebde59: mov qword ptr [rdx + 8], r14 + _0xffffff8000ebde5d: mov rdx, qword ptr [rbp - 0x328] + _0xffffff8000ebde64: mov qword ptr [rbp - 0x278], rdx + _0xffffff8000ebde6b: mov r14, qword ptr [rdx + 8] + _0xffffff8000ebde6f: mov qword ptr [rbp - 0x270], r14 + _0xffffff8000ebde76: lea r15, [rbp - 0x278] + _0xffffff8000ebde7d: mov qword ptr [r14], r15 + _0xffffff8000ebde80: mov qword ptr [rdx + 8], r15 + _0xffffff8000ebde84: lea rdx, [rbp - 0x268] + _0xffffff8000ebde8b: mov qword ptr [rbp - 0x50], rdx + _0xffffff8000ebde8f: mov qword ptr [rbp - 0x158], rdx + _0xffffff8000ebde96: mov qword ptr [rbp - 0x150], rax + _0xffffff8000ebde9d: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebdea4: mov qword ptr [rbp - 0x268], rax + _0xffffff8000ebdeab: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdeaf: mov r14, qword ptr [rbp - 0x50] + _0xffffff8000ebdeb3: mov qword ptr [r14 + 8], rdx + _0xffffff8000ebdeb7: mov r14, qword ptr [rbp - 0x50] + _0xffffff8000ebdebb: mov qword ptr [rdx], r14 + _0xffffff8000ebdebe: mov rdx, qword ptr [rbp - 0x50] + _0xffffff8000ebdec2: mov qword ptr [rax + 8], rdx + _0xffffff8000ebdec6: lea rax, [rbp - 0x258] + _0xffffff8000ebdecd: mov qword ptr [rbp - 0x318], rax + _0xffffff8000ebded4: mov qword ptr [rbp - 0x258], rax + _0xffffff8000ebdedb: mov qword ptr [rbp - 0x250], rax + _0xffffff8000ebdee2: lea rax, [rbp - 0x248] + _0xffffff8000ebdee9: mov qword ptr [rbp - 0x168], rax + _0xffffff8000ebdef0: mov rax, qword ptr [rbp - 0x318] + _0xffffff8000ebdef7: mov qword ptr [rbp - 0x248], rax + _0xffffff8000ebdefe: lea rdx, [rax + 8] + _0xffffff8000ebdf02: mov qword ptr [rbp - 0x148], rdx + _0xffffff8000ebdf09: mov rax, qword ptr [rax + 8] + _0xffffff8000ebdf0d: mov qword ptr [rbp - 0x160], rax + _0xffffff8000ebdf14: mov rdx, qword ptr [rbp - 0x168] + _0xffffff8000ebdf1b: lea r14, [rdx + 8] + _0xffffff8000ebdf1f: mov qword ptr [rbp - 0x140], r14 + _0xffffff8000ebdf26: mov qword ptr [rdx + 8], rax + _0xffffff8000ebdf2a: mov rax, qword ptr [rbp - 0x168] + _0xffffff8000ebdf31: mov rdx, qword ptr [rbp - 0x160] + _0xffffff8000ebdf38: mov qword ptr [rdx], rax + _0xffffff8000ebdf3b: mov rax, qword ptr [rbp - 0x148] + _0xffffff8000ebdf42: mov rdx, qword ptr [rbp - 0x168] + _0xffffff8000ebdf49: mov qword ptr [rax], rdx + _0xffffff8000ebdf4c: mov rax, qword ptr [rbp - 0x328] + _0xffffff8000ebdf53: mov qword ptr [rbp - 0x238], rax + _0xffffff8000ebdf5a: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdf5e: mov qword ptr [rbp - 0x230], rdx + _0xffffff8000ebdf65: lea r14, [rbp - 0x238] + _0xffffff8000ebdf6c: mov qword ptr [rdx], r14 + _0xffffff8000ebdf6f: mov qword ptr [rax + 8], r14 + _0xffffff8000ebdf73: lea rax, [rbp - 0x228] + _0xffffff8000ebdf7a: mov qword ptr [rbp - 0x1d8], rax + _0xffffff8000ebdf81: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebdf88: mov qword ptr [rbp - 0x228], rax + _0xffffff8000ebdf8f: lea rdx, [rax + 8] + _0xffffff8000ebdf93: mov qword ptr [rbp - 0x138], rdx + _0xffffff8000ebdf9a: mov rax, qword ptr [rax + 8] + _0xffffff8000ebdf9e: mov qword ptr [rbp - 0x1c0], rax + _0xffffff8000ebdfa5: mov rdx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebdfac: lea r14, [rdx + 8] + _0xffffff8000ebdfb0: mov qword ptr [rbp - 0x190], r14 + _0xffffff8000ebdfb7: mov qword ptr [rdx + 8], rax + _0xffffff8000ebdfbb: mov rax, qword ptr [rbp - 0x1d8] + _0xffffff8000ebdfc2: mov rdx, qword ptr [rbp - 0x1c0] + _0xffffff8000ebdfc9: mov qword ptr [rdx], rax + _0xffffff8000ebdfcc: mov rax, qword ptr [rbp - 0x138] + _0xffffff8000ebdfd3: mov rdx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebdfda: mov qword ptr [rax], rdx + _0xffffff8000ebdfdd: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebdfe4: mov qword ptr [rbp - 0x218], rax + _0xffffff8000ebdfeb: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebdfef: mov qword ptr [rbp - 0x210], rdx + _0xffffff8000ebdff6: lea r14, [rbp - 0x218] + _0xffffff8000ebdffd: mov qword ptr [rdx], r14 + _0xffffff8000ebe000: mov qword ptr [rax + 8], r14 + _0xffffff8000ebe004: lea rax, [rbp - 0x208] + _0xffffff8000ebe00b: mov qword ptr [rbp - 0x1b8], rax + _0xffffff8000ebe012: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebe019: mov qword ptr [rbp - 0x208], rax + _0xffffff8000ebe020: lea rdx, [rax + 8] + _0xffffff8000ebe024: mov qword ptr [rbp - 0x1d0], rdx + _0xffffff8000ebe02b: mov rax, qword ptr [rax + 8] + _0xffffff8000ebe02f: mov qword ptr [rbp - 0x1b0], rax + _0xffffff8000ebe036: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebe03d: lea r14, [rdx + 8] + _0xffffff8000ebe041: mov qword ptr [rbp - 0x1c8], r14 + _0xffffff8000ebe048: mov qword ptr [rdx + 8], rax + _0xffffff8000ebe04c: mov rax, qword ptr [rbp - 0x1b8] + _0xffffff8000ebe053: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebe05a: mov qword ptr [rdx], rax + _0xffffff8000ebe05d: mov rax, qword ptr [rbp - 0x1d0] + _0xffffff8000ebe064: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebe06b: mov qword ptr [rax], rdx + _0xffffff8000ebe06e: mov qword ptr [rbp - 0x198], 0 + _0xffffff8000ebe079: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebe07f: lea edx, [rax + 0x191bcdbb] + _0xffffff8000ebe085: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebe08c: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebe093: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebe09a: mov dword ptr [r12], edx + _0xffffff8000ebe09e: add eax, 0x191bcdca + _0xffffff8000ebe0a3: jmp _0xffffff8000ebbc81 + _0xffffff8000ebe0a8: mov qword ptr [rbp - 0x1f8], rdi + _0xffffff8000ebe0af: mov rax, qword ptr [rdi + 8] + _0xffffff8000ebe0b3: mov qword ptr [rbp - 0x340], rax + _0xffffff8000ebe0ba: mov eax, dword ptr [rdi + 4] + _0xffffff8000ebe0bd: dec eax + _0xffffff8000ebe0bf: inc rax + _0xffffff8000ebe0c2: mov qword ptr [rbp - 0x198], rax + // Unused + _0xffffff8000ebe0c9: lea rax, [rip] + _0xffffff8000ebe0d0: mov rax, qword ptr [rax - 0x1345] + _0xffffff8000ebe0d7: lea rax, [rbp - 0x2b8] + _0xffffff8000ebe0de: mov qword ptr [rbp - 0x2c0], rax + _0xffffff8000ebe0e5: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ebe0ec: mov qword ptr [rbp - 0x2b0], rax + _0xffffff8000ebe0f3: lea rax, [rbp - 0x2a8] + _0xffffff8000ebe0fa: mov qword ptr [rbp - 0x338], rax + _0xffffff8000ebe101: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ebe108: mov qword ptr [rbp - 0x2a0], rax + _0xffffff8000ebe10f: lea rax, [rbp - 0x298] + _0xffffff8000ebe116: mov qword ptr [rbp - 0x328], rax + _0xffffff8000ebe11d: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ebe124: mov qword ptr [rbp - 0x290], rax + _0xffffff8000ebe12b: lea rax, [rbp - 0x288] + _0xffffff8000ebe132: mov qword ptr [rbp - 0x1d8], rax + _0xffffff8000ebe139: mov qword ptr [rbp - 0x318], rax + _0xffffff8000ebe140: mov rax, qword ptr [rbp - 0x1d8] + _0xffffff8000ebe147: mov qword ptr [rax], rax + _0xffffff8000ebe14a: mov rdx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebe151: mov qword ptr [rax + 8], rdx + _0xffffff8000ebe155: lea rax, [rbp - 0x278] + _0xffffff8000ebe15c: mov qword ptr [rbp - 0x330], rax + _0xffffff8000ebe163: mov qword ptr [rbp - 0x278], rax + _0xffffff8000ebe16a: mov qword ptr [rbp - 0x270], rax + _0xffffff8000ebe171: lea rax, [rbp - 0x268] + _0xffffff8000ebe178: mov qword ptr [rbp - 0x320], rax + _0xffffff8000ebe17f: mov qword ptr [rbp - 0x268], rax + _0xffffff8000ebe186: mov qword ptr [rbp - 0x260], rax + _0xffffff8000ebe18d: lea rax, [rbp - 0x258] + _0xffffff8000ebe194: mov qword ptr [rbp - 0x1c0], rax + _0xffffff8000ebe19b: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebe1a2: mov qword ptr [rbp - 0x258], rax + _0xffffff8000ebe1a9: lea rdx, [rax + 8] + _0xffffff8000ebe1ad: mov qword ptr [rbp - 0x190], rdx + _0xffffff8000ebe1b4: mov rax, qword ptr [rax + 8] + _0xffffff8000ebe1b8: mov rdx, qword ptr [rbp - 0x1c0] + _0xffffff8000ebe1bf: mov qword ptr [rdx + 8], rax + _0xffffff8000ebe1c3: mov rdx, qword ptr [rbp - 0x1c0] + _0xffffff8000ebe1ca: mov qword ptr [rax], rdx + _0xffffff8000ebe1cd: mov rax, qword ptr [rbp - 0x190] + _0xffffff8000ebe1d4: mov rdx, qword ptr [rbp - 0x1c0] + _0xffffff8000ebe1db: mov qword ptr [rax], rdx + _0xffffff8000ebe1de: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebe1e5: mov qword ptr [rbp - 0x248], rax + _0xffffff8000ebe1ec: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebe1f0: mov qword ptr [rbp - 0x240], rdx + _0xffffff8000ebe1f7: lea r14, [rbp - 0x248] + _0xffffff8000ebe1fe: mov qword ptr [rdx], r14 + _0xffffff8000ebe201: mov qword ptr [rax + 8], r14 + _0xffffff8000ebe205: lea rax, [rbp - 0x238] + _0xffffff8000ebe20c: mov qword ptr [rbp - 0x1b8], rax + _0xffffff8000ebe213: mov rax, qword ptr [rbp - 0x328] + _0xffffff8000ebe21a: mov qword ptr [rbp - 0x238], rax + _0xffffff8000ebe221: lea rdx, [rax + 8] + _0xffffff8000ebe225: mov qword ptr [rbp - 0x1d0], rdx + _0xffffff8000ebe22c: mov rax, qword ptr [rax + 8] + _0xffffff8000ebe230: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebe237: mov qword ptr [rdx + 8], rax + _0xffffff8000ebe23b: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebe242: mov qword ptr [rax], rdx + _0xffffff8000ebe245: mov rax, qword ptr [rbp - 0x1d0] + _0xffffff8000ebe24c: mov rdx, qword ptr [rbp - 0x1b8] + _0xffffff8000ebe253: mov qword ptr [rax], rdx + _0xffffff8000ebe256: mov rax, qword ptr [rbp - 0x330] + _0xffffff8000ebe25d: mov qword ptr [rbp - 0x228], rax + _0xffffff8000ebe264: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebe268: mov qword ptr [rbp - 0x220], rdx + _0xffffff8000ebe26f: lea r14, [rbp - 0x228] + _0xffffff8000ebe276: mov qword ptr [rdx], r14 + _0xffffff8000ebe279: mov qword ptr [rax + 8], r14 + _0xffffff8000ebe27d: lea rax, [rbp - 0x218] + _0xffffff8000ebe284: mov qword ptr [rbp - 0x1b0], rax + _0xffffff8000ebe28b: mov rax, qword ptr [rbp - 0x338] + _0xffffff8000ebe292: mov qword ptr [rbp - 0x218], rax + _0xffffff8000ebe299: lea rdx, [rax + 8] + _0xffffff8000ebe29d: mov qword ptr [rbp - 0x1c8], rdx + _0xffffff8000ebe2a4: mov rax, qword ptr [rax + 8] + _0xffffff8000ebe2a8: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebe2af: mov qword ptr [rdx + 8], rax + _0xffffff8000ebe2b3: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebe2ba: mov qword ptr [rax], rdx + _0xffffff8000ebe2bd: mov rax, qword ptr [rbp - 0x1c8] + _0xffffff8000ebe2c4: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebe2cb: mov qword ptr [rax], rdx + _0xffffff8000ebe2ce: mov rax, qword ptr [rbp - 0x330] + _0xffffff8000ebe2d5: mov qword ptr [rbp - 0x208], rax + _0xffffff8000ebe2dc: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebe2e0: mov qword ptr [rbp - 0x200], rdx + _0xffffff8000ebe2e7: lea r14, [rbp - 0x208] + _0xffffff8000ebe2ee: mov qword ptr [rdx], r14 + _0xffffff8000ebe2f1: mov qword ptr [rax + 8], r14 + _0xffffff8000ebe2f5: mov qword ptr [rbp - 0x1a0], 0 + _0xffffff8000ebe300: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebe306: lea edx, [rax - 7] + _0xffffff8000ebe309: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebe310: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebe317: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebe31e: mov dword ptr [r12], edx + _0xffffff8000ebe322: add eax, -0x12 + _0xffffff8000ebe325: jmp _0xffffff8000ebbc81 + _0xffffff8000ebe32a: mov dword ptr [rbp - 0x12c], 0xffff586c + _0xffffff8000ebe334: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebe33a: lea edx, [rax - 0x43b0826] + _0xffffff8000ebe340: lea r14d, [rax - 6] + _0xffffff8000ebe344: lea r15d, [rax - 0x251c01b8] + _0xffffff8000ebe34b: lea eax, [rax + 0x191bcdbf] + _0xffffff8000ebe351: mov r12b, byte ptr [rbp - 0xc3] + _0xffffff8000ebe358: test r12b, r12b + _0xffffff8000ebe35b: mov r12, rsi + _0xffffff8000ebe35e: cmovne r12, rcx + _0xffffff8000ebe362: mov r12d, dword ptr [r12] + _0xffffff8000ebe366: cmovne eax, r15d + _0xffffff8000ebe36a: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebe371: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebe378: mov dword ptr [r13], eax + _0xffffff8000ebe37c: cmove edx, r14d + _0xffffff8000ebe380: jmp _0xffffff8000ebd459 + _0xffffff8000ebe385: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebe38b: lea edx, [rax + 0x311d827] + _0xffffff8000ebe391: lea r14d, [rax - 0x2e732ed] + _0xffffff8000ebe398: lea r15d, [rax + 0x191bcdad] + _0xffffff8000ebe39f: lea eax, [rax - 0x171cce63] + _0xffffff8000ebe3a5: cmp byte ptr [rbp - 0x2b], 3 + _0xffffff8000ebe3a9: jmp _0xffffff8000ebd434 + _0xffffff8000ebe3ae: mov rax, qword ptr [rbp - 0x178] + _0xffffff8000ebe3b5: mov dword ptr [rax], 0x792bf512 + _0xffffff8000ebe3bb: mov qword ptr [rbp - 0x198], 0 + _0xffffff8000ebe3c6: mov rax, qword ptr [rbp - 0x340] + _0xffffff8000ebe3cd: mov qword ptr [rbp - 0x1f8], rax + _0xffffff8000ebe3d4: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebe3da: lea edx, [rax + 3] + _0xffffff8000ebe3dd: mov r14d, dword ptr [rbp - 0x344] + _0xffffff8000ebe3e4: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebe3eb: mov r12, qword ptr [rbp - 0x350] + _0xffffff8000ebe3f2: mov dword ptr [r12], edx + _0xffffff8000ebe3f6: add eax, 0x191bcdb2 + _0xffffff8000ebe3fb: jmp _0xffffff8000ebbc81 + _0xffffff8000ebe400: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebe406: lea edx, [rax - 0xc158115] + _0xffffff8000ebe40c: lea r14d, [rax + 0x19a3018f] + _0xffffff8000ebe413: lea r15d, [rax + 0x30389c0b] + _0xffffff8000ebe41a: lea eax, [rax - 5] + _0xffffff8000ebe41d: cmp byte ptr [rbp - 0x2b], 1 + _0xffffff8000ebe421: jmp _0xffffff8000ebd434 + _0xffffff8000ebe426: mov eax, dword ptr [rbp - 0x35c] + _0xffffff8000ebe42c: lea edx, [rax - 0x576cdeb] + _0xffffff8000ebe432: lea r14d, [rax + 0x2ff18316] + _0xffffff8000ebe439: lea r15d, [rax + 0x54f1e316] + _0xffffff8000ebe440: lea eax, [rax + 0x30389c08] + _0xffffff8000ebe446: cmp dword ptr [rbp - 0x34], 8 + _0xffffff8000ebe44a: mov r12, rsi + _0xffffff8000ebe44d: cmove r12, rcx + _0xffffff8000ebe451: mov r12d, dword ptr [r12] + _0xffffff8000ebe455: cmove eax, r15d + _0xffffff8000ebe459: mov r15, qword ptr [rbp - 0x358] + _0xffffff8000ebe460: mov r13, qword ptr [rbp - 0x350] + _0xffffff8000ebe467: mov dword ptr [r13], eax + _0xffffff8000ebe46b: cmovne edx, r14d + _0xffffff8000ebe46f: mov dword ptr [r15], edx + _0xffffff8000ebe472: mov dword ptr [rbp - 0x35c], r12d + _0xffffff8000ebe479: jne _0xffffff8000ebbd8a + _0xffffff8000ebe47f: jmp _0xffffff8000ebc574 + _0xffffff8000ebe484: mov rcx, qword ptr [rbp - 0x1e8] + _0xffffff8000ebe48b: mov eax, dword ptr [rbp - 0x1ec] + _0xffffff8000ebe491: mov dword ptr [rcx + 0x14], eax + _0xffffff8000ebe494: add rsp, 0x358 + _0xffffff8000ebe49b: pop rbx + _0xffffff8000ebe49c: pop r12 + _0xffffff8000ebe49e: pop r13 + _0xffffff8000ebe4a0: pop r14 + _0xffffff8000ebe4a2: pop r15 + _0xffffff8000ebe4a4: pop rbp + _0xffffff8000ebe4a5: ret + + .align 0x100 + sub_0xffffff8000ebe600: + _0xffffff8000ebe600: push rbp + _0xffffff8000ebe601: mov rbp, rsp + _0xffffff8000ebe604: push r15 + _0xffffff8000ebe606: push r14 + _0xffffff8000ebe608: push r13 + _0xffffff8000ebe60a: push r12 + _0xffffff8000ebe60c: push rbx + _0xffffff8000ebe60d: sub rsp, 0x3d8 + _0xffffff8000ebe614: lea rbx, [rbp - 0x38c] + _0xffffff8000ebe61b: mov dword ptr [rbp - 0x38c], ebx + _0xffffff8000ebe621: lea r14, [rbp - 0x390] + _0xffffff8000ebe628: mov dword ptr [rbp - 0x390], r14d + _0xffffff8000ebe62f: lea rax, [rbp - 0x398] + _0xffffff8000ebe636: mov qword ptr [rbp - 0x398], rax + _0xffffff8000ebe63d: lea rax, [rbp - 0x3a0] + _0xffffff8000ebe644: mov qword ptr [rbp - 0x3a0], rax + _0xffffff8000ebe64b: mov dword ptr [rbp - 0x3a4], 0x465c2aa6 + _0xffffff8000ebe655: mov qword ptr [rbp - 0x398], rbx + _0xffffff8000ebe65c: mov qword ptr [rbp - 0x3a0], r14 + _0xffffff8000ebe663: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebe669: lea ecx, [rax - 0x2109fba9] + _0xffffff8000ebe66f: mov rdx, qword ptr [rbp - 0x398] + _0xffffff8000ebe676: mov dword ptr [rdx], ecx + _0xffffff8000ebe678: add eax, 0x19a4c6a6 + _0xffffff8000ebe67d: mov dword ptr [rbp - 0x390], eax + _0xffffff8000ebe683: mov dword ptr [rbp - 0x3a4], 0x535c784f + _0xffffff8000ebe68d: mov qword ptr [rbp - 0x3b0], rdi + _0xffffff8000ebe694: lea r15, [rbp - 0x308] + _0xffffff8000ebe69b: lea r12, [rbp - 0x2f8] + _0xffffff8000ebe6a2: lea r13, [rbp - 0x388] + _0xffffff8000ebe6a9: jmp _0xffffff8000ebe6bb + _0xffffff8000ebe6ab: nop dword ptr [rax + rax] + nop + _0xffffff8000ebe6b0: cmp eax, 0x45a75f7b + _0xffffff8000ebe6b5: je _0xffffff8000ebee14 + _0xffffff8000ebe6bb: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebe6c1: cmp eax, 0x45a75f7a + _0xffffff8000ebe6c6: jg _0xffffff8000ebe7e3 + _0xffffff8000ebe6cc: cmp eax, 0x25522f03 + _0xffffff8000ebe6d1: jne _0xffffff8000ebe6bb + _0xffffff8000ebe6d3: jmp _0xffffff8000ebea0e + _0xffffff8000ebe6d8: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebe6de: lea ecx, [rax + 0x2e0a4956] + _0xffffff8000ebe6e4: lea edx, [rax - 0x22f64fa8] + _0xffffff8000ebe6ea: lea esi, [rax + 8] + _0xffffff8000ebe6ed: lea eax, [rax + 0x20553074] + _0xffffff8000ebe6f3: cmp dword ptr [rbp - 0x2c], 1 + _0xffffff8000ebe6f7: mov rdi, r14 + _0xffffff8000ebe6fa: cmove rdi, rbx + _0xffffff8000ebe6fe: mov edi, dword ptr [rdi] + _0xffffff8000ebe700: cmove eax, esi + _0xffffff8000ebe703: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebe70a: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebe711: mov dword ptr [r8], eax + _0xffffff8000ebe714: cmovne ecx, edx + _0xffffff8000ebe717: mov dword ptr [rsi], ecx + _0xffffff8000ebe719: mov dword ptr [rbp - 0x3a4], edi + _0xffffff8000ebe71f: je _0xffffff8000ebea0e + _0xffffff8000ebe725: jmp _0xffffff8000ebee14 + _0xffffff8000ebe72a: mov rax, qword ptr [rbp - 0x130] + _0xffffff8000ebe731: mov rcx, rax + _0xffffff8000ebe734: movabs rdx, 0x2ebfaeb5daff5ba3 + _0xffffff8000ebe73e: xor rcx, rdx + _0xffffff8000ebe741: add rcx, qword ptr [rbp - 0x138] + _0xffffff8000ebe748: and eax, 0xdaff5ba3 + _0xffffff8000ebe74d: lea rax, [rcx + rax*2] + _0xffffff8000ebe751: add rax, qword ptr [rbp - 0x1d8] + _0xffffff8000ebe758: movabs rcx, 0xd140514a2500a45d + _0xffffff8000ebe762: add rax, rcx + _0xffffff8000ebe765: mov rcx, qword ptr [rbp - 0x1f8] + _0xffffff8000ebe76c: mov qword ptr [rcx + 8], rax + _0xffffff8000ebe770: mov rax, qword ptr [rbp - 0x1f8] + _0xffffff8000ebe777: lea rcx, [rbp - 0x238] + _0xffffff8000ebe77e: mov qword ptr [rax + 0x18], rcx + _0xffffff8000ebe782: mov rax, qword ptr [rbp - 0x1c0] + _0xffffff8000ebe789: mov rcx, qword ptr [rbp - 0x1f8] + _0xffffff8000ebe790: mov qword ptr [rcx + 0x20], rax + _0xffffff8000ebe794: mov rdi, qword ptr [rbp - 0x1f8] + _0xffffff8000ebe79b: mov dword ptr [rbp - 0x200], 0 + _0xffffff8000ebe7a5: mov dword ptr [rdi], 0x28 + _0xffffff8000ebe7ab: call sub_0xffffff8000eb7d00 + _0xffffff8000ebe7b0: mov rax, qword ptr [rbp - 0x1f8] + _0xffffff8000ebe7b7: mov eax, dword ptr [rax + 0x10] + _0xffffff8000ebe7ba: mov dword ptr [rbp - 0x1fc], eax + _0xffffff8000ebe7c0: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebe7c6: lea edx, [rcx - 0x35b97d2c] + _0xffffff8000ebe7cc: lea esi, [rcx - 0x2bb38d2f] + _0xffffff8000ebe7d2: lea edi, [rcx - 0x2018fb7b] + _0xffffff8000ebe7d8: lea ecx, [rcx + 0x1c0fe88c] + _0xffffff8000ebe7de: jmp _0xffffff8000ebeef3 + _0xffffff8000ebe7e3: cmp eax, 0x535c784b + _0xffffff8000ebe7e8: jle _0xffffff8000ebe6b0 + _0xffffff8000ebe7ee: add eax, 0xaca387b4 + _0xffffff8000ebe7f3: cmp eax, 9 + _0xffffff8000ebe7f6: ja _0xffffff8000ebe6bb + _0xffffff8000ebe7fc: lea rcx, [rip + jumptbl_0xffffff8000ebe7fc] + _0xffffff8000ebe803: movsxd rax, dword ptr [rcx + rax*4] + _0xffffff8000ebe807: add rax, rcx + _0xffffff8000ebe80a: jmp rax + _0xffffff8000ebe80c: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebe813: inc dword ptr [rax + 0x14] + _0xffffff8000ebe816: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebe81c: lea ecx, [rax + 0x2e0a4950] + _0xffffff8000ebe822: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebe828: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebe82f: lea rdi, [rip] + _0xffffff8000ebe836: mov rdi, qword ptr [rdi - 0x2f] + _0xffffff8000ebe83a: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebe841: mov dword ptr [rdi], ecx + _0xffffff8000ebe843: add eax, 0x20553070 + _0xffffff8000ebe848: mov dword ptr [rsi], eax + _0xffffff8000ebe84a: mov dword ptr [rbp - 0x3a4], edx + _0xffffff8000ebe850: jmp _0xffffff8000ebe6bb + _0xffffff8000ebe855: mov eax, dword ptr [rbp - 0x23c] + _0xffffff8000ebe85b: mov ecx, 0x3f + _0xffffff8000ebe860: sub ecx, eax + _0xffffff8000ebe862: inc rcx + _0xffffff8000ebe865: mov edx, 0x40 + _0xffffff8000ebe86a: sub edx, eax + _0xffffff8000ebe86c: cmp edx, 1 + _0xffffff8000ebe86f: mov eax, 1 + _0xffffff8000ebe874: cmova rax, rcx + _0xffffff8000ebe878: mov qword ptr [rbp - 0x68], rax + _0xffffff8000ebe87c: mov qword ptr [rbp - 0xe0], 0 + _0xffffff8000ebe887: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebe88d: lea ecx, [rax - 0xa] + _0xffffff8000ebe890: jmp _0xffffff8000ebf4a3 + _0xffffff8000ebe895: mov eax, dword ptr [rbp - 0x90] + _0xffffff8000ebe89b: mov ecx, eax + _0xffffff8000ebe89d: shr ecx, 0x1d + _0xffffff8000ebe8a0: xor ecx, 0x647fdfba + _0xffffff8000ebe8a6: mov rdx, qword ptr [rbp - 0x98] + _0xffffff8000ebe8ad: add ecx, dword ptr [rdx + 0x14] + _0xffffff8000ebe8b0: shr eax, 0x1c + _0xffffff8000ebe8b3: and eax, 4 + _0xffffff8000ebe8b6: lea eax, [rax + rcx - 0x647fdfba] + _0xffffff8000ebe8bd: mov dword ptr [rdx + 0x14], eax + _0xffffff8000ebe8c0: mov eax, dword ptr [rbp - 0x8c] + _0xffffff8000ebe8c6: mov ecx, eax + _0xffffff8000ebe8c8: shr ecx, 2 + _0xffffff8000ebe8cb: and ecx, 0x12 + _0xffffff8000ebe8ce: shr eax, 3 + _0xffffff8000ebe8d1: add eax, 9 + _0xffffff8000ebe8d4: sub eax, ecx + _0xffffff8000ebe8d6: and eax, 0x3f + _0xffffff8000ebe8d9: mov ecx, eax + _0xffffff8000ebe8db: xor ecx, 9 + _0xffffff8000ebe8de: mov dword ptr [rbp - 0x23c], ecx + _0xffffff8000ebe8e4: mov rcx, qword ptr [rbp - 0x248] + _0xffffff8000ebe8eb: mov qword ptr [rbp - 0xd8], rcx + _0xffffff8000ebe8f2: mov ecx, dword ptr [rbp - 0x90] + _0xffffff8000ebe8f8: mov dword ptr [rbp - 0xd0], ecx + _0xffffff8000ebe8fe: mov dword ptr [rbp - 0x34], ecx + _0xffffff8000ebe901: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebe907: lea edx, [rcx - 0x2e0a4950] + _0xffffff8000ebe90d: lea esi, [rcx - 0x2e0a4954] + _0xffffff8000ebe913: lea edi, [rcx + 3] + _0xffffff8000ebe916: lea ecx, [rcx - 2] + _0xffffff8000ebe919: cmp eax, 9 + _0xffffff8000ebe91c: mov rax, r14 + _0xffffff8000ebe91f: cmove rax, rbx + _0xffffff8000ebe923: mov eax, dword ptr [rax] + _0xffffff8000ebe925: cmove ecx, edi + _0xffffff8000ebe928: mov rdi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebe92f: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebe936: mov dword ptr [r8], ecx + _0xffffff8000ebe939: cmovne edx, esi + _0xffffff8000ebe93c: mov dword ptr [rdi], edx + _0xffffff8000ebe93e: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebe944: je _0xffffff8000ebe6bb + _0xffffff8000ebe94a: jmp _0xffffff8000ebea0e + _0xffffff8000ebe94f: nop + _0xffffff8000ebe950: mov rax, qword ptr [rbp - 0xb0] + _0xffffff8000ebe957: mov rcx, qword ptr [rbp - 0x98] + _0xffffff8000ebe95e: mov qword ptr [rax + 8], rcx + _0xffffff8000ebe962: mov rax, qword ptr [rbp - 0x1c8] + _0xffffff8000ebe969: mov rcx, qword ptr [rbp - 0xb0] + _0xffffff8000ebe970: mov qword ptr [rcx + 0x10], rax + _0xffffff8000ebe974: mov rdi, qword ptr [rbp - 0xb0] + _0xffffff8000ebe97b: mov dword ptr [rbp - 0xec], 0 + _0xffffff8000ebe985: mov dword ptr [rdi], 0xf + _0xffffff8000ebe98b: call sub_0xffffff8000eb7d00 + _0xffffff8000ebe990: mov rax, qword ptr [rbp - 0x58] + _0xffffff8000ebe994: inc rax + _0xffffff8000ebe997: mov ecx, dword ptr [rbp - 0x200] + _0xffffff8000ebe99d: mov qword ptr [rbp - 0x128], rax + _0xffffff8000ebe9a4: mov rax, qword ptr [rbp - 0x110] + _0xffffff8000ebe9ab: mov dword ptr [rbp - 0xcc], ecx + _0xffffff8000ebe9b1: mov qword ptr [rbp - 0xc8], rax + _0xffffff8000ebe9b8: mov dword ptr [rbp - 0x30], ecx + _0xffffff8000ebe9bb: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebe9c1: lea edx, [rax + 0xe] + _0xffffff8000ebe9c4: lea rsi, [rip - 0x5ec6] + _0xffffff8000ebe9cb: mov rsi, qword ptr [rsi] + _0xffffff8000ebe9ce: lea esi, [rax - 0x2055306f] + _0xffffff8000ebe9d4: lea edi, [rax + 0xdb518e7] + _0xffffff8000ebe9da: lea eax, [rax + 3] + _0xffffff8000ebe9dd: cmp ecx, 0x3f + _0xffffff8000ebe9e0: mov rcx, r14 + _0xffffff8000ebe9e3: cmova rcx, rbx + _0xffffff8000ebe9e7: mov ecx, dword ptr [rcx] + _0xffffff8000ebe9e9: cmova eax, edi + _0xffffff8000ebe9ec: mov rdi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebe9f3: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebe9fa: mov dword ptr [r8], eax + _0xffffff8000ebe9fd: cmovbe edx, esi + _0xffffff8000ebea00: mov dword ptr [rdi], edx + _0xffffff8000ebea02: mov dword ptr [rbp - 0x3a4], ecx + _0xffffff8000ebea08: jbe _0xffffff8000ebee14 + _0xffffff8000ebea0e: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebea14: mov ecx, 0xba58a097 + _0xffffff8000ebea19: add eax, ecx + _0xffffff8000ebea1b: cmp eax, 0x12 + _0xffffff8000ebea1e: ja _0xffffff8000ebea0e + _0xffffff8000ebea20: lea rcx, [rip + jumptbl_0xffffff8000ebea20] + _0xffffff8000ebea27: movsxd rax, dword ptr [rcx + rax*4] + _0xffffff8000ebea2b: add rax, rcx + _0xffffff8000ebea2e: jmp rax + _0xffffff8000ebea30: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebea36: lea ecx, [rax + 0x296065fa] + _0xffffff8000ebea3c: lea edx, [rax + 0x59f586db] + _0xffffff8000ebea42: lea esi, [rax + 7] + _0xffffff8000ebea45: lea eax, [rax + 2] + _0xffffff8000ebea48: cmp dword ptr [rbp - 0x234], 0x18 + _0xffffff8000ebea4f: mov rdi, r14 + _0xffffff8000ebea52: cmove rdi, rbx + _0xffffff8000ebea56: mov edi, dword ptr [rdi] + _0xffffff8000ebea58: cmove eax, esi + _0xffffff8000ebea5b: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebea62: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebea69: mov dword ptr [r8], eax + _0xffffff8000ebea6c: cmovne ecx, edx + _0xffffff8000ebea6f: mov dword ptr [rsi], ecx + _0xffffff8000ebea71: jmp _0xffffff8000ec6a4b + _0xffffff8000ebea76: mov rax, qword ptr [rbp - 0x1c8] + _0xffffff8000ebea7d: mov dword ptr [rax], 0 + _0xffffff8000ebea83: lea rax, [rip - 0x7480] + _0xffffff8000ebea8a: mov rax, qword ptr [rax] + _0xffffff8000ebea8d: mov rax, qword ptr [rbp - 0x1d8] + _0xffffff8000ebea94: mov rcx, qword ptr [rbp - 0x1e8] + _0xffffff8000ebea9b: mov qword ptr [rcx + 8], rax + _0xffffff8000ebea9f: mov rax, qword ptr [rbp - 0x1e8] + _0xffffff8000ebeaa6: lea rcx, [rbp - 0x234] + _0xffffff8000ebeaad: mov qword ptr [rax + 0x18], rcx + _0xffffff8000ebeab1: mov rax, qword ptr [rbp - 0x1c0] + _0xffffff8000ebeab8: mov rcx, qword ptr [rbp - 0x1e8] + _0xffffff8000ebeabf: mov qword ptr [rcx + 0x10], rax + _0xffffff8000ebeac3: mov rdi, qword ptr [rbp - 0x1e8] + _0xffffff8000ebeaca: mov dword ptr [rbp - 0x200], 0 + _0xffffff8000ebead4: mov dword ptr [rdi], 0x20 + _0xffffff8000ebeada: call sub_0xffffff8000eb7d00 + _0xffffff8000ebeadf: mov rax, qword ptr [rbp - 0x1e8] + _0xffffff8000ebeae6: mov eax, dword ptr [rax + 4] + _0xffffff8000ebeae9: mov dword ptr [rbp - 0x1fc], eax + _0xffffff8000ebeaef: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebeaf5: lea edx, [rcx - 0x2055306f] + _0xffffff8000ebeafb: lea esi, [rcx - 0x7e84934] + _0xffffff8000ebeb01: lea edi, [rcx - 0x2055307b] + _0xffffff8000ebeb07: lea ecx, [rcx + 0x34508cde] + _0xffffff8000ebeb0d: test eax, eax + _0xffffff8000ebeb0f: mov rax, r14 + _0xffffff8000ebeb12: cmove rax, rbx + _0xffffff8000ebeb16: mov eax, dword ptr [rax] + _0xffffff8000ebeb18: cmove ecx, edi + _0xffffff8000ebeb1b: mov rdi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebeb22: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebeb29: mov dword ptr [r8], ecx + _0xffffff8000ebeb2c: cmovne edx, esi + _0xffffff8000ebeb2f: mov dword ptr [rdi], edx + _0xffffff8000ebeb31: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebeb37: jmp _0xffffff8000ebee14 + _0xffffff8000ebeb3c: mov eax, dword ptr [rbp - 0x23c] + _0xffffff8000ebeb42: mov rcx, rax + _0xffffff8000ebeb45: movabs rdx, 0x77ffcfd57eafad96 + _0xffffff8000ebeb4f: xor rcx, rdx + _0xffffff8000ebeb52: and rax, 0x16 + _0xffffff8000ebeb56: lea rax, [rcx + rax*2] + _0xffffff8000ebeb5a: movabs rcx, 0x8800302a8150526a + _0xffffff8000ebeb64: add rax, rcx + _0xffffff8000ebeb67: mov qword ptr [rbp - 0x78], rax + _0xffffff8000ebeb6b: mov eax, 0x40 + _0xffffff8000ebeb70: sub eax, dword ptr [rbp - 0x23c] + _0xffffff8000ebeb76: mov dword ptr [rbp - 0x238], eax + _0xffffff8000ebeb7c: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebeb82: lea edx, [rcx - 0x2055306e] + _0xffffff8000ebeb88: lea esi, [rcx + 0x22e1969b] + _0xffffff8000ebeb8e: lea edi, [rcx - 2] + _0xffffff8000ebeb91: lea ecx, [rcx - 0x2055307f] + _0xffffff8000ebeb97: cmp eax, dword ptr [rbp - 0x90] + _0xffffff8000ebeb9d: jmp _0xffffff8000ebfac9 + _0xffffff8000ebeba2: mov rax, qword ptr [rbp - 0xc0] + _0xffffff8000ebeba9: movabs rcx, 0x7b6af56be72effff + _0xffffff8000ebebb3: xor rcx, rax + _0xffffff8000ebebb6: lea rdx, [rax + rax] + _0xffffff8000ebebba: movabs rsi, 0x1ce5dfffe + _0xffffff8000ebebc4: and rsi, rdx + _0xffffff8000ebebc7: add rsi, rcx + _0xffffff8000ebebca: add rsi, qword ptr [rbp - 0x1d0] + _0xffffff8000ebebd1: movabs rcx, 0x84950a9418d10001 + _0xffffff8000ebebdb: mov cl, byte ptr [rcx + rsi] + _0xffffff8000ebebde: movabs rsi, 0x6effc5ebf5fef7f3 + _0xffffff8000ebebe8: xor rsi, rax + _0xffffff8000ebebeb: movabs rdi, 0x1ebfdefe6 + _0xffffff8000ebebf5: and rdi, rdx + _0xffffff8000ebebf8: add rdi, rsi + _0xffffff8000ebebfb: add rdi, qword ptr [rbp - 0x98] + _0xffffff8000ebec02: movabs rdx, 0x91003a140a010825 + _0xffffff8000ebec0c: mov byte ptr [rdx + rdi], cl + _0xffffff8000ebec0f: inc rax + _0xffffff8000ebec12: mov rcx, qword ptr [rbp - 0x130] + _0xffffff8000ebec19: mov qword ptr [rbp - 0xc0], rax + _0xffffff8000ebec20: mov edx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebec26: lea esi, [rdx + 0x57459369] + _0xffffff8000ebec2c: lea edi, [rdx + 0x2055307b] + _0xffffff8000ebec32: lea r8d, [rdx + 0x2fbae764] + _0xffffff8000ebec39: cmp rax, rcx + _0xffffff8000ebec3c: mov rax, r14 + _0xffffff8000ebec3f: cmove rax, rbx + _0xffffff8000ebec43: mov eax, dword ptr [rax] + _0xffffff8000ebec45: cmovne r8d, edi + _0xffffff8000ebec49: mov rcx, qword ptr [rbp - 0x3a0] + _0xffffff8000ebec50: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebec57: mov dword ptr [rdi], r8d + _0xffffff8000ebec5a: cmovne esi, edx + _0xffffff8000ebec5d: mov dword ptr [rcx], esi + _0xffffff8000ebec5f: jmp _0xffffff8000ebef17 + _0xffffff8000ebec64: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebec6b: add rax, 0x18 + _0xffffff8000ebec6f: mov rcx, qword ptr [rbp - 0xa8] + _0xffffff8000ebec76: mov qword ptr [rcx + 8], rax + _0xffffff8000ebec7a: mov rax, qword ptr [rbp - 0xa8] + _0xffffff8000ebec81: mov dword ptr [rax + 4], 0x10 + _0xffffff8000ebec88: mov rdi, qword ptr [rbp - 0xa8] + _0xffffff8000ebec8f: mov dword ptr [rdi], 0x13 + _0xffffff8000ebec95: call sub_0xffffff8000eb7d00 + _0xffffff8000ebec9a: mov rax, qword ptr [rbp - 0xb8] + _0xffffff8000ebeca1: mov rcx, qword ptr [rbp - 0x98] + _0xffffff8000ebeca8: mov qword ptr [rax + 8], rcx + _0xffffff8000ebecac: add rcx, 0x18 + _0xffffff8000ebecb0: mov rax, qword ptr [rbp - 0xb8] + _0xffffff8000ebecb7: mov qword ptr [rax + 0x10], rcx + _0xffffff8000ebecbb: mov rdi, qword ptr [rbp - 0xb8] + _0xffffff8000ebecc2: mov dword ptr [rbp - 0xec], 0 + _0xffffff8000ebeccc: mov dword ptr [rdi], 0x23 + _0xffffff8000ebecd2: call sub_0xffffff8000eb7d00 + _0xffffff8000ebecd7: mov eax, dword ptr [rbp - 0x238] + _0xffffff8000ebecdd: movabs rcx, 0x7677cf71acbeff3f + _0xffffff8000ebece7: xor rcx, rax + _0xffffff8000ebecea: mov rdx, rax + _0xffffff8000ebeced: and rdx, 0x3f + _0xffffff8000ebecf1: lea rcx, [rcx + rdx*2] + _0xffffff8000ebecf5: add rcx, qword ptr [rbp - 0x248] + _0xffffff8000ebecfc: movabs rdx, 0x8988308e534100c1 + _0xffffff8000ebed06: add rdx, rcx + _0xffffff8000ebed09: mov ecx, dword ptr [rbp - 0x90] + _0xffffff8000ebed0f: mov qword ptr [rbp - 0xd8], rdx + _0xffffff8000ebed16: sub ecx, eax + _0xffffff8000ebed18: mov dword ptr [rbp - 0xd0], ecx + _0xffffff8000ebed1e: mov dword ptr [rbp - 0x34], ecx + _0xffffff8000ebed21: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebed27: lea ecx, [rax + 0x2e0a4961] + _0xffffff8000ebed2d: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebed33: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebed3a: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebed41: mov dword ptr [rdi], ecx + _0xffffff8000ebed43: add eax, 0xe + _0xffffff8000ebed46: jmp _0xffffff8000ebe848 + _0xffffff8000ebed4b: nop dword ptr [rax + rax] + nop + _0xffffff8000ebed50: mov rax, qword ptr [rbp - 0xe0] + _0xffffff8000ebed57: movabs rcx, 0x5ffddaffffff5ecf + _0xffffff8000ebed61: xor rcx, rax + _0xffffff8000ebed64: lea rdx, [rip + 0x9c1] + _0xffffff8000ebed6b: mov rdx, qword ptr [rdx] + _0xffffff8000ebed6e: lea rdx, [rax + rax] + _0xffffff8000ebed72: movabs rsi, 0x1fffebd9e + _0xffffff8000ebed7c: and rsi, rdx + _0xffffff8000ebed7f: add rsi, rcx + _0xffffff8000ebed82: add rsi, qword ptr [rbp - 0x248] + _0xffffff8000ebed89: movabs rcx, 0xa00225000000a131 + _0xffffff8000ebed93: mov cl, byte ptr [rcx + rsi] + _0xffffff8000ebed96: movabs rsi, 0x35dcfe4477f71fff + _0xffffff8000ebeda0: xor rsi, rax + _0xffffff8000ebeda3: and edx, 0xefee3ffe + _0xffffff8000ebeda9: add rdx, rsi + _0xffffff8000ebedac: add rdx, qword ptr [rbp - 0x78] + _0xffffff8000ebedb0: add rdx, qword ptr [rbp - 0x98] + _0xffffff8000ebedb7: movabs rsi, 0xca2301bb8808e019 + _0xffffff8000ebedc1: mov byte ptr [rsi + rdx], cl + _0xffffff8000ebedc4: inc rax + _0xffffff8000ebedc7: mov rcx, qword ptr [rbp - 0x68] + _0xffffff8000ebedcb: mov qword ptr [rbp - 0xe0], rax + _0xffffff8000ebedd2: mov edx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebedd8: lea esi, [rdx + 0x3ad2aeb1] + _0xffffff8000ebedde: cmp rax, rcx + _0xffffff8000ebede1: cmovne esi, edx + _0xffffff8000ebede4: lea edi, [rdx - 1] + _0xffffff8000ebede7: lea edx, [rdx + 0x2e0a495f] + _0xffffff8000ebeded: cmp rax, rcx + _0xffffff8000ebedf0: mov rax, r14 + _0xffffff8000ebedf3: cmove rax, rbx + _0xffffff8000ebedf7: mov eax, dword ptr [rax] + _0xffffff8000ebedf9: cmove edi, edx + _0xffffff8000ebedfc: mov rcx, qword ptr [rbp - 0x3a0] + _0xffffff8000ebee03: mov rdx, qword ptr [rbp - 0x398] + _0xffffff8000ebee0a: mov dword ptr [rdx], edi + _0xffffff8000ebee0c: mov dword ptr [rcx], esi + _0xffffff8000ebee0e: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebee14: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebee1a: mov ecx, 0xdaadd114 + _0xffffff8000ebee1f: add eax, ecx + _0xffffff8000ebee21: cmp eax, 0x18 + _0xffffff8000ebee24: ja _0xffffff8000ebee14 + _0xffffff8000ebee26: jmp _0xffffff8000ec6610 + _0xffffff8000ebee2b: mov eax, dword ptr [rbp - 0x90] + _0xffffff8000ebee31: mov qword ptr [rbp - 0x70], rax + _0xffffff8000ebee35: mov qword ptr [rbp - 0xe8], 0 + _0xffffff8000ebee40: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebee46: lea ecx, [rax + 0x2055306c] + _0xffffff8000ebee4c: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebee52: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebee59: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebee60: mov dword ptr [rdi], ecx + _0xffffff8000ebee62: add eax, 0x20553078 + _0xffffff8000ebee67: jmp _0xffffff8000ebf608 + _0xffffff8000ebee6c: mov eax, dword ptr [rbp - 0x23c] + _0xffffff8000ebee72: mov qword ptr [rbp - 0x130], rax + _0xffffff8000ebee79: mov rcx, qword ptr [rbp - 0x1c8] + _0xffffff8000ebee80: add dword ptr [rcx], eax + _0xffffff8000ebee82: mov rax, qword ptr [rbp - 0x1d0] + _0xffffff8000ebee89: mov al, byte ptr [rax + 4] + _0xffffff8000ebee8c: mov dword ptr [rbp - 0x1fc], 0xffff586f + _0xffffff8000ebee96: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebee9c: lea edx, [rcx + 0xc] + _0xffffff8000ebee9f: lea esi, [rcx + 0x14c179d4] + _0xffffff8000ebeea5: lea edi, [rcx + 0x4a52f2ba] + _0xffffff8000ebeeab: lea ecx, [rcx + 0x2055307a] + _0xffffff8000ebeeb1: cmp al, 1 + _0xffffff8000ebeeb3: mov rax, r14 + _0xffffff8000ebeeb6: cmove rax, rbx + _0xffffff8000ebeeba: mov eax, dword ptr [rax] + _0xffffff8000ebeebc: cmovne ecx, edi + _0xffffff8000ebeebf: jmp _0xffffff8000ebef01 + _0xffffff8000ebeec1: mov eax, dword ptr [rbp - 0x30] + _0xffffff8000ebeec4: mov dword ptr [rbp - 0x1fc], eax + _0xffffff8000ebeeca: mov rcx, qword ptr [rbp - 0xc8] + _0xffffff8000ebeed1: mov qword ptr [rbp - 0x1d0], rcx + _0xffffff8000ebeed8: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebeede: lea edx, [rcx + 0x5647c469] + _0xffffff8000ebeee4: lea esi, [rcx + 0x20e6cd0d] + _0xffffff8000ebeeea: lea edi, [rcx + 0x3c48292b] + _0xffffff8000ebeef0: lea ecx, [rcx - 0xb] + _0xffffff8000ebeef3: test eax, eax + _0xffffff8000ebeef5: mov rax, r14 + _0xffffff8000ebeef8: cmove rax, rbx + _0xffffff8000ebeefc: mov eax, dword ptr [rax] + _0xffffff8000ebeefe: cmove ecx, edi + _0xffffff8000ebef01: mov rdi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebef08: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebef0f: mov dword ptr [r8], ecx + _0xffffff8000ebef12: cmovne edx, esi + _0xffffff8000ebef15: mov dword ptr [rdi], edx + _0xffffff8000ebef17: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebef1d: je _0xffffff8000ebea0e + _0xffffff8000ebef23: jmp _0xffffff8000ebee14 + _0xffffff8000ebef28: mov eax, dword ptr [rbp - 0x200] + _0xffffff8000ebef2e: mov ecx, 0x3e + _0xffffff8000ebef33: sub ecx, eax + _0xffffff8000ebef35: inc rcx + _0xffffff8000ebef38: mov edx, 0x3f + _0xffffff8000ebef3d: sub edx, eax + _0xffffff8000ebef3f: cmp edx, 1 + _0xffffff8000ebef42: mov eax, 1 + _0xffffff8000ebef47: cmova rax, rcx + _0xffffff8000ebef4b: mov qword ptr [rbp - 0x138], rax + _0xffffff8000ebef52: mov qword ptr [rbp - 0x70], 0 + _0xffffff8000ebef5a: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebef60: lea ecx, [rax + 0x2e0a4952] + _0xffffff8000ebef66: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebef6c: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebef73: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebef7a: mov dword ptr [rdi], ecx + _0xffffff8000ebef7c: add eax, -0xd + _0xffffff8000ebef7f: jmp _0xffffff8000ebf4bc + _0xffffff8000ebef84: mov rdi, qword ptr [rbp - 0x3b0] + _0xffffff8000ebef8b: mov rax, qword ptr [rdi + 8] + _0xffffff8000ebef8f: lea rcx, [rbp - 0x108] + _0xffffff8000ebef96: mov qword ptr [rbp - 0xb8], rcx + _0xffffff8000ebef9d: mov qword ptr [rbp - 0xb0], rcx + _0xffffff8000ebefa4: mov qword ptr [rbp - 0x50], rcx + _0xffffff8000ebefa8: mov qword ptr [rbp - 0x48], rcx + _0xffffff8000ebefac: mov qword ptr [rbp - 0x40], rcx + _0xffffff8000ebefb0: mov qword ptr [rbp - 0xa8], rcx + _0xffffff8000ebefb7: mov qword ptr [rbp - 0xa0], rcx + _0xffffff8000ebefbe: mov qword ptr [rbp - 0x1d8], rax + _0xffffff8000ebefc5: mov rcx, qword ptr [rax + 8] + _0xffffff8000ebefc9: mov qword ptr [rbp - 0x98], rcx + _0xffffff8000ebefd0: mov rax, qword ptr [rax + 0x10] + _0xffffff8000ebefd4: mov qword ptr [rbp - 0x1d0], rax + _0xffffff8000ebefdb: mov eax, dword ptr [rcx + 0x10] + _0xffffff8000ebefde: mov ecx, eax + _0xffffff8000ebefe0: shr ecx, 2 + _0xffffff8000ebefe3: and ecx, 0xe + _0xffffff8000ebefe6: shr eax, 3 + _0xffffff8000ebefe9: add eax, 0x27 + _0xffffff8000ebefec: sub eax, ecx + _0xffffff8000ebefee: and eax, 0x3f + _0xffffff8000ebeff1: mov dword ptr [rbp - 0x234], eax + _0xffffff8000ebeff7: xor eax, 0x27 + _0xffffff8000ebeffa: mov dword ptr [rbp - 0x200], eax + _0xffffff8000ebf000: mov rcx, rax + _0xffffff8000ebf003: movabs rdx, 0x260dff6fddc56f5f + _0xffffff8000ebf00d: xor rcx, rdx + _0xffffff8000ebf010: and rax, 0x1f + _0xffffff8000ebf014: lea rax, [rcx + rax*2] + _0xffffff8000ebf018: mov qword ptr [rbp - 0x58], rax + _0xffffff8000ebf01c: add rax, qword ptr [rbp - 0x98] + _0xffffff8000ebf023: movabs rcx, 0xd9f20090223a90b9 + _0xffffff8000ebf02d: mov byte ptr [rcx + rax], 0x80 + _0xffffff8000ebf031: mov eax, 0x3f + _0xffffff8000ebf036: sub eax, dword ptr [rbp - 0x200] + _0xffffff8000ebf03c: mov dword ptr [rbp - 0x1fc], eax + _0xffffff8000ebf042: cmp eax, 8 + _0xffffff8000ebf045: sbb al, al + _0xffffff8000ebf047: and al, 1 + _0xffffff8000ebf049: mov byte ptr [rbp - 0x1b1], al + _0xffffff8000ebf04f: mov qword ptr [rbp - 0x310], r15 + _0xffffff8000ebf056: mov qword ptr [rbp - 0x308], r15 + _0xffffff8000ebf05d: mov qword ptr [rbp - 0x300], r15 + _0xffffff8000ebf064: mov qword ptr [rbp - 0x388], r12 + _0xffffff8000ebf06b: mov qword ptr [rbp - 0x2f8], r12 + _0xffffff8000ebf072: mov qword ptr [rbp - 0x2f0], r12 + _0xffffff8000ebf079: lea rax, [rbp - 0x2e8] + _0xffffff8000ebf080: mov qword ptr [rbp - 0x1b0], rax + _0xffffff8000ebf087: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf08e: mov qword ptr [rbp - 0x2e8], rax + _0xffffff8000ebf095: lea rcx, [rax + 8] + _0xffffff8000ebf099: mov qword ptr [rbp - 0x1a8], rcx + _0xffffff8000ebf0a0: mov rax, qword ptr [rax + 8] + _0xffffff8000ebf0a4: mov rcx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebf0ab: mov qword ptr [rcx + 8], rax + _0xffffff8000ebf0af: mov qword ptr [rbp - 0x1a0], rax + _0xffffff8000ebf0b6: mov rcx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebf0bd: mov qword ptr [rax], rcx + _0xffffff8000ebf0c0: mov rax, qword ptr [rbp - 0x1a8] + _0xffffff8000ebf0c7: mov rcx, qword ptr [rbp - 0x1b0] + _0xffffff8000ebf0ce: mov qword ptr [rax], rcx + _0xffffff8000ebf0d1: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf0d8: mov qword ptr [rbp - 0x2d8], rax + _0xffffff8000ebf0df: mov rcx, qword ptr [rax + 8] + _0xffffff8000ebf0e3: mov qword ptr [rbp - 0x2d0], rcx + _0xffffff8000ebf0ea: lea rdx, [rbp - 0x2d8] + _0xffffff8000ebf0f1: mov qword ptr [rcx], rdx + _0xffffff8000ebf0f4: mov qword ptr [rax + 8], rdx + _0xffffff8000ebf0f8: lea rax, [rbp - 0x2c8] + _0xffffff8000ebf0ff: mov qword ptr [rbp - 0x198], rax + _0xffffff8000ebf106: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf10d: mov qword ptr [rbp - 0x2c8], rax + _0xffffff8000ebf114: lea rcx, [rax + 8] + _0xffffff8000ebf118: mov qword ptr [rbp - 0x190], rcx + _0xffffff8000ebf11f: mov rax, qword ptr [rax + 8] + _0xffffff8000ebf123: mov rcx, qword ptr [rbp - 0x198] + _0xffffff8000ebf12a: mov qword ptr [rcx + 8], rax + _0xffffff8000ebf12e: mov qword ptr [rbp - 0x188], rax + _0xffffff8000ebf135: mov rcx, qword ptr [rbp - 0x198] + _0xffffff8000ebf13c: mov qword ptr [rax], rcx + _0xffffff8000ebf13f: mov rax, qword ptr [rbp - 0x190] + _0xffffff8000ebf146: mov rcx, qword ptr [rbp - 0x198] + _0xffffff8000ebf14d: mov qword ptr [rax], rcx + _0xffffff8000ebf150: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf157: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ebf15e: mov rcx, qword ptr [rax + 8] + _0xffffff8000ebf162: mov qword ptr [rbp - 0x2b0], rcx + _0xffffff8000ebf169: lea rdx, [rbp - 0x2b8] + _0xffffff8000ebf170: mov qword ptr [rcx], rdx + _0xffffff8000ebf173: mov qword ptr [rax + 8], rdx + _0xffffff8000ebf177: lea rax, [rbp - 0x2a8] + _0xffffff8000ebf17e: mov qword ptr [rbp - 0x180], rax + _0xffffff8000ebf185: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf18c: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ebf193: lea rcx, [rax + 8] + _0xffffff8000ebf197: mov qword ptr [rbp - 0x178], rcx + _0xffffff8000ebf19e: mov rax, qword ptr [rax + 8] + _0xffffff8000ebf1a2: mov rcx, qword ptr [rbp - 0x180] + _0xffffff8000ebf1a9: mov qword ptr [rcx + 8], rax + _0xffffff8000ebf1ad: mov qword ptr [rbp - 0x170], rax + _0xffffff8000ebf1b4: mov rcx, qword ptr [rbp - 0x180] + _0xffffff8000ebf1bb: mov qword ptr [rax], rcx + _0xffffff8000ebf1be: mov rax, qword ptr [rbp - 0x178] + _0xffffff8000ebf1c5: mov rcx, qword ptr [rbp - 0x180] + _0xffffff8000ebf1cc: mov qword ptr [rax], rcx + _0xffffff8000ebf1cf: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf1d6: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ebf1dd: mov rcx, qword ptr [rax + 8] + _0xffffff8000ebf1e1: mov qword ptr [rbp - 0x290], rcx + _0xffffff8000ebf1e8: lea rdx, [rbp - 0x298] + _0xffffff8000ebf1ef: mov qword ptr [rcx], rdx + _0xffffff8000ebf1f2: mov qword ptr [rax + 8], rdx + _0xffffff8000ebf1f6: lea rax, [rbp - 0x288] + _0xffffff8000ebf1fd: mov qword ptr [rbp - 0x168], rax + _0xffffff8000ebf204: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf20b: mov qword ptr [rbp - 0x288], rax + _0xffffff8000ebf212: lea rcx, [rax + 8] + _0xffffff8000ebf216: mov qword ptr [rbp - 0x160], rcx + _0xffffff8000ebf21d: mov rax, qword ptr [rax + 8] + _0xffffff8000ebf221: mov rcx, qword ptr [rbp - 0x168] + _0xffffff8000ebf228: mov qword ptr [rcx + 8], rax + _0xffffff8000ebf22c: mov qword ptr [rbp - 0x158], rax + _0xffffff8000ebf233: mov rcx, qword ptr [rbp - 0x168] + _0xffffff8000ebf23a: mov qword ptr [rax], rcx + _0xffffff8000ebf23d: mov rax, qword ptr [rbp - 0x160] + _0xffffff8000ebf244: mov rcx, qword ptr [rbp - 0x168] + _0xffffff8000ebf24b: mov qword ptr [rax], rcx + _0xffffff8000ebf24e: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf255: mov qword ptr [rbp - 0x278], rax + _0xffffff8000ebf25c: mov rcx, qword ptr [rax + 8] + _0xffffff8000ebf260: mov qword ptr [rbp - 0x270], rcx + _0xffffff8000ebf267: lea rdx, [rbp - 0x278] + _0xffffff8000ebf26e: mov qword ptr [rcx], rdx + _0xffffff8000ebf271: mov qword ptr [rax + 8], rdx + _0xffffff8000ebf275: lea rax, [rbp - 0x268] + _0xffffff8000ebf27c: mov qword ptr [rbp - 0x150], rax + _0xffffff8000ebf283: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf28a: mov qword ptr [rbp - 0x268], rax + _0xffffff8000ebf291: lea rcx, [rax + 8] + _0xffffff8000ebf295: mov qword ptr [rbp - 0x148], rcx + _0xffffff8000ebf29c: mov rax, qword ptr [rax + 8] + _0xffffff8000ebf2a0: mov rcx, qword ptr [rbp - 0x150] + _0xffffff8000ebf2a7: mov qword ptr [rcx + 8], rax + _0xffffff8000ebf2ab: mov qword ptr [rbp - 0x140], rax + _0xffffff8000ebf2b2: mov rcx, qword ptr [rbp - 0x150] + _0xffffff8000ebf2b9: mov qword ptr [rax], rcx + _0xffffff8000ebf2bc: mov rax, qword ptr [rbp - 0x148] + _0xffffff8000ebf2c3: mov rcx, qword ptr [rbp - 0x150] + _0xffffff8000ebf2ca: mov qword ptr [rax], rcx + _0xffffff8000ebf2cd: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ebf2d4: mov qword ptr [rbp - 0x258], rax + _0xffffff8000ebf2db: mov rcx, qword ptr [rax + 8] + _0xffffff8000ebf2df: mov qword ptr [rbp - 0x250], rcx + _0xffffff8000ebf2e6: lea rdx, [rbp - 0x258] + _0xffffff8000ebf2ed: mov qword ptr [rcx], rdx + _0xffffff8000ebf2f0: mov qword ptr [rax + 8], rdx + _0xffffff8000ebf2f4: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf2fa: lea ecx, [rax - 0x20553070] + _0xffffff8000ebf300: lea edx, [rax - 0x2055307b] + _0xffffff8000ebf306: lea esi, [rax + 0xdb518e2] + _0xffffff8000ebf30c: lea eax, [rax - 0x20553072] + _0xffffff8000ebf312: jmp _0xffffff8000ec6a1f + _0xffffff8000ebf317: mov rax, qword ptr [rbp - 0xe8] + _0xffffff8000ebf31e: mov rcx, rax + _0xffffff8000ebf321: movabs rdx, 0x6b5ff3f7eacff6ff + _0xffffff8000ebf32b: xor rcx, rdx + _0xffffff8000ebf32e: lea rdx, [rax + rax] + _0xffffff8000ebf332: mov rsi, rdx + _0xffffff8000ebf335: movabs rdi, 0x1d59fedfe + _0xffffff8000ebf33f: and rsi, rdi + _0xffffff8000ebf342: add rsi, rcx + _0xffffff8000ebf345: add rsi, qword ptr [rbp - 0x78] + _0xffffff8000ebf349: add rsi, qword ptr [rbp - 0x98] + _0xffffff8000ebf350: mov rcx, rax + _0xffffff8000ebf353: lea rdi, [rip + 0x43f] + _0xffffff8000ebf35a: mov rdi, qword ptr [rdi] + _0xffffff8000ebf35d: movabs rdi, 0x1bff25516ffe5d57 + _0xffffff8000ebf367: xor rcx, rdi + _0xffffff8000ebf36a: and edx, 0xdffcbaae + _0xffffff8000ebf370: add rdx, rcx + _0xffffff8000ebf373: add rdx, qword ptr [rbp - 0x248] + _0xffffff8000ebf37a: movabs rcx, 0xe400daae9001a2a9 + _0xffffff8000ebf384: mov cl, byte ptr [rcx + rdx] + _0xffffff8000ebf387: movabs rdx, 0x94a00c0815300919 + _0xffffff8000ebf391: mov byte ptr [rdx + rsi], cl + _0xffffff8000ebf394: inc rax + _0xffffff8000ebf397: mov rcx, qword ptr [rbp - 0x70] + _0xffffff8000ebf39b: mov qword ptr [rbp - 0xe8], rax + _0xffffff8000ebf3a2: mov edx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf3a8: lea esi, [rdx - 0x42443150] + _0xffffff8000ebf3ae: cmp rax, rcx + _0xffffff8000ebf3b1: cmovne esi, edx + _0xffffff8000ebf3b4: lea edi, [rdx - 0xc] + _0xffffff8000ebf3b7: lea edx, [rdx - 0x1ab796f5] + _0xffffff8000ebf3bd: cmp rax, rcx + _0xffffff8000ebf3c0: mov rax, r14 + _0xffffff8000ebf3c3: cmove rax, rbx + _0xffffff8000ebf3c7: mov eax, dword ptr [rax] + _0xffffff8000ebf3c9: cmove edi, edx + _0xffffff8000ebf3cc: mov rcx, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf3d3: mov rdx, qword ptr [rbp - 0x398] + _0xffffff8000ebf3da: mov dword ptr [rdx], edi + _0xffffff8000ebf3dc: mov dword ptr [rcx], esi + _0xffffff8000ebf3de: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebf3e4: jmp _0xffffff8000ebea0e + _0xffffff8000ebf3e9: mov rax, qword ptr [rbp - 0x60] + _0xffffff8000ebf3ed: mov rcx, rax + _0xffffff8000ebf3f0: movabs rdx, 0x7fdfffdd7dfbe4fc + _0xffffff8000ebf3fa: xor rcx, rdx + _0xffffff8000ebf3fd: add rcx, qword ptr [rbp - 0x58] + _0xffffff8000ebf401: mov rdx, rax + _0xffffff8000ebf404: and rdx, 0x7dfbe4fc + _0xffffff8000ebf40b: lea rcx, [rcx + rdx*2] + _0xffffff8000ebf40f: add rcx, qword ptr [rbp - 0x98] + _0xffffff8000ebf416: movabs rdx, 0x5a1200b2a43eabbe + _0xffffff8000ebf420: mov byte ptr [rdx + rcx], 0 + _0xffffff8000ebf424: inc rax + _0xffffff8000ebf427: mov rcx, qword ptr [rbp - 0x130] + _0xffffff8000ebf42e: mov qword ptr [rbp - 0x60], rax + _0xffffff8000ebf432: mov edx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf438: lea esi, [rdx - 0xdb518e0] + _0xffffff8000ebf43e: lea edi, [rdx - 0x2e0a494e] + _0xffffff8000ebf444: lea r8d, [rdx + 8] + _0xffffff8000ebf448: cmp rax, rcx + _0xffffff8000ebf44b: mov rax, r14 + _0xffffff8000ebf44e: cmove rax, rbx + _0xffffff8000ebf452: mov eax, dword ptr [rax] + _0xffffff8000ebf454: cmovne r8d, edi + _0xffffff8000ebf458: mov rcx, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf45f: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebf466: mov dword ptr [rdi], r8d + _0xffffff8000ebf469: cmovne esi, edx + _0xffffff8000ebf46c: mov dword ptr [rcx], esi + _0xffffff8000ebf46e: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebf474: jne _0xffffff8000ebe6bb + _0xffffff8000ebf47a: jmp _0xffffff8000ebee14 + _0xffffff8000ebf47f: mov eax, dword ptr [rbp - 0x1fc] + _0xffffff8000ebf485: mov qword ptr [rbp - 0x130], rax + _0xffffff8000ebf48c: mov qword ptr [rbp - 0xc0], 0 + _0xffffff8000ebf497: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf49d: lea ecx, [rax + 0x20553072] + _0xffffff8000ebf4a3: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebf4a9: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf4b0: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebf4b7: mov dword ptr [rdi], ecx + _0xffffff8000ebf4b9: add eax, -9 + _0xffffff8000ebf4bc: mov dword ptr [rsi], eax + _0xffffff8000ebf4be: mov dword ptr [rbp - 0x3a4], edx + _0xffffff8000ebf4c4: jmp _0xffffff8000ebee14 + _0xffffff8000ebf4c9: mov rax, qword ptr [rbp - 0x138] + _0xffffff8000ebf4d0: movabs rcx, 0x8540c0080e000011 + _0xffffff8000ebf4da: lea rcx, [rax + rcx] + _0xffffff8000ebf4de: mov qword ptr [rbp - 0x118], rcx + _0xffffff8000ebf4e5: mov rcx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebf4ec: lea rax, [rax + rcx + 0x40] + _0xffffff8000ebf4f1: mov qword ptr [rbp - 0x110], rax + _0xffffff8000ebf4f8: mov qword ptr [rbp - 0x120], 0 + _0xffffff8000ebf503: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf509: lea ecx, [rax - 1] + _0xffffff8000ebf50c: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebf512: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf519: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebf520: mov dword ptr [rdi], ecx + _0xffffff8000ebf522: add eax, 0xdb518d9 + _0xffffff8000ebf527: jmp _0xffffff8000ebe848 + _0xffffff8000ebf52c: mov rax, qword ptr [rbp - 0xb0] + _0xffffff8000ebf533: mov rcx, qword ptr [rbp - 0x98] + _0xffffff8000ebf53a: mov qword ptr [rax + 8], rcx + _0xffffff8000ebf53e: mov rax, qword ptr [rbp - 0x1c8] + _0xffffff8000ebf545: mov rcx, qword ptr [rbp - 0xb0] + _0xffffff8000ebf54c: mov qword ptr [rcx + 0x10], rax + _0xffffff8000ebf550: mov rdi, qword ptr [rbp - 0xb0] + _0xffffff8000ebf557: mov dword ptr [rbp - 0x238], 0 + _0xffffff8000ebf561: mov dword ptr [rdi], 5 + _0xffffff8000ebf567: call sub_0xffffff8000eb7d00 + _0xffffff8000ebf56c: mov rax, qword ptr [rbp - 0x40] + _0xffffff8000ebf570: mov rcx, qword ptr [rbp - 0x98] + _0xffffff8000ebf577: mov qword ptr [rax + 8], rcx + _0xffffff8000ebf57b: mov rax, qword ptr [rbp - 0x40] + _0xffffff8000ebf57f: mov dword ptr [rax + 4], 4 + _0xffffff8000ebf586: mov rdi, qword ptr [rbp - 0x40] + _0xffffff8000ebf58a: mov dword ptr [rbp - 0x238], 0 + _0xffffff8000ebf594: mov dword ptr [rdi], 0x27 + _0xffffff8000ebf59a: call sub_0xffffff8000eb7d00 + _0xffffff8000ebf59f: mov qword ptr [rbp - 0xc0], 0 + _0xffffff8000ebf5aa: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf5b0: lea ecx, [rax - 0x20553078] + _0xffffff8000ebf5b6: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebf5bc: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf5c3: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebf5ca: mov dword ptr [rdi], ecx + _0xffffff8000ebf5cc: add eax, 0xdb518e8 + _0xffffff8000ebf5d1: jmp _0xffffff8000ebe848 + _0xffffff8000ebf5d6: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf5dc: lea ecx, [rax + 0xd] + _0xffffff8000ebf5df: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebf5e5: lea rsi, [rip] + _0xffffff8000ebf5ec: mov rsi, qword ptr [rsi + 0x1688] + _0xffffff8000ebf5f3: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf5fa: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebf601: mov dword ptr [rdi], ecx + _0xffffff8000ebf603: add eax, 0x2055307c + _0xffffff8000ebf608: mov dword ptr [rsi], eax + _0xffffff8000ebf60a: mov dword ptr [rbp - 0x3a4], edx + _0xffffff8000ebf610: jmp _0xffffff8000ebea0e + _0xffffff8000ebf615: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebf61c: add rax, 0x18 + _0xffffff8000ebf620: mov rcx, qword ptr [rbp - 0x50] + _0xffffff8000ebf624: mov qword ptr [rcx + 8], rax + _0xffffff8000ebf628: mov rax, qword ptr [rbp - 0x50] + _0xffffff8000ebf62c: mov dword ptr [rax + 4], 0x10 + _0xffffff8000ebf633: mov rdi, qword ptr [rbp - 0x50] + _0xffffff8000ebf637: mov dword ptr [rbp - 0x238], 0 + _0xffffff8000ebf641: mov dword ptr [rdi], 0x27 + _0xffffff8000ebf647: call sub_0xffffff8000eb7d00 + _0xffffff8000ebf64c: mov rax, qword ptr [rbp - 0xb8] + _0xffffff8000ebf653: mov rcx, qword ptr [rbp - 0x98] + _0xffffff8000ebf65a: mov qword ptr [rax + 8], rcx + _0xffffff8000ebf65e: add rcx, 0x18 + _0xffffff8000ebf662: mov rax, qword ptr [rbp - 0xb8] + _0xffffff8000ebf669: mov qword ptr [rax + 0x10], rcx + _0xffffff8000ebf66d: mov rdi, qword ptr [rbp - 0xb8] + _0xffffff8000ebf674: mov dword ptr [rbp - 0x238], 0 + _0xffffff8000ebf67e: mov dword ptr [rdi], 0x2d + _0xffffff8000ebf684: call sub_0xffffff8000eb7d00 + _0xffffff8000ebf689: mov qword ptr [rbp - 0x68], 0 + _0xffffff8000ebf691: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf697: lea ecx, [rax - 0x2e0a4954] + _0xffffff8000ebf69d: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebf6a3: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf6aa: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebf6b1: mov dword ptr [rdi], ecx + _0xffffff8000ebf6b3: add eax, 0xd1f5b6a6 + _0xffffff8000ebf6b8: jmp _0xffffff8000ebf4bc + _0xffffff8000ebf6bd: mov eax, dword ptr [rbp - 0x234] + _0xffffff8000ebf6c3: cmp rax, 0x2fc2e0a1 + _0xffffff8000ebf6c9: sbb rcx, rcx + _0xffffff8000ebf6cc: and ecx, 1 + _0xffffff8000ebf6cf: shl rcx, 0x20 + _0xffffff8000ebf6d3: add rcx, rax + _0xffffff8000ebf6d6: add rcx, -0x2fc2e0a1 + _0xffffff8000ebf6dd: mov qword ptr [rbp - 0x138], rcx + _0xffffff8000ebf6e4: add rcx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebf6eb: mov rax, qword ptr [rbp - 0x1c8] + _0xffffff8000ebf6f2: mov edx, dword ptr [rax] + _0xffffff8000ebf6f4: mov esi, dword ptr [rbp - 0x234] + _0xffffff8000ebf6fa: lea edx, [rdx + rsi - 0x2fc2e0a1] + _0xffffff8000ebf701: mov dword ptr [rax], edx + _0xffffff8000ebf703: mov rax, qword ptr [rbp - 0x1f0] + _0xffffff8000ebf70a: mov qword ptr [rax + 0x10], rcx + _0xffffff8000ebf70e: mov rax, qword ptr [rbp - 0x1d0] + _0xffffff8000ebf715: mov rcx, qword ptr [rbp - 0x1f0] + _0xffffff8000ebf71c: mov qword ptr [rcx + 0x28], rax + _0xffffff8000ebf720: lea rax, [rbp - 0x23c] + _0xffffff8000ebf727: mov rcx, qword ptr [rbp - 0x1f0] + _0xffffff8000ebf72e: mov qword ptr [rcx + 0x18], rax + _0xffffff8000ebf732: mov rax, qword ptr [rbp - 0x1c0] + _0xffffff8000ebf739: mov rcx, qword ptr [rbp - 0x1f0] + _0xffffff8000ebf740: mov qword ptr [rcx + 8], rax + _0xffffff8000ebf744: mov rdi, qword ptr [rbp - 0x1f0] + _0xffffff8000ebf74b: mov dword ptr [rbp - 0x200], 0 + _0xffffff8000ebf755: mov dword ptr [rdi], 3 + _0xffffff8000ebf75b: call sub_0xffffff8000eb7d00 + _0xffffff8000ebf760: mov rax, qword ptr [rbp - 0x1f0] + _0xffffff8000ebf767: mov eax, dword ptr [rax + 0x20] + _0xffffff8000ebf76a: mov dword ptr [rbp - 0x1fc], eax + _0xffffff8000ebf770: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf776: lea edx, [rcx + 0xf] + _0xffffff8000ebf779: lea esi, [rcx + 0x294f9b07] + _0xffffff8000ebf77f: lea edi, [rcx + 0x20553083] + _0xffffff8000ebf785: lea ecx, [rcx + 0x13b1329a] + _0xffffff8000ebf78b: jmp _0xffffff8000ebeb0d + _0xffffff8000ebf790: mov rax, qword ptr [rbp - 0x120] + _0xffffff8000ebf797: lea rcx, [rax + rax] + _0xffffff8000ebf79b: mov rdx, rcx + _0xffffff8000ebf79e: movabs rsi, 0x1e3ffffde + _0xffffff8000ebf7a8: and rdx, rsi + _0xffffff8000ebf7ab: mov rsi, rax + _0xffffff8000ebf7ae: movabs rdi, 0x7abf3ff7f1ffffef + _0xffffff8000ebf7b8: xor rsi, rdi + _0xffffff8000ebf7bb: add rsi, qword ptr [rbp - 0x118] + _0xffffff8000ebf7c2: add rsi, rdx + _0xffffff8000ebf7c5: mov rdx, qword ptr [rbp - 0x1d8] + _0xffffff8000ebf7cc: mov dl, byte ptr [rdx + rsi] + _0xffffff8000ebf7cf: mov rsi, rax + _0xffffff8000ebf7d2: movabs rdi, 0x6bf6f7e45f7fdfff + _0xffffff8000ebf7dc: xor rsi, rdi + _0xffffff8000ebf7df: and ecx, 0xbeffbffe + _0xffffff8000ebf7e5: add rcx, rsi + _0xffffff8000ebf7e8: add rcx, qword ptr [rbp - 0x98] + _0xffffff8000ebf7ef: movabs rsi, 0x9409081ba0802019 + _0xffffff8000ebf7f9: mov byte ptr [rsi + rcx], dl + _0xffffff8000ebf7fc: inc rax + _0xffffff8000ebf7ff: mov qword ptr [rbp - 0x120], rax + _0xffffff8000ebf806: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf80c: lea edx, [rcx - 0xdb518de] + _0xffffff8000ebf812: lea esi, [rcx - 0xdb518e7] + _0xffffff8000ebf818: lea edi, [rcx - 0xdb518da] + _0xffffff8000ebf81e: cmp rax, 0x40 + _0xffffff8000ebf822: mov rax, r14 + _0xffffff8000ebf825: cmove rax, rbx + _0xffffff8000ebf829: mov eax, dword ptr [rax] + _0xffffff8000ebf82b: cmove edi, esi + _0xffffff8000ebf82e: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf835: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebf83c: mov dword ptr [r8], edi + _0xffffff8000ebf83f: cmovne edx, ecx + _0xffffff8000ebf842: mov dword ptr [rsi], edx + _0xffffff8000ebf844: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebf84a: jne _0xffffff8000ebe6bb + _0xffffff8000ebf850: jmp _0xffffff8000ebea0e + _0xffffff8000ebf855: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebf85c: add rax, 0x18 + _0xffffff8000ebf860: mov rcx, qword ptr [rbp - 0xa0] + _0xffffff8000ebf867: mov qword ptr [rcx + 8], rax + _0xffffff8000ebf86b: mov rax, qword ptr [rbp - 0xa0] + _0xffffff8000ebf872: mov dword ptr [rax + 4], 0x10 + _0xffffff8000ebf879: mov rdi, qword ptr [rbp - 0xa0] + _0xffffff8000ebf880: mov dword ptr [rbp - 0xec], 0 + _0xffffff8000ebf88a: mov dword ptr [rdi], 0x13 + _0xffffff8000ebf890: call sub_0xffffff8000eb7d00 + _0xffffff8000ebf895: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebf89c: add rax, 0x18 + _0xffffff8000ebf8a0: mov qword ptr [rbp - 0x1c8], rax + _0xffffff8000ebf8a7: mov rax, qword ptr [rbp - 0x378] + _0xffffff8000ebf8ae: mov rcx, qword ptr [rax] + _0xffffff8000ebf8b1: mov rdx, qword ptr [rax + 8] + _0xffffff8000ebf8b5: mov qword ptr [rcx + 8], rdx + _0xffffff8000ebf8b9: mov qword ptr [rdx], rcx + _0xffffff8000ebf8bc: mov qword ptr [rbp - 0x378], rdx + _0xffffff8000ebf8c3: mov qword ptr [rax], rax + _0xffffff8000ebf8c6: mov qword ptr [rax + 8], rax + _0xffffff8000ebf8ca: mov qword ptr [rbp - 0x310], rax + _0xffffff8000ebf8d1: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf8d7: lea edx, [rcx - 0x2055307a] + _0xffffff8000ebf8dd: lea esi, [rcx + 1] + _0xffffff8000ebf8e0: lea edi, [rcx - 4] + _0xffffff8000ebf8e3: lea ecx, [rcx + 0xdb518da] + _0xffffff8000ebf8e9: mov r8, qword ptr [rax] + _0xffffff8000ebf8ec: cmp rax, qword ptr [r8 + 8] + _0xffffff8000ebf8f0: mov rax, r14 + _0xffffff8000ebf8f3: cmove rax, rbx + _0xffffff8000ebf8f7: mov eax, dword ptr [rax] + _0xffffff8000ebf8f9: cmove ecx, edi + _0xffffff8000ebf8fc: mov rdi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf903: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebf90a: mov dword ptr [r8], ecx + _0xffffff8000ebf90d: cmovne edx, esi + _0xffffff8000ebf910: mov dword ptr [rdi], edx + _0xffffff8000ebf912: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebf918: jmp _0xffffff8000ebea0e + _0xffffff8000ebf91d: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf923: lea ecx, [rax - 0x591274c] + _0xffffff8000ebf929: lea edx, [rax + 0xa477722] + _0xffffff8000ebf92f: lea esi, [rax - 0x3c1b47d9] + _0xffffff8000ebf935: lea eax, [rax - 0xdb518d2] + _0xffffff8000ebf93b: cmp dword ptr [rbp - 0x90], 0 + _0xffffff8000ebf942: jmp _0xffffff8000ebe6f7 + _0xffffff8000ebf947: mov rdi, qword ptr [rbp - 0x3b0] + _0xffffff8000ebf94e: mov eax, dword ptr [rdi] + _0xffffff8000ebf950: lea ecx, [rax + 2] + _0xffffff8000ebf953: mov edx, ecx + _0xffffff8000ebf955: sar edx, 0x1f + _0xffffff8000ebf958: shr edx, 0x1e + _0xffffff8000ebf95b: lea edx, [rax + rdx + 2] + _0xffffff8000ebf95f: and edx, 0xfffffffc + _0xffffff8000ebf962: neg edx + _0xffffff8000ebf964: lea eax, [rax + rdx + 2] + _0xffffff8000ebf968: cmp ecx, 4 + _0xffffff8000ebf96b: cmovl eax, ecx + _0xffffff8000ebf96e: mov dword ptr [rbp - 0x2c], eax + _0xffffff8000ebf971: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf977: lea ecx, [rax - 0x2e0a4957] + _0xffffff8000ebf97d: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebf983: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf98a: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebf991: mov dword ptr [rdi], ecx + _0xffffff8000ebf993: add eax, 0xd1f5b6a8 + _0xffffff8000ebf998: jmp _0xffffff8000ebf4bc + _0xffffff8000ebf99d: mov eax, dword ptr [rbp - 0x234] + _0xffffff8000ebf9a3: add eax, -0x40 + _0xffffff8000ebf9a6: mov qword ptr [rbp - 0x60], rax + _0xffffff8000ebf9aa: mov qword ptr [rbp - 0x128], 0 + _0xffffff8000ebf9b5: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf9bb: lea ecx, [rax + 1] + _0xffffff8000ebf9be: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebf9c4: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebf9cb: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebf9d2: mov dword ptr [rdi], ecx + _0xffffff8000ebf9d4: add eax, 0xf24ae728 + _0xffffff8000ebf9d9: jmp _0xffffff8000ebf608 + _0xffffff8000ebf9de: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebf9e4: lea ecx, [rax + 0xdb518de] + _0xffffff8000ebf9ea: lea edx, [rax + 0x28c539db] + _0xffffff8000ebf9f0: lea esi, [rax - 0x20553084] + _0xffffff8000ebf9f6: lea eax, [rax - 0xa] + _0xffffff8000ebf9f9: cmp dword ptr [rbp - 0x2c], 2 + _0xffffff8000ebf9fd: jmp _0xffffff8000ebe6f7 + _0xffffff8000ebfa02: mov rax, qword ptr [rbp - 0x68] + _0xffffff8000ebfa06: movabs rcx, 0x7d73bbf782bf7fb7 + _0xffffff8000ebfa10: xor rcx, rax + _0xffffff8000ebfa13: mov edx, eax + _0xffffff8000ebfa15: and edx, 0x82bf7fb7 + _0xffffff8000ebfa1b: lea rcx, [rcx + rdx*2] + _0xffffff8000ebfa1f: add rcx, qword ptr [rbp - 0x98] + _0xffffff8000ebfa26: movabs rdx, 0x828c44087d408061 + _0xffffff8000ebfa30: mov byte ptr [rdx + rcx], 0 + _0xffffff8000ebfa34: inc rax + _0xffffff8000ebfa37: mov qword ptr [rbp - 0x68], rax + _0xffffff8000ebfa3b: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebfa41: lea edx, [rcx + 0x20553074] + _0xffffff8000ebfa47: lea esi, [rcx + 0x2e0a495c] + _0xffffff8000ebfa4d: lea edi, [rcx + 6] + _0xffffff8000ebfa50: cmp rax, 0x38 + _0xffffff8000ebfa54: mov rax, r14 + _0xffffff8000ebfa57: cmove rax, rbx + _0xffffff8000ebfa5b: mov eax, dword ptr [rax] + _0xffffff8000ebfa5d: cmove edi, esi + _0xffffff8000ebfa60: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebfa67: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebfa6e: mov dword ptr [r8], edi + _0xffffff8000ebfa71: cmovne edx, ecx + _0xffffff8000ebfa74: mov dword ptr [rsi], edx + _0xffffff8000ebfa76: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebfa7c: jmp _0xffffff8000ebee14 + _0xffffff8000ebfa81: mov eax, dword ptr [rbp - 0x34] + _0xffffff8000ebfa84: mov rcx, qword ptr [rbp - 0xd8] + _0xffffff8000ebfa8b: mov qword ptr [rbp - 0x1d8], rcx + _0xffffff8000ebfa92: mov dword ptr [rbp - 0x234], eax + _0xffffff8000ebfa98: mov dword ptr [rbp - 0xcc], eax + _0xffffff8000ebfa9e: mov qword ptr [rbp - 0xc8], rcx + _0xffffff8000ebfaa5: mov dword ptr [rbp - 0x30], eax + _0xffffff8000ebfaa8: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ebfaae: lea edx, [rcx - 0x41df1e8d] + _0xffffff8000ebfab4: lea esi, [rcx - 0x2e0a4954] + _0xffffff8000ebfaba: lea edi, [rcx - 0xdb518dc] + _0xffffff8000ebfac0: lea ecx, [rcx - 0xdb518e2] + _0xffffff8000ebfac6: cmp eax, 0x3f + _0xffffff8000ebfac9: mov rax, r14 + _0xffffff8000ebfacc: cmova rax, rbx + _0xffffff8000ebfad0: mov eax, dword ptr [rax] + _0xffffff8000ebfad2: cmova ecx, edi + _0xffffff8000ebfad5: mov rdi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebfadc: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ebfae3: mov dword ptr [r8], ecx + _0xffffff8000ebfae6: cmovbe edx, esi + _0xffffff8000ebfae9: mov dword ptr [rdi], edx + _0xffffff8000ebfaeb: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ebfaf1: ja _0xffffff8000ebe6bb + _0xffffff8000ebfaf7: jmp _0xffffff8000ebee14 + _0xffffff8000ebfafc: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebfb03: mov dword ptr [rax], 0 + _0xffffff8000ebfb09: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebfb10: mov dword ptr [rax + 4], 0 + _0xffffff8000ebfb17: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebfb1e: mov dword ptr [rax + 8], 0 + _0xffffff8000ebfb25: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebfb2c: mov dword ptr [rax + 0xc], 0 + _0xffffff8000ebfb33: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebfb3a: mov dword ptr [rax + 0x10], 0 + _0xffffff8000ebfb41: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ebfb48: mov dword ptr [rax + 0x14], 0 + _0xffffff8000ebfb4f: mov qword ptr [rbp - 0x78], 0 + _0xffffff8000ebfb57: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebfb5d: lea ecx, [rax - 4] + _0xffffff8000ebfb60: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ebfb66: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ebfb6d: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ebfb74: mov dword ptr [rdi], ecx + _0xffffff8000ebfb76: add eax, 5 + _0xffffff8000ebfb79: jmp _0xffffff8000ebf608 + _0xffffff8000ebfb7e: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ebfb84: lea ecx, [rax - 0xdb518e7] + _0xffffff8000ebfb8a: lea edx, [rax + 0xd275c9b] + _0xffffff8000ebfb90: lea esi, [rax + 1] + _0xffffff8000ebfb93: lea eax, [rax - 7] + _0xffffff8000ebfb96: mov edi, dword ptr [rbp - 0x1fc] + _0xffffff8000ebfb9c: lea r8d, [rdi + rdi - 0x10] + _0xffffff8000ebfba1: and r8d, 0xedff7fbc + _0xffffff8000ebfba8: neg r8d + _0xffffff8000ebfbab: add edi, -8 + _0xffffff8000ebfbae: xor edi, 0x76ffbfde + _0xffffff8000ebfbb4: add edi, 0x89004022 + _0xffffff8000ebfbba: cmp edi, r8d + _0xffffff8000ebfbbd: jmp _0xffffff8000ec6b3e + _0xffffff8000ebfbc2: mov rbx, qword ptr [rbp - 0x1e0] + _0xffffff8000ebfbc9: mov eax, dword ptr [rbp - 0x1fc] + _0xffffff8000ebfbcf: mov dword ptr [rbx + 0x28], eax + _0xffffff8000ebfbd2: jmp _0xffffff8000ebfbfa + _0xffffff8000ebfbd4: mov rbx, qword ptr [rbp - 0x1c8] + _0xffffff8000ebfbdb: mov eax, dword ptr [rbx] + _0xffffff8000ebfbdd: mov ecx, dword ptr [rbp - 0x238] + _0xffffff8000ebfbe3: lea eax, [rax + rcx - 0x792bf512] + _0xffffff8000ebfbea: mov dword ptr [rbx], eax + _0xffffff8000ebfbec: mov rbx, qword ptr [rbp - 0x1e0] + _0xffffff8000ebfbf3: mov dword ptr [rbx + 0x28], 0 + _0xffffff8000ebfbfa: add rsp, 0x3d8 + _0xffffff8000ebfc01: pop rbx + _0xffffff8000ebfc02: pop r12 + _0xffffff8000ebfc04: pop r13 + _0xffffff8000ebfc06: pop r14 + _0xffffff8000ebfc08: pop r15 + _0xffffff8000ebfc0a: pop rbp + _0xffffff8000ebfc0b: ret + _0xffffff8000ebfc0c: mov rdi, qword ptr [rbp - 0x3b0] + _0xffffff8000ebfc13: mov rbx, qword ptr [rdi + 8] + _0xffffff8000ebfc17: mov qword ptr [rbp - 0x3f0], rbx + _0xffffff8000ebfc1e: mov rbx, qword ptr [rbx + 8] + _0xffffff8000ebfc22: mov qword ptr [rbp - 0x3e0], rbx + _0xffffff8000ebfc29: mov rbx, qword ptr [rbp - 0x3f0] + _0xffffff8000ebfc30: mov r14, qword ptr [rbx + 0x10] + _0xffffff8000ebfc34: mov rbx, qword ptr [rbp - 0x3e0] + _0xffffff8000ebfc3b: movzx eax, byte ptr [rbx + 3] + _0xffffff8000ebfc3f: mov ecx, eax + _0xffffff8000ebfc41: and ecx, 0x5f + _0xffffff8000ebfc44: xor eax, 0x4fbf525f + _0xffffff8000ebfc49: lea eax, [rax + rcx*2 - 0x4fbf525f] + _0xffffff8000ebfc50: movsxd r15, eax + _0xffffff8000ebfc53: movabs r12, 0x3f2bbfedbe7cfbf7 + _0xffffff8000ebfc5d: mov r13, r15 + _0xffffff8000ebfc60: and r13, r12 + _0xffffff8000ebfc63: xor r15, r12 + _0xffffff8000ebfc66: lea r15, [r15 + r13*2] + _0xffffff8000ebfc6a: add r15, qword ptr [r14 + 0x28] + _0xffffff8000ebfc6e: movabs r12, 0xc0d4401241830409 + _0xffffff8000ebfc78: mov al, byte ptr [r12 + r15] + _0xffffff8000ebfc7c: mov cl, al + _0xffffff8000ebfc7e: add cl, cl + _0xffffff8000ebfc80: xor al, 0xd + _0xffffff8000ebfc82: and cl, 0x1a + _0xffffff8000ebfc85: add cl, al + _0xffffff8000ebfc87: movzx r15d, cl + _0xffffff8000ebfc8b: lea r12, [r15 - 0xd] + _0xffffff8000ebfc8f: cmp r15b, 0xd + _0xffffff8000ebfc93: sbb r15, r15 + _0xffffff8000ebfc96: and r15, 0x100 + _0xffffff8000ebfc9d: mov r13, r12 + _0xffffff8000ebfca0: and r13, r15 + _0xffffff8000ebfca3: movabs rax, 0x25224912a2849514 + _0xffffff8000ebfcad: mov rcx, r12 + _0xffffff8000ebfcb0: and rcx, rax + _0xffffff8000ebfcb3: movabs rdx, 0xa44922545092a28 + _0xffffff8000ebfcbd: add rdx, r15 + _0xffffff8000ebfcc0: sub rdx, rcx + _0xffffff8000ebfcc3: and rdx, rax + _0xffffff8000ebfcc6: add r13, r13 + _0xffffff8000ebfcc9: add r13, rdx + _0xffffff8000ebfccc: movabs r15, 0x484924a51452484a + _0xffffff8000ebfcd6: mov rax, r12 + _0xffffff8000ebfcd9: and rax, r15 + _0xffffff8000ebfcdc: movabs rcx, 0x9092494a28a49094 + _0xffffff8000ebfce6: sub rcx, rax + _0xffffff8000ebfce9: and rcx, r15 + _0xffffff8000ebfcec: movabs r15, 0x92949248492922a1 + _0xffffff8000ebfcf6: and r12, r15 + _0xffffff8000ebfcf9: movabs rax, 0x2529249092524542 + _0xffffff8000ebfd03: sub rax, r12 + _0xffffff8000ebfd06: and rax, r15 + _0xffffff8000ebfd09: add rax, rcx + _0xffffff8000ebfd0c: lea r15, [rax + r13] + _0xffffff8000ebfd10: mov ecx, 1 + _0xffffff8000ebfd15: sub ecx, r15d + _0xffffff8000ebfd18: mov edx, ecx + _0xffffff8000ebfd1a: and edx, r15d + _0xffffff8000ebfd1d: xor ecx, r15d + _0xffffff8000ebfd20: lea ecx, [rcx + rdx*2] + _0xffffff8000ebfd23: and ecx, 0x3f + _0xffffff8000ebfd26: mov r12, 0xffffffff81ecd709 + _0xffffff8000ebfd2d: shl r12, cl + _0xffffff8000ebfd30: mov esi, 0x15 + _0xffffff8000ebfd35: shl rsi, cl + _0xffffff8000ebfd38: mov rdi, rsi + _0xffffff8000ebfd3b: sar rdi, 1 + _0xffffff8000ebfd3e: mov rdx, rdi + _0xffffff8000ebfd41: imul rdx, rdx + _0xffffff8000ebfd45: sub r12, rdx + _0xffffff8000ebfd48: lea r13, [rax + r13 + 0x7e1328f7] + _0xffffff8000ebfd50: movabs rdx, 0x8618618618618619 + _0xffffff8000ebfd5a: mov rax, r13 + _0xffffff8000ebfd5d: mul rdx + _0xffffff8000ebfd60: mov rax, r13 + _0xffffff8000ebfd63: sub rax, rdx + _0xffffff8000ebfd66: shr rax, 1 + _0xffffff8000ebfd69: add rax, rdx + _0xffffff8000ebfd6c: shr rax, 4 + _0xffffff8000ebfd70: imul rdx, rax, 0x15 + _0xffffff8000ebfd74: mov r8, rax + _0xffffff8000ebfd77: imul r8, r8 + _0xffffff8000ebfd7b: sub r12, r8 + _0xffffff8000ebfd7e: and rsi, 1 + _0xffffff8000ebfd82: neg rsi + _0xffffff8000ebfd85: and rsi, rax + _0xffffff8000ebfd88: add rsi, r12 + _0xffffff8000ebfd8b: sub r13, rdx + _0xffffff8000ebfd8e: shl r13, cl + _0xffffff8000ebfd91: add r13, rsi + _0xffffff8000ebfd94: add rax, rdi + _0xffffff8000ebfd97: imul rax, rax + _0xffffff8000ebfd9b: add rax, r13 + _0xffffff8000ebfd9e: movabs r12, 0x2a2820449509494 + _0xffffff8000ebfda8: and r12, rax + _0xffffff8000ebfdab: mov r13d, 0xe0240d6d + _0xffffff8000ebfdb1: xor r13, r15 + _0xffffff8000ebfdb4: movabs r15, 0x27f1f7ef9f8a72b2 + _0xffffff8000ebfdbe: add r15, r13 + _0xffffff8000ebfdc1: add r13, r13 + _0xffffff8000ebfdc4: movabs rcx, 0x4fe3efdf3f14e564 + _0xffffff8000ebfdce: and rcx, r13 + _0xffffff8000ebfdd1: sub r15, rcx + _0xffffff8000ebfdd4: movabs r13, 0x580e081080518021 + _0xffffff8000ebfdde: and r13, r15 + _0xffffff8000ebfde1: movabs rcx, 0xd80e081080518021 + _0xffffff8000ebfdeb: xor rcx, r15 + _0xffffff8000ebfdee: lea r15, [rcx + r13*2] + _0xffffff8000ebfdf2: movabs r13, 0x22a2922449509494 + _0xffffff8000ebfdfc: mov rcx, r15 + _0xffffff8000ebfdff: and rcx, r13 + _0xffffff8000ebfe02: movabs rdx, 0x545244892a12928 + _0xffffff8000ebfe0c: add rdx, rcx + _0xffffff8000ebfe0f: sub rdx, r12 + _0xffffff8000ebfe12: and rdx, r13 + _0xffffff8000ebfe15: and r12, r15 + _0xffffff8000ebfe18: add r12, r12 + _0xffffff8000ebfe1b: add r12, rdx + _0xffffff8000ebfe1e: movabs r13, 0x440454814082920 + _0xffffff8000ebfe28: and r13, rax + _0xffffff8000ebfe2b: movabs rcx, 0x14544549148a2921 + _0xffffff8000ebfe35: mov rdx, r15 + _0xffffff8000ebfe38: and rdx, rcx + _0xffffff8000ebfe3b: movabs rsi, 0x8a88a9229145242 + _0xffffff8000ebfe45: add rsi, rdx + _0xffffff8000ebfe48: sub rsi, r13 + _0xffffff8000ebfe4b: and rsi, rcx + _0xffffff8000ebfe4e: and r13, r15 + _0xffffff8000ebfe51: add r13, r13 + _0xffffff8000ebfe54: add r13, rsi + _0xffffff8000ebfe57: add r13, r12 + _0xffffff8000ebfe5a: movabs r12, 0x49012892a204420a + _0xffffff8000ebfe64: and r12, rax + _0xffffff8000ebfe67: mov rax, r12 + _0xffffff8000ebfe6a: and rax, r15 + _0xffffff8000ebfe6d: movabs rcx, 0x9092892a225424a + _0xffffff8000ebfe77: and rcx, r15 + _0xffffff8000ebfe7a: xor rcx, r12 + _0xffffff8000ebfe7d: add rax, rax + _0xffffff8000ebfe80: add rax, rcx + _0xffffff8000ebfe83: add rax, r13 + _0xffffff8000ebfe86: mov r15, qword ptr [r14 + 0x228] + _0xffffff8000ebfe8d: mov qword ptr [rbp - 0x3b8], r15 + _0xffffff8000ebfe94: mov ecx, 0xa0553d0f + _0xffffff8000ebfe99: mov eax, dword ptr [r15 + rax*4] + _0xffffff8000ebfe9d: xor eax, ecx + _0xffffff8000ebfe9f: movzx edx, byte ptr [rbx + 4] + _0xffffff8000ebfea3: mov esi, edx + _0xffffff8000ebfea5: and esi, 0xef + _0xffffff8000ebfeab: xor edx, 0x7efffdef + _0xffffff8000ebfeb1: lea edx, [rdx + rsi*2 - 0x7efffdef] + _0xffffff8000ebfeb8: movsxd r12, edx + _0xffffff8000ebfebb: movabs r13, 0x558dfd5bee73fdbe + _0xffffff8000ebfec5: mov rdx, r12 + _0xffffff8000ebfec8: and rdx, r13 + _0xffffff8000ebfecb: xor r12, r13 + _0xffffff8000ebfece: lea r12, [r12 + rdx*2] + _0xffffff8000ebfed2: add r12, qword ptr [r14 + 0x30] + _0xffffff8000ebfed6: movabs r13, 0xaa7202a4118c0242 + _0xffffff8000ebfee0: mov dl, byte ptr [r13 + r12] + _0xffffff8000ebfee5: mov sil, dl + _0xffffff8000ebfee8: add sil, sil + _0xffffff8000ebfeeb: xor dl, 0x7f + _0xffffff8000ebfeee: add dl, sil + _0xffffff8000ebfef1: movzx r12d, dl + _0xffffff8000ebfef5: mov r13, 0xffffffffd68eb4cd + _0xffffff8000ebfefc: sub r13, r12 + _0xffffff8000ebfeff: lea rdx, [r12 + 0x29714ab4] + _0xffffff8000ebff07: mov rsi, r13 + _0xffffff8000ebff0a: and rsi, rdx + _0xffffff8000ebff0d: xor r13, rdx + _0xffffff8000ebff10: lea r13, [r13 + rsi*2] + _0xffffff8000ebff15: mov rdx, r13 + _0xffffff8000ebff18: and rdx, r12 + _0xffffff8000ebff1b: xor r13, r12 + _0xffffff8000ebff1e: cmp r12b, 0x7f + _0xffffff8000ebff22: sbb r12, r12 + _0xffffff8000ebff25: and r12, 0x100 + _0xffffff8000ebff2c: add r12, r13 + _0xffffff8000ebff2f: lea r12, [r12 + rdx*2] + _0xffffff8000ebff33: lea r13, [r12 + r12] + _0xffffff8000ebff37: movabs rdx, 0x11ceee71b + _0xffffff8000ebff41: or rdx, r13 + _0xffffff8000ebff44: movabs rsi, 0xfffffffee31118e4 + _0xffffff8000ebff4e: xor rsi, rdx + _0xffffff8000ebff51: movabs rdx, 0xfffffffee31118e5 + _0xffffff8000ebff5b: or rdx, r13 + _0xffffff8000ebff5e: movabs rdi, 0x11ceee71a + _0xffffff8000ebff68: xor rdi, rdx + _0xffffff8000ebff6b: mov rdx, rdi + _0xffffff8000ebff6e: xor rdx, rsi + _0xffffff8000ebff71: mov r8, rdi + _0xffffff8000ebff74: shr r8, 1 + _0xffffff8000ebff77: mov r9, rsi + _0xffffff8000ebff7a: shr r9, 1 + _0xffffff8000ebff7d: add r9, r8 + _0xffffff8000ebff80: imul edi, esi + _0xffffff8000ebff83: and rdi, 1 + _0xffffff8000ebff87: add rdi, r9 + _0xffffff8000ebff8a: shr rdx, 1 + _0xffffff8000ebff8d: sub rdi, rdx + _0xffffff8000ebff90: movabs rdx, 0x1fdedf9fb7feafae + _0xffffff8000ebff9a: and rdi, rdx + _0xffffff8000ebff9d: xor rdi, rdx + _0xffffff8000ebffa0: movabs rdx, 0x3ffffffffffffffe + _0xffffff8000ebffaa: xor r12, rdx + _0xffffff8000ebffad: movabs rsi, 0x2021206048015052 + _0xffffff8000ebffb7: and rsi, r13 + _0xffffff8000ebffba: xor rsi, 2 + _0xffffff8000ebffbe: add rsi, r12 + _0xffffff8000ebffc1: add rsi, rdi + _0xffffff8000ebffc4: mov r12, qword ptr [r14 + 0x210] + _0xffffff8000ebffcb: mov qword ptr [rbp - 0x3c0], r12 + _0xffffff8000ebffd2: mov r13, qword ptr [r14 + 0x218] + _0xffffff8000ebffd9: mov qword ptr [rbp - 0x3d0], r13 + _0xffffff8000ebffe0: xor ecx, dword ptr [r12 + rsi*4] + _0xffffff8000ebffe4: lea esi, [rax + rcx] + _0xffffff8000ebffe7: and ecx, eax + _0xffffff8000ebffe9: add ecx, ecx + _0xffffff8000ebffeb: sub esi, ecx + _0xffffff8000ebffed: movzx eax, byte ptr [rbx + 9] + _0xffffff8000ebfff1: mov ecx, eax + _0xffffff8000ebfff3: xor ecx, 0x59cf8fff + _0xffffff8000ebfff9: lea eax, [rcx + rax*2 - 0x59cf8fff] + _0xffffff8000ec0000: movsxd rax, eax + _0xffffff8000ec0003: movabs rcx, 0x3ffeefddfe7fabf5 + _0xffffff8000ec000d: mov rdi, rax + _0xffffff8000ec0010: and rdi, rcx + _0xffffff8000ec0013: xor rax, rcx + _0xffffff8000ec0016: lea rax, [rax + rdi*2] + _0xffffff8000ec001a: add rax, qword ptr [r14 + 0x58] + _0xffffff8000ec001e: movabs rcx, 0xc00110220180540b + _0xffffff8000ec0028: mov al, byte ptr [rcx + rax] + _0xffffff8000ec002b: mov cl, al + _0xffffff8000ec002d: add cl, cl + _0xffffff8000ec002f: xor al, 0x1e + _0xffffff8000ec0031: and cl, 0x3c + _0xffffff8000ec0034: add cl, al + _0xffffff8000ec0036: add cl, 0xf4 + _0xffffff8000ec0039: movzx eax, cl + _0xffffff8000ec003c: mov rcx, rax + _0xffffff8000ec003f: and rcx, 0xc6 + _0xffffff8000ec0046: mov rdi, rax + _0xffffff8000ec0049: xor rdi, 0x3d00c6c6 + _0xffffff8000ec0050: lea rcx, [rdi + rcx*2] + _0xffffff8000ec0054: cmp al, 0x12 + _0xffffff8000ec0056: sbb rax, rax + _0xffffff8000ec0059: and rax, 0x100 + _0xffffff8000ec005f: mov rdi, rcx + _0xffffff8000ec0062: and rdi, rax + _0xffffff8000ec0065: xor rax, rcx + _0xffffff8000ec0068: lea rax, [rax + rdi*2] + _0xffffff8000ec006c: mov rcx, rax + _0xffffff8000ec006f: shl rcx, 0x21 + _0xffffff8000ec0073: movabs rdi, 0x85fe725000000000 + _0xffffff8000ec007d: and rdi, rcx + _0xffffff8000ec0080: shl rax, 0x20 + _0xffffff8000ec0084: movabs rcx, 0xc2ff392800000000 + _0xffffff8000ec008e: xor rcx, rax + _0xffffff8000ec0091: add rcx, rdi + _0xffffff8000ec0094: mov rax, rcx + _0xffffff8000ec0097: sar rax, 0x20 + _0xffffff8000ec009b: xor rax, 0x8a9a7bf + _0xffffff8000ec00a1: movabs rdi, 0x77ff1ffed64a50c4 + _0xffffff8000ec00ab: add rdi, rax + _0xffffff8000ec00ae: add rax, rax + _0xffffff8000ec00b1: movabs r8, 0xeffe3ffdac94a188 + _0xffffff8000ec00bb: and r8, rax + _0xffffff8000ec00be: sub rdi, r8 + _0xffffff8000ec00c1: sar rcx, 0x1f + _0xffffff8000ec00c5: movabs rax, 0xeffe3ffdbdc7eef6 + _0xffffff8000ec00cf: and rax, rcx + _0xffffff8000ec00d2: movabs rcx, 0x8800e001211c0885 + _0xffffff8000ec00dc: add rcx, rax + _0xffffff8000ec00df: mov rax, rdi + _0xffffff8000ec00e2: and rax, rcx + _0xffffff8000ec00e5: xor rcx, rdi + _0xffffff8000ec00e8: lea rax, [rcx + rax*2] + _0xffffff8000ec00ec: xor esi, dword ptr [r13 + rax*4] + _0xffffff8000ec00f1: movzx eax, byte ptr [rbx + 0xe] + _0xffffff8000ec00f5: mov ecx, eax + _0xffffff8000ec00f7: and ecx, 0x7b + _0xffffff8000ec00fa: xor eax, 0x5f767f7b + _0xffffff8000ec00ff: lea eax, [rax + rcx*2 - 0x5f767f7b] + _0xffffff8000ec0106: movsxd rax, eax + _0xffffff8000ec0109: movabs rcx, 0x3defcbfedfffd2ff + _0xffffff8000ec0113: mov rdi, rax + _0xffffff8000ec0116: and rdi, rcx + _0xffffff8000ec0119: xor rax, rcx + _0xffffff8000ec011c: lea rax, [rax + rdi*2] + _0xffffff8000ec0120: add rax, qword ptr [r14 + 0x80] + _0xffffff8000ec0127: movabs rcx, 0xc210340120002d01 + _0xffffff8000ec0131: mov al, byte ptr [rcx + rax] + _0xffffff8000ec0134: mov cl, al + _0xffffff8000ec0136: add cl, cl + _0xffffff8000ec0138: xor al, 0x13 + _0xffffff8000ec013a: and cl, 0x26 + _0xffffff8000ec013d: add cl, al + _0xffffff8000ec013f: add cl, 0xfe + _0xffffff8000ec0142: movzx eax, cl + _0xffffff8000ec0145: mov ecx, eax + _0xffffff8000ec0147: xor ecx, 0x4ff09cc4 + _0xffffff8000ec014d: movzx edi, al + _0xffffff8000ec0150: add rdi, rdi + _0xffffff8000ec0153: and edi, 0x188 + _0xffffff8000ec0159: add edi, ecx + _0xffffff8000ec015b: cmp al, 0x11 + _0xffffff8000ec015d: sbb rax, rax + _0xffffff8000ec0160: and eax, 0x100 + _0xffffff8000ec0165: mov ecx, edi + _0xffffff8000ec0167: and ecx, eax + _0xffffff8000ec0169: xor eax, edi + _0xffffff8000ec016b: lea eax, [rax + rcx*2] + _0xffffff8000ec016e: shl rax, 0x20 + _0xffffff8000ec0172: movabs rcx, 0x7ae5fe4400000000 + _0xffffff8000ec017c: and rcx, rax + _0xffffff8000ec017f: movabs rdi, 0x851a01bb00000000 + _0xffffff8000ec0189: and rdi, rax + _0xffffff8000ec018c: lea rax, [rdi + rcx + 0x4496eb22] + _0xffffff8000ec0194: movabs rcx, 0xb00f632b4496eb22 + _0xffffff8000ec019e: sub rcx, rax + _0xffffff8000ec01a1: lea rcx, [rcx + rax - 0x4496eb22] + _0xffffff8000ec01a9: add rax, -0x4496eb22 + _0xffffff8000ec01af: mov rdi, rcx + _0xffffff8000ec01b2: and rdi, rax + _0xffffff8000ec01b5: xor rax, rcx + _0xffffff8000ec01b8: lea rax, [rax + rdi*2] + _0xffffff8000ec01bc: mov rcx, rax + _0xffffff8000ec01bf: sar rcx, 0x1e + _0xffffff8000ec01c3: movabs rdi, 0x441288088410020 + _0xffffff8000ec01cd: and rdi, rcx + _0xffffff8000ec01d0: mov rcx, rax + _0xffffff8000ec01d3: sar rcx, 0x1f + _0xffffff8000ec01d7: movabs r8, 0xf3bc977f77bcffde + _0xffffff8000ec01e1: and r8, rcx + _0xffffff8000ec01e4: movabs rcx, 0x8621b44044218011 + _0xffffff8000ec01ee: xor rcx, r8 + _0xffffff8000ec01f1: lea rcx, [rdi + rcx + 0x5ac799e2] + _0xffffff8000ec01f9: movabs rdi, 0x4922914922491511 + _0xffffff8000ec0203: and rdi, rcx + _0xffffff8000ec0206: sar rax, 0x20 + _0xffffff8000ec020a: movabs r8, 0x79de4bbfbbde7fef + _0xffffff8000ec0214: xor r8, rax + _0xffffff8000ec0217: add r8, -0x5ac799e2 + _0xffffff8000ec021e: movabs rax, 0x922914922491511 + _0xffffff8000ec0228: mov r9, r8 + _0xffffff8000ec022b: and r9, rax + _0xffffff8000ec022e: movabs r10, 0x245229244922a22 + _0xffffff8000ec0238: add r10, r9 + _0xffffff8000ec023b: sub r10, rdi + _0xffffff8000ec023e: and r10, rax + _0xffffff8000ec0241: and rdi, r8 + _0xffffff8000ec0244: add rdi, rdi + _0xffffff8000ec0247: add rdi, r10 + _0xffffff8000ec024a: movabs rax, 0x124924949492424a + _0xffffff8000ec0254: mov r9, rcx + _0xffffff8000ec0257: and r9, rax + _0xffffff8000ec025a: movabs r10, 0x492492929248494 + _0xffffff8000ec0264: add r10, r9 + _0xffffff8000ec0267: movabs r9, 0x924924949492424a + _0xffffff8000ec0271: and r9, r8 + _0xffffff8000ec0274: sub r10, r9 + _0xffffff8000ec0277: and r10, rax + _0xffffff8000ec027a: and r9, rcx + _0xffffff8000ec027d: add r9, r9 + _0xffffff8000ec0280: add r9, r10 + _0xffffff8000ec0283: add r9, rdi + _0xffffff8000ec0286: movabs rax, 0x24944a224924a8a4 + _0xffffff8000ec0290: mov rdi, rcx + _0xffffff8000ec0293: and rdi, rax + _0xffffff8000ec0296: movabs r10, 0x928944492495148 + _0xffffff8000ec02a0: add r10, rdi + _0xffffff8000ec02a3: and r8, rax + _0xffffff8000ec02a6: sub r10, r8 + _0xffffff8000ec02a9: and r10, rax + _0xffffff8000ec02ac: and r8, rcx + _0xffffff8000ec02af: add r8, r8 + _0xffffff8000ec02b2: add r8, r10 + _0xffffff8000ec02b5: add r8, r9 + _0xffffff8000ec02b8: mov rax, qword ptr [r14 + 0x220] + _0xffffff8000ec02bf: mov qword ptr [rbp - 0x3d8], rax + _0xffffff8000ec02c6: xor esi, dword ptr [rax + r8*4] + _0xffffff8000ec02ca: mov edi, 0xf + _0xffffff8000ec02cf: sub edi, esi + _0xffffff8000ec02d1: mov ecx, edi + _0xffffff8000ec02d3: xor ecx, esi + _0xffffff8000ec02d5: mov r8d, esi + _0xffffff8000ec02d8: and r8d, edi + _0xffffff8000ec02db: mov r9d, edi + _0xffffff8000ec02de: and r9d, 0x4a52484a + _0xffffff8000ec02e5: mov r10d, esi + _0xffffff8000ec02e8: and r10d, 0xa + _0xffffff8000ec02ec: add r10d, 4 + _0xffffff8000ec02f0: sub r10d, r9d + _0xffffff8000ec02f3: and r9d, esi + _0xffffff8000ec02f6: add r9d, r9d + _0xffffff8000ec02f9: and r10d, 0xa + _0xffffff8000ec02fd: add r10d, r9d + _0xffffff8000ec0300: and ecx, 1 + _0xffffff8000ec0303: and r8d, 1 + _0xffffff8000ec0307: add r8d, r8d + _0xffffff8000ec030a: add r8d, ecx + _0xffffff8000ec030d: and edi, 0x14 + _0xffffff8000ec0310: mov ecx, esi + _0xffffff8000ec0312: and ecx, 0x14 + _0xffffff8000ec0315: add ecx, edi + _0xffffff8000ec0317: add ecx, r8d + _0xffffff8000ec031a: add ecx, r10d + _0xffffff8000ec031d: and ecx, 0x1f + _0xffffff8000ec0320: mov edi, esi + _0xffffff8000ec0322: and edi, 0xe1c3a698 + _0xffffff8000ec0328: shr edi, cl + _0xffffff8000ec032a: xor edi, 0x9c10d56 + _0xffffff8000ec0330: mov r8d, esi + _0xffffff8000ec0333: and r8d, 0x1e3c5967 + _0xffffff8000ec033a: shr r8d, cl + _0xffffff8000ec033d: xor r8d, 0x9c10d56 + _0xffffff8000ec0344: lea ecx, [r8 + rdi] + _0xffffff8000ec0348: and r8d, edi + _0xffffff8000ec034b: add r8d, r8d + _0xffffff8000ec034e: sub ecx, r8d + _0xffffff8000ec0351: and ecx, 0xec + _0xffffff8000ec0357: neg ecx + _0xffffff8000ec0359: mov edi, esi + _0xffffff8000ec035b: shr edi, 0xf + _0xffffff8000ec035e: and edi, 0xec + _0xffffff8000ec0364: mov r8d, esi + _0xffffff8000ec0367: shr r8d, 0x10 + _0xffffff8000ec036b: xor r8d, 0x76 + _0xffffff8000ec036f: add r8d, edi + _0xffffff8000ec0372: mov edi, r8d + _0xffffff8000ec0375: and edi, ecx + _0xffffff8000ec0377: xor r8d, ecx + _0xffffff8000ec037a: lea ecx, [r8 + rdi*2] + _0xffffff8000ec037e: xor ecx, 0xb0b29ad6 + _0xffffff8000ec0384: lea edi, [rcx + rcx] + _0xffffff8000ec0387: and edi, 0x40 + _0xffffff8000ec038a: neg edi + _0xffffff8000ec038c: lea ecx, [rcx + rdi + 0xa0] + _0xffffff8000ec0393: mov dil, cl + _0xffffff8000ec0396: add dil, 0x7a + _0xffffff8000ec039a: mov al, dil + _0xffffff8000ec039d: and al, 3 + _0xffffff8000ec039f: mov r8b, 0xa2 + _0xffffff8000ec03a2: mul r8b + _0xffffff8000ec03a5: mov r8b, al + _0xffffff8000ec03a8: sar dil, 2 + _0xffffff8000ec03ac: mov al, dil + _0xffffff8000ec03af: add al, 0x5e + _0xffffff8000ec03b1: mul al + _0xffffff8000ec03b3: sub r8b, al + _0xffffff8000ec03b6: mov al, dil + _0xffffff8000ec03b9: add al, 0xa2 + _0xffffff8000ec03bb: mul al + _0xffffff8000ec03bd: add al, r8b + _0xffffff8000ec03c0: mov dil, 0x31 + _0xffffff8000ec03c3: mul dil + _0xffffff8000ec03c6: mov dil, 0x1a + _0xffffff8000ec03c9: sub dil, al + _0xffffff8000ec03cc: add al, 0xc + _0xffffff8000ec03ce: mov r8b, al + _0xffffff8000ec03d1: xor r8b, dil + _0xffffff8000ec03d4: and dil, al + _0xffffff8000ec03d7: add dil, dil + _0xffffff8000ec03da: add dil, r8b + _0xffffff8000ec03dd: and dil, al + _0xffffff8000ec03e0: xor cl, 0x64 + _0xffffff8000ec03e3: mov r8b, cl + _0xffffff8000ec03e6: add r8b, r8b + _0xffffff8000ec03e9: add cl, 0x77 + _0xffffff8000ec03ec: and r8b, 0xee + _0xffffff8000ec03f0: sub cl, r8b + _0xffffff8000ec03f3: mov r8b, cl + _0xffffff8000ec03f6: add r8b, r8b + _0xffffff8000ec03f9: xor cl, 0xfe + _0xffffff8000ec03fc: and r8b, 0xfc + _0xffffff8000ec0400: add r8b, cl + _0xffffff8000ec0403: mov cl, dil + _0xffffff8000ec0406: xor cl, r8b + _0xffffff8000ec0409: and r8b, dil + _0xffffff8000ec040c: add r8b, r8b + _0xffffff8000ec040f: add r8b, cl + _0xffffff8000ec0412: movzx ecx, r8b + _0xffffff8000ec0416: cmp cl, 0x11 + _0xffffff8000ec0419: sbb rdi, rdi + _0xffffff8000ec041c: and edi, 0x100 + _0xffffff8000ec0422: add edi, ecx + _0xffffff8000ec0424: add edi, 0x56027765 + _0xffffff8000ec042a: shl rdi, 0x20 + _0xffffff8000ec042e: movabs rcx, 0xa9fd888a00000000 + _0xffffff8000ec0438: add rcx, rdi + _0xffffff8000ec043b: mov rdi, rcx + _0xffffff8000ec043e: sar rdi, 0x1f + _0xffffff8000ec0442: movabs r8, 0xb3cffb5f7b95ebfc + _0xffffff8000ec044c: and r8, rdi + _0xffffff8000ec044f: sar rcx, 0x20 + _0xffffff8000ec0453: movabs rdi, 0x59e7fdafbdcaf5fe + _0xffffff8000ec045d: xor rdi, rcx + _0xffffff8000ec0460: add rdi, r8 + _0xffffff8000ec0463: add rdi, qword ptr [r14 + 0xc0] + _0xffffff8000ec046a: movabs rcx, 0xa618025042350a02 + _0xffffff8000ec0474: mov cl, byte ptr [rcx + rdi] + _0xffffff8000ec0477: mov dil, cl + _0xffffff8000ec047a: add dil, dil + _0xffffff8000ec047d: xor cl, 0x3c + _0xffffff8000ec0480: and dil, 0x78 + _0xffffff8000ec0484: add dil, cl + _0xffffff8000ec0487: add dil, 0xfc + _0xffffff8000ec048b: movzx ecx, dil + _0xffffff8000ec048f: cmp cl, 0x38 + _0xffffff8000ec0492: sbb rdi, rdi + _0xffffff8000ec0495: and edi, 0x100 + _0xffffff8000ec049b: add edi, ecx + _0xffffff8000ec049d: add edi, 0x487c2840 + _0xffffff8000ec04a3: shl rdi, 0x20 + _0xffffff8000ec04a7: movabs rcx, 0xb783d78800000000 + _0xffffff8000ec04b1: add rcx, rdi + _0xffffff8000ec04b4: mov rdi, rcx + _0xffffff8000ec04b7: sar rdi, 0x1f + _0xffffff8000ec04bb: movabs r8, 0x3bbff3ceae7df3fa + _0xffffff8000ec04c5: and r8, rdi + _0xffffff8000ec04c8: sar rcx, 0x20 + _0xffffff8000ec04cc: movabs rdi, 0x3ddff9e7573ef9fd + _0xffffff8000ec04d6: xor rdi, rcx + _0xffffff8000ec04d9: add rdi, r8 + _0xffffff8000ec04dc: mov rax, qword ptr [rbp - 0x3d8] + _0xffffff8000ec04e3: lea rcx, [rax + rdi*4] + _0xffffff8000ec04e7: movzx edi, byte ptr [rbx + 8] + _0xffffff8000ec04eb: mov r8d, edi + _0xffffff8000ec04ee: and r8d, 0xdf + _0xffffff8000ec04f5: xor edi, 0x7fdb7fdf + _0xffffff8000ec04fb: lea edi, [rdi + r8*2 - 0x7fdb7fdf] + _0xffffff8000ec0503: movsxd rdi, edi + _0xffffff8000ec0506: movabs r8, 0x56fae2f9e320b6fd + _0xffffff8000ec0510: mov r9, rdi + _0xffffff8000ec0513: and r9, r8 + _0xffffff8000ec0516: xor rdi, r8 + _0xffffff8000ec0519: lea rdi, [rdi + r9*2] + _0xffffff8000ec051d: add rdi, qword ptr [r14 + 0x50] + _0xffffff8000ec0521: movabs r8, 0xa9051d061cdf4903 + _0xffffff8000ec052b: mov dil, byte ptr [r8 + rdi] + _0xffffff8000ec052f: mov r8b, dil + _0xffffff8000ec0532: add r8b, r8b + _0xffffff8000ec0535: xor dil, 0x4d + _0xffffff8000ec0539: and r8b, 0x9a + _0xffffff8000ec053d: add r8b, dil + _0xffffff8000ec0540: movzx edi, r8b + _0xffffff8000ec0544: cmp dil, 0x4d + _0xffffff8000ec0548: sbb r8, r8 + _0xffffff8000ec054b: and r8d, 0x100 + _0xffffff8000ec0552: add r8d, edi + _0xffffff8000ec0555: add r8d, 0x11a33b06 + _0xffffff8000ec055c: shl r8, 0x20 + _0xffffff8000ec0560: movabs rdi, 0xee5cc4ad00000000 + _0xffffff8000ec056a: add rdi, r8 + _0xffffff8000ec056d: mov r8, rdi + _0xffffff8000ec0570: sar r8, 0x1f + _0xffffff8000ec0574: movabs r9, 0xbffb3ddb6b62c7e + _0xffffff8000ec057e: and r9, r8 + _0xffffff8000ec0581: sar rdi, 0x20 + _0xffffff8000ec0585: movabs r8, 0x25ffd9eedb5b163f + _0xffffff8000ec058f: xor r8, rdi + _0xffffff8000ec0592: add r8, r9 + _0xffffff8000ec0595: lea rdi, [r12 + r8*4] + _0xffffff8000ec0599: movzx r8d, byte ptr [rbx + 0xd] + _0xffffff8000ec059e: mov r9d, r8d + _0xffffff8000ec05a1: and r9d, 0xbf + _0xffffff8000ec05a8: xor r8d, 0x5efbfbbf + _0xffffff8000ec05af: lea r8d, [r8 + r9*2 - 0x5efbfbbf] + _0xffffff8000ec05b7: movsxd r8, r8d + _0xffffff8000ec05ba: movabs r9, 0x6ceed34bb7fbe36a + _0xffffff8000ec05c4: mov r10, r8 + _0xffffff8000ec05c7: and r10, r9 + _0xffffff8000ec05ca: xor r8, r9 + _0xffffff8000ec05cd: lea r8, [r8 + r10*2] + _0xffffff8000ec05d1: add r8, qword ptr [r14 + 0x78] + _0xffffff8000ec05d5: movabs r9, 0x93112cb448041c96 + _0xffffff8000ec05df: mov r8b, byte ptr [r9 + r8] + _0xffffff8000ec05e3: mov r9b, r8b + _0xffffff8000ec05e6: add r9b, r9b + _0xffffff8000ec05e9: xor r8b, 0x7f + _0xffffff8000ec05ed: add r8b, r9b + _0xffffff8000ec05f0: add r8b, 0xaf + _0xffffff8000ec05f4: movzx r8d, r8b + _0xffffff8000ec05f8: cmp r8b, 0x2e + _0xffffff8000ec05fc: sbb r9, r9 + _0xffffff8000ec05ff: and r9d, 0x100 + _0xffffff8000ec0606: add r9d, r8d + _0xffffff8000ec0609: add r9d, 0x305c7794 + _0xffffff8000ec0610: shl r9, 0x20 + _0xffffff8000ec0614: movabs r8, 0xcfa3883e00000000 + _0xffffff8000ec061e: add r8, r9 + _0xffffff8000ec0621: mov r9, r8 + _0xffffff8000ec0624: sar r9, 0x1f + _0xffffff8000ec0628: movabs r10, 0x3daeb57bfebdd32c + _0xffffff8000ec0632: and r10, r9 + _0xffffff8000ec0635: sar r8, 0x20 + _0xffffff8000ec0639: movabs r9, 0x3ed75abdff5ee996 + _0xffffff8000ec0643: xor r9, r8 + _0xffffff8000ec0646: add r9, r10 + _0xffffff8000ec0649: lea r8, [r13 + r9*4] + _0xffffff8000ec064e: movabs r9, 0x4a29508028459a8 + _0xffffff8000ec0658: mov r8d, dword ptr [r9 + r8] + _0xffffff8000ec065c: movabs r9, 0x680098449293a704 + _0xffffff8000ec0666: xor r8d, dword ptr [r9 + rdi] + _0xffffff8000ec066a: movzx edi, byte ptr [rbx + 2] + _0xffffff8000ec066e: mov r9d, edi + _0xffffff8000ec0671: and r9d, 0xee + _0xffffff8000ec0678: xor edi, 0x3f1eebee + _0xffffff8000ec067e: lea edi, [rdi + r9*2 - 0x3f1eebee] + _0xffffff8000ec0686: movsxd rdi, edi + _0xffffff8000ec0689: movabs r9, 0x1d5abfb3f4ffffff + _0xffffff8000ec0693: mov r10, rdi + _0xffffff8000ec0696: and r10, r9 + _0xffffff8000ec0699: xor rdi, r9 + _0xffffff8000ec069c: lea rdi, [rdi + r10*2] + _0xffffff8000ec06a0: add rdi, qword ptr [r14 + 0x20] + _0xffffff8000ec06a4: movabs r9, 0xe2a5404c0b000001 + _0xffffff8000ec06ae: mov dil, byte ptr [r9 + rdi] + _0xffffff8000ec06b2: mov r9b, dil + _0xffffff8000ec06b5: add r9b, r9b + _0xffffff8000ec06b8: xor dil, 0x69 + _0xffffff8000ec06bc: and r9b, 0xd2 + _0xffffff8000ec06c0: add r9b, dil + _0xffffff8000ec06c3: add r9b, 0xb8 + _0xffffff8000ec06c7: movzx edi, r9b + _0xffffff8000ec06cb: cmp dil, 0x21 + _0xffffff8000ec06cf: sbb r9, r9 + _0xffffff8000ec06d2: and r9d, 0x100 + _0xffffff8000ec06d9: add r9d, edi + _0xffffff8000ec06dc: add r9d, 0x71c9b886 + _0xffffff8000ec06e3: shl r9, 0x20 + _0xffffff8000ec06e7: movabs rdi, 0x8e36475900000000 + _0xffffff8000ec06f1: add rdi, r9 + _0xffffff8000ec06f4: mov r9, rdi + _0xffffff8000ec06f7: sar r9, 0x1f + _0xffffff8000ec06fb: movabs r10, 0x37ffbf97adbfb6ae + _0xffffff8000ec0705: and r10, r9 + _0xffffff8000ec0708: sar rdi, 0x20 + _0xffffff8000ec070c: movabs r9, 0x3bffdfcbd6dfdb57 + _0xffffff8000ec0716: xor r9, rdi + _0xffffff8000ec0719: add r9, r10 + _0xffffff8000ec071c: lea rdi, [rax + r9*4] + _0xffffff8000ec0720: movabs r9, 0x100080d0a48092a4 + _0xffffff8000ec072a: xor r8d, dword ptr [r9 + rdi] + _0xffffff8000ec072e: movzx edi, byte ptr [rbx + 7] + _0xffffff8000ec0732: mov r9d, edi + _0xffffff8000ec0735: and r9d, 0xd7 + _0xffffff8000ec073c: xor edi, 0x7fafefd7 + _0xffffff8000ec0742: lea edi, [rdi + r9*2 - 0x7fafefd7] + _0xffffff8000ec074a: movsxd rdi, edi + _0xffffff8000ec074d: movabs r9, 0x3b7ddf6feffdebbd + _0xffffff8000ec0757: mov r10, rdi + _0xffffff8000ec075a: and r10, r9 + _0xffffff8000ec075d: xor rdi, r9 + _0xffffff8000ec0760: lea rdi, [rdi + r10*2] + _0xffffff8000ec0764: add rdi, qword ptr [r14 + 0x48] + _0xffffff8000ec0768: movabs r9, 0xc482209010021443 + _0xffffff8000ec0772: mov dil, byte ptr [r9 + rdi] + _0xffffff8000ec0776: mov r9b, dil + _0xffffff8000ec0779: add r9b, r9b + _0xffffff8000ec077c: xor dil, 0x71 + _0xffffff8000ec0780: and r9b, 0xe2 + _0xffffff8000ec0784: add r9b, dil + _0xffffff8000ec0787: add r9b, 0xcf + _0xffffff8000ec078b: movzx edi, r9b + _0xffffff8000ec078f: cmp dil, 0x40 + _0xffffff8000ec0793: sbb r9, r9 + _0xffffff8000ec0796: and r9d, 0x100 + _0xffffff8000ec079d: add r9d, edi + _0xffffff8000ec07a0: add r9d, 0x58ebbcfd + _0xffffff8000ec07a7: shl r9, 0x20 + _0xffffff8000ec07ab: movabs rdi, 0xa71442c300000000 + _0xffffff8000ec07b5: add rdi, r9 + _0xffffff8000ec07b8: mov r9, rdi + _0xffffff8000ec07bb: sar r9, 0x1f + _0xffffff8000ec07bf: movabs r10, 0x25fff6ef9a9df55e + _0xffffff8000ec07c9: and r10, r9 + _0xffffff8000ec07cc: sar rdi, 0x20 + _0xffffff8000ec07d0: movabs r9, 0x12fffb77cd4efaaf + _0xffffff8000ec07da: xor r9, rdi + _0xffffff8000ec07dd: add r9, r10 + _0xffffff8000ec07e0: lea rdi, [r15 + r9*4] + _0xffffff8000ec07e4: movabs r9, 0xb4001220cac41544 + _0xffffff8000ec07ee: xor r8d, dword ptr [r9 + rdi] + _0xffffff8000ec07f2: mov edi, r8d + _0xffffff8000ec07f5: shr edi, 0x17 + _0xffffff8000ec07f8: and edi, 0x86 + _0xffffff8000ec07fe: mov r9d, r8d + _0xffffff8000ec0801: shr r9d, 0x18 + _0xffffff8000ec0805: add r9d, 0x43 + _0xffffff8000ec0809: sub r9d, edi + _0xffffff8000ec080c: xor r9d, 0x43 + _0xffffff8000ec0810: mov dil, r9b + _0xffffff8000ec0813: add dil, dil + _0xffffff8000ec0816: xor r9b, 0x71 + _0xffffff8000ec081a: and dil, 0xe2 + _0xffffff8000ec081e: add dil, r9b + _0xffffff8000ec0821: add dil, 0xcf + _0xffffff8000ec0825: movzx edi, dil + _0xffffff8000ec0829: cmp dil, 0x40 + _0xffffff8000ec082d: sbb r9, r9 + _0xffffff8000ec0830: and r9d, 0x100 + _0xffffff8000ec0837: add r9d, edi + _0xffffff8000ec083a: add r9d, 0x4c7a70c1 + _0xffffff8000ec0841: shl r9, 0x20 + _0xffffff8000ec0845: movabs rdi, 0xb3858eff00000000 + _0xffffff8000ec084f: add rdi, r9 + _0xffffff8000ec0852: mov r9, rdi + _0xffffff8000ec0855: sar r9, 0x1f + _0xffffff8000ec0859: movabs r10, 0xe2d1f38fdfcfbf9e + _0xffffff8000ec0863: and r10, r9 + _0xffffff8000ec0866: sar rdi, 0x20 + _0xffffff8000ec086a: movabs r9, 0x7168f9c7efe7dfcf + _0xffffff8000ec0874: xor r9, rdi + _0xffffff8000ec0877: add r9, r10 + _0xffffff8000ec087a: add r9, qword ptr [r14 + 0xe8] + _0xffffff8000ec0881: movabs rdi, 0x8e97063810182031 + _0xffffff8000ec088b: mov dil, byte ptr [rdi + r9] + _0xffffff8000ec088f: mov r9b, dil + _0xffffff8000ec0892: add r9b, r9b + _0xffffff8000ec0895: xor dil, 0x6f + _0xffffff8000ec0899: and r9b, 0xde + _0xffffff8000ec089d: add r9b, dil + _0xffffff8000ec08a0: add r9b, 0xe0 + _0xffffff8000ec08a4: movzx edi, r9b + _0xffffff8000ec08a8: cmp dil, 0x4f + _0xffffff8000ec08ac: sbb r9, r9 + _0xffffff8000ec08af: and r9, 0x100 + _0xffffff8000ec08b6: add r9, rdi + _0xffffff8000ec08b9: add r9, -0x4f + _0xffffff8000ec08bd: movabs rdi, 0x1bebfffb57f63f3e + _0xffffff8000ec08c7: mov r10, r9 + _0xffffff8000ec08ca: and r10, rdi + _0xffffff8000ec08cd: xor r9, rdi + _0xffffff8000ec08d0: lea rdi, [r9 + r10*2] + _0xffffff8000ec08d4: lea rdi, [r15 + rdi*4] + _0xffffff8000ec08d8: movabs r9, 0x90500012a0270308 + _0xffffff8000ec08e2: mov eax, dword ptr [r9 + rdi] + _0xffffff8000ec08e6: mov dword ptr [rbp - 0x3c4], eax + _0xffffff8000ec08ec: movzx edx, byte ptr [rbx + 6] + _0xffffff8000ec08f0: mov edi, edx + _0xffffff8000ec08f2: and edi, 0x8f + _0xffffff8000ec08f8: xor edx, 0x4fecb98f + _0xffffff8000ec08fe: lea edx, [rdx + rdi*2 - 0x4fecb98f] + _0xffffff8000ec0905: movsxd r15, edx + _0xffffff8000ec0908: movabs r12, 0x3ddff2bfffff7faf + _0xffffff8000ec0912: mov r13, r15 + _0xffffff8000ec0915: and r13, r12 + _0xffffff8000ec0918: xor r15, r12 + _0xffffff8000ec091b: lea r15, [r15 + r13*2] + _0xffffff8000ec091f: add r15, qword ptr [r14 + 0x40] + _0xffffff8000ec0923: movabs r12, 0xc2200d4000008051 + _0xffffff8000ec092d: mov dl, byte ptr [r12 + r15] + _0xffffff8000ec0931: mov dil, dl + _0xffffff8000ec0934: add dil, dil + _0xffffff8000ec0937: xor dl, 0x7d + _0xffffff8000ec093a: and dil, 0xfa + _0xffffff8000ec093e: add dil, dl + _0xffffff8000ec0941: add dil, 0xbc + _0xffffff8000ec0945: movzx r15d, dil + _0xffffff8000ec0949: cmp r15b, 0x39 + _0xffffff8000ec094d: sbb r12, r12 + _0xffffff8000ec0950: and r12, 0x100 + _0xffffff8000ec0957: add r12, r15 + _0xffffff8000ec095a: add r12, -0x39 + _0xffffff8000ec095e: movabs r15, 0x1962fb4ffdc9b7ae + _0xffffff8000ec0968: mov r13, r12 + _0xffffff8000ec096b: and r13, r15 + _0xffffff8000ec096e: xor r12, r15 + _0xffffff8000ec0971: lea r15, [r12 + r13*2] + _0xffffff8000ec0975: mov rax, qword ptr [rbp - 0x3d8] + _0xffffff8000ec097c: lea r15, [rax + r15*4] + _0xffffff8000ec0980: movabs r12, 0x9a7412c008d92148 + _0xffffff8000ec098a: mov edi, dword ptr [r12 + r15] + _0xffffff8000ec098e: movzx edx, byte ptr [rbx + 0xc] + _0xffffff8000ec0992: mov r9d, edx + _0xffffff8000ec0995: and r9d, 0xcd + _0xffffff8000ec099c: xor edx, 0x7fe79ecd + _0xffffff8000ec09a2: lea edx, [rdx + r9*2 - 0x7fe79ecd] + _0xffffff8000ec09aa: movsxd r15, edx + _0xffffff8000ec09ad: movabs r12, 0x35eefd7f7f1f7ad7 + _0xffffff8000ec09b7: mov r13, r15 + _0xffffff8000ec09ba: and r13, r12 + _0xffffff8000ec09bd: xor r15, r12 + _0xffffff8000ec09c0: lea r15, [r15 + r13*2] + _0xffffff8000ec09c4: add r15, qword ptr [r14 + 0x70] + _0xffffff8000ec09c8: movabs r12, 0xca11028080e08529 + _0xffffff8000ec09d2: mov dl, byte ptr [r12 + r15] + _0xffffff8000ec09d6: mov r9b, dl + _0xffffff8000ec09d9: add r9b, r9b + _0xffffff8000ec09dc: xor dl, 0x4c + _0xffffff8000ec09df: and r9b, 0x98 + _0xffffff8000ec09e3: add r9b, dl + _0xffffff8000ec09e6: movzx edx, r9b + _0xffffff8000ec09ea: cmp dl, 0x4c + _0xffffff8000ec09ed: sbb r15, r15 + _0xffffff8000ec09f0: and r15d, 0x100 + _0xffffff8000ec09f7: add r15d, edx + _0xffffff8000ec09fa: add r15d, 0x56372b56 + _0xffffff8000ec0a01: shl r15, 0x20 + _0xffffff8000ec0a05: movabs r12, 0xa9c8d45e00000000 + _0xffffff8000ec0a0f: add r12, r15 + _0xffffff8000ec0a12: mov r15, r12 + _0xffffff8000ec0a15: sar r15, 0x1f + _0xffffff8000ec0a19: movabs r13, 0x3feaadbfdecbfcda + _0xffffff8000ec0a23: and r13, r15 + _0xffffff8000ec0a26: sar r12, 0x20 + _0xffffff8000ec0a2a: movabs r15, 0x1ff556dfef65fe6d + _0xffffff8000ec0a34: xor r15, r12 + _0xffffff8000ec0a37: add r15, r13 + _0xffffff8000ec0a3a: mov r12, qword ptr [rbp - 0x3c0] + _0xffffff8000ec0a41: lea r15, [r12 + r15*4] + _0xffffff8000ec0a45: movabs r13, 0x802aa4804268064c + _0xffffff8000ec0a4f: xor edi, dword ptr [r13 + r15] + _0xffffff8000ec0a54: movzx edx, byte ptr [rbx + 1] + _0xffffff8000ec0a58: mov r9d, edx + _0xffffff8000ec0a5b: and r9d, 0xaf + _0xffffff8000ec0a62: xor edx, 0x5de5bfaf + _0xffffff8000ec0a68: lea edx, [rdx + r9*2 - 0x5de5bfaf] + _0xffffff8000ec0a70: movsxd r15, edx + _0xffffff8000ec0a73: movabs r13, 0x7af69a7fdfedcf6a + _0xffffff8000ec0a7d: mov rdx, r15 + _0xffffff8000ec0a80: and rdx, r13 + _0xffffff8000ec0a83: xor r15, r13 + _0xffffff8000ec0a86: lea r15, [r15 + rdx*2] + _0xffffff8000ec0a8a: add r15, qword ptr [r14 + 0x18] + _0xffffff8000ec0a8e: movabs r13, 0x8509658020123096 + _0xffffff8000ec0a98: mov dl, byte ptr [r13 + r15] + _0xffffff8000ec0a9d: mov r9b, dl + _0xffffff8000ec0aa0: add r9b, r9b + _0xffffff8000ec0aa3: xor dl, 0x7c + _0xffffff8000ec0aa6: and r9b, 0xf8 + _0xffffff8000ec0aaa: add r9b, dl + _0xffffff8000ec0aad: add r9b, 0xe8 + _0xffffff8000ec0ab1: movzx edx, r9b + _0xffffff8000ec0ab5: cmp dl, 0x64 + _0xffffff8000ec0ab8: sbb r15, r15 + _0xffffff8000ec0abb: and r15d, 0x100 + _0xffffff8000ec0ac2: add r15d, edx + _0xffffff8000ec0ac5: add r15d, 0x30b9c48a + _0xffffff8000ec0acc: shl r15, 0x20 + _0xffffff8000ec0ad0: movabs r13, 0xcf463b1200000000 + _0xffffff8000ec0ada: add r13, r15 + _0xffffff8000ec0add: mov r15, r13 + _0xffffff8000ec0ae0: sar r15, 0x1f + _0xffffff8000ec0ae4: movabs rdx, 0x3ebe1fbff7febebe + _0xffffff8000ec0aee: and rdx, r15 + _0xffffff8000ec0af1: sar r13, 0x20 + _0xffffff8000ec0af5: movabs r15, 0x1f5f0fdffbff5f5f + _0xffffff8000ec0aff: xor r15, r13 + _0xffffff8000ec0b02: add r15, rdx + _0xffffff8000ec0b05: mov r13, qword ptr [rbp - 0x3d0] + _0xffffff8000ec0b0c: lea r15, [r13 + r15*4] + _0xffffff8000ec0b11: movabs rdx, 0x8283c08010028284 + _0xffffff8000ec0b1b: xor edi, dword ptr [rdx + r15] + _0xffffff8000ec0b1f: movzx edx, byte ptr [rbx + 0xb] + _0xffffff8000ec0b23: mov r9d, edx + _0xffffff8000ec0b26: and r9d, 0x7d + _0xffffff8000ec0b2a: xor edx, 0x47b6677d + _0xffffff8000ec0b30: lea edx, [rdx + r9*2 - 0x47b6677d] + _0xffffff8000ec0b38: movsxd r15, edx + _0xffffff8000ec0b3b: movabs rdx, 0x7fcf6bf8ef3eefbb + _0xffffff8000ec0b45: mov r9, r15 + _0xffffff8000ec0b48: and r9, rdx + _0xffffff8000ec0b4b: xor r15, rdx + _0xffffff8000ec0b4e: lea r15, [r15 + r9*2] + _0xffffff8000ec0b52: add r15, qword ptr [r14 + 0x68] + _0xffffff8000ec0b56: movabs rdx, 0x8030940710c11045 + _0xffffff8000ec0b60: mov dl, byte ptr [rdx + r15] + _0xffffff8000ec0b64: mov r9b, dl + _0xffffff8000ec0b67: add r9b, r9b + _0xffffff8000ec0b6a: xor dl, 0x6e + _0xffffff8000ec0b6d: and r9b, 0xdc + _0xffffff8000ec0b71: add r9b, dl + _0xffffff8000ec0b74: add r9b, 0xfc + _0xffffff8000ec0b78: movzx r15d, r9b + _0xffffff8000ec0b7c: cmp r15b, 0x6a + _0xffffff8000ec0b80: sbb rdx, rdx + _0xffffff8000ec0b83: and rdx, 0x100 + _0xffffff8000ec0b8a: add rdx, r15 + _0xffffff8000ec0b8d: add rdx, -0x6a + _0xffffff8000ec0b91: movabs r15, 0x13c8efdffd4d3cdb + _0xffffff8000ec0b9b: and r15, rdx + _0xffffff8000ec0b9e: movabs r9, 0x33c8efdffd4d3cdb + _0xffffff8000ec0ba8: xor r9, rdx + _0xffffff8000ec0bab: lea r15, [r9 + r15*2] + _0xffffff8000ec0baf: mov rdx, qword ptr [rbp - 0x3b8] + _0xffffff8000ec0bb6: lea r15, [rdx + r15*4] + _0xffffff8000ec0bba: movabs r9, 0x30dc40800acb0c94 + _0xffffff8000ec0bc4: xor edi, dword ptr [r9 + r15] + _0xffffff8000ec0bc8: mov r9b, dil + _0xffffff8000ec0bcb: add r9b, r9b + _0xffffff8000ec0bce: mov r10b, dil + _0xffffff8000ec0bd1: xor r10b, 0x4c + _0xffffff8000ec0bd5: and r9b, 0x98 + _0xffffff8000ec0bd9: add r9b, r10b + _0xffffff8000ec0bdc: movzx r9d, r9b + _0xffffff8000ec0be0: cmp r9b, 0x4c + _0xffffff8000ec0be4: mov r15d, 0xb4 + _0xffffff8000ec0bea: mov r10d, 0xffffffb4 + _0xffffff8000ec0bf0: cmovb r10, r15 + _0xffffff8000ec0bf4: lea r15d, [r10 + r9 + 0x55ae4500] + _0xffffff8000ec0bfc: shl r15, 0x20 + _0xffffff8000ec0c00: movabs r9, 0xaa51bb0000000000 + _0xffffff8000ec0c0a: add r9, r15 + _0xffffff8000ec0c0d: mov r15, r9 + _0xffffff8000ec0c10: sar r15, 0x1f + _0xffffff8000ec0c14: movabs r10, 0xa9f7bbbfef27d6f2 + _0xffffff8000ec0c1e: and r10, r15 + _0xffffff8000ec0c21: sar r9, 0x20 + _0xffffff8000ec0c25: movabs r15, 0x54fbdddff793eb79 + _0xffffff8000ec0c2f: xor r15, r9 + _0xffffff8000ec0c32: add r15, r10 + _0xffffff8000ec0c35: add r15, qword ptr [r14 + 0xf0] + _0xffffff8000ec0c3c: movabs r9, 0xab042220086c1487 + _0xffffff8000ec0c46: mov r9b, byte ptr [r9 + r15] + _0xffffff8000ec0c4a: mov r10b, r9b + _0xffffff8000ec0c4d: add r10b, r10b + _0xffffff8000ec0c50: xor r9b, 0x7d + _0xffffff8000ec0c54: and r10b, 0xfa + _0xffffff8000ec0c58: add r10b, r9b + _0xffffff8000ec0c5b: add r10b, 0x98 + _0xffffff8000ec0c5f: movzx r9d, r10b + _0xffffff8000ec0c63: cmp r9b, 0x15 + _0xffffff8000ec0c67: sbb r15, r15 + _0xffffff8000ec0c6a: and r15d, 0x100 + _0xffffff8000ec0c71: add r15d, r9d + _0xffffff8000ec0c74: add r15d, 0x31060e20 + _0xffffff8000ec0c7b: shl r15, 0x20 + _0xffffff8000ec0c7f: movabs r9, 0xcef9f1cb00000000 + _0xffffff8000ec0c89: add r9, r15 + _0xffffff8000ec0c8c: mov r15, r9 + _0xffffff8000ec0c8f: sar r15, 0x1f + _0xffffff8000ec0c93: movabs r10, 0xefffb27fb8fd7dc + _0xffffff8000ec0c9d: and r10, r15 + _0xffffff8000ec0ca0: sar r9, 0x20 + _0xffffff8000ec0ca4: movabs r15, 0x77ffd93fdc7ebee + _0xffffff8000ec0cae: xor r15, r9 + _0xffffff8000ec0cb1: add r15, r10 + _0xffffff8000ec0cb4: lea r15, [r12 + r15*4] + _0xffffff8000ec0cb8: movabs r9, 0xe20009b008e05048 + _0xffffff8000ec0cc2: mov eax, dword ptr [rbp - 0x3c4] + _0xffffff8000ec0cc8: xor eax, dword ptr [r9 + r15] + _0xffffff8000ec0ccc: movabs r15, 0x8801862a304180c + _0xffffff8000ec0cd6: xor eax, dword ptr [r15 + rcx] + _0xffffff8000ec0cda: mov dword ptr [rbp - 0x3c4], eax + _0xffffff8000ec0ce0: movzx ecx, byte ptr [rbx + 0xf] + _0xffffff8000ec0ce4: mov r9d, ecx + _0xffffff8000ec0ce7: and r9d, 0x2f + _0xffffff8000ec0ceb: xor ecx, 0x15cfdb2f + _0xffffff8000ec0cf1: lea ecx, [rcx + r9*2 - 0x15cfdb2f] + _0xffffff8000ec0cf9: movsxd r15, ecx + _0xffffff8000ec0cfc: movabs rcx, 0x6df5f53bd9efdffe + _0xffffff8000ec0d06: mov r9, r15 + _0xffffff8000ec0d09: and r9, rcx + _0xffffff8000ec0d0c: xor r15, rcx + _0xffffff8000ec0d0f: lea r15, [r15 + r9*2] + _0xffffff8000ec0d13: add r15, qword ptr [r14 + 0x88] + _0xffffff8000ec0d1a: movabs rcx, 0x920a0ac426102002 + _0xffffff8000ec0d24: mov cl, byte ptr [rcx + r15] + _0xffffff8000ec0d28: mov r9b, cl + _0xffffff8000ec0d2b: add r9b, r9b + _0xffffff8000ec0d2e: xor cl, 0x77 + _0xffffff8000ec0d31: and r9b, 0xee + _0xffffff8000ec0d35: add r9b, cl + _0xffffff8000ec0d38: add r9b, 0xbf + _0xffffff8000ec0d3c: cmp r9b, 0x36 + _0xffffff8000ec0d40: movabs r15, 0x49229122a0042109 + _0xffffff8000ec0d4a: movabs rcx, 0x49229122a0042009 + _0xffffff8000ec0d54: cmovb rcx, r15 + _0xffffff8000ec0d58: movzx r15d, r9b + _0xffffff8000ec0d5c: mov r9d, 0xb0792967 + _0xffffff8000ec0d62: add r9, r15 + _0xffffff8000ec0d65: movabs r15, 0x49229122a224a489 + _0xffffff8000ec0d6f: and r15, r9 + _0xffffff8000ec0d72: add r15, rcx + _0xffffff8000ec0d75: movabs rcx, 0x12494a4849514922 + _0xffffff8000ec0d7f: and rcx, r9 + _0xffffff8000ec0d82: lea r10, [r9 + r9] + _0xffffff8000ec0d86: movabs r11, 0x2492949010801204 + _0xffffff8000ec0d90: and r11, r10 + _0xffffff8000ec0d93: add r11, rcx + _0xffffff8000ec0d96: movabs rcx, 0x12494a4808400902 + _0xffffff8000ec0da0: xor rcx, r11 + _0xffffff8000ec0da3: add rcx, r15 + _0xffffff8000ec0da6: movabs r15, 0xa4942495148a1254 + _0xffffff8000ec0db0: and r15, r9 + _0xffffff8000ec0db3: movabs r9, 0x4928492a090020a0 + _0xffffff8000ec0dbd: and r9, r10 + _0xffffff8000ec0dc0: add r9, r15 + _0xffffff8000ec0dc3: movabs r15, 0xa494249504801050 + _0xffffff8000ec0dcd: xor r15, r9 + _0xffffff8000ec0dd0: add r15, rcx + _0xffffff8000ec0dd3: mov r9d, 0x20 + _0xffffff8000ec0dd9: sub r9d, r15d + _0xffffff8000ec0ddc: mov ecx, r9d + _0xffffff8000ec0ddf: xor ecx, r15d + _0xffffff8000ec0de2: mov r10d, ecx + _0xffffff8000ec0de5: and r10d, 9 + _0xffffff8000ec0de9: mov r11d, r15d + _0xffffff8000ec0dec: and r11d, r9d + _0xffffff8000ec0def: mov eax, r11d + _0xffffff8000ec0df2: and eax, 9 + _0xffffff8000ec0df5: add eax, eax + _0xffffff8000ec0df7: add eax, r10d + _0xffffff8000ec0dfa: and ecx, 0x12 + _0xffffff8000ec0dfd: and r11d, 0x12 + _0xffffff8000ec0e01: add r11d, r11d + _0xffffff8000ec0e04: add r11d, ecx + _0xffffff8000ec0e07: add r11d, eax + _0xffffff8000ec0e0a: and r9d, 0xa484a8a4 + _0xffffff8000ec0e11: mov ecx, r15d + _0xffffff8000ec0e14: and ecx, 0x24 + _0xffffff8000ec0e17: add ecx, 8 + _0xffffff8000ec0e1a: sub ecx, r9d + _0xffffff8000ec0e1d: and r9d, r15d + _0xffffff8000ec0e20: add r9d, r9d + _0xffffff8000ec0e23: and ecx, 0x24 + _0xffffff8000ec0e26: add ecx, r9d + _0xffffff8000ec0e29: add ecx, r11d + _0xffffff8000ec0e2c: and ecx, 0x3f + _0xffffff8000ec0e2f: mov rax, r15 + _0xffffff8000ec0e32: movabs r9, 0xcccccccccccccccd + _0xffffff8000ec0e3c: mul r9 + _0xffffff8000ec0e3f: mov rax, rdx + _0xffffff8000ec0e42: and rax, 0xfffffffffffffff0 + _0xffffff8000ec0e46: shr rax, 2 + _0xffffff8000ec0e4a: lea rax, [rax + rax*4] + _0xffffff8000ec0e4e: sub r15, rax + _0xffffff8000ec0e51: shl r15, cl + _0xffffff8000ec0e54: mov eax, 0x14 + _0xffffff8000ec0e59: shl rax, cl + _0xffffff8000ec0e5c: mov rcx, rax + _0xffffff8000ec0e5f: and rcx, 3 + _0xffffff8000ec0e63: shr rdx, 4 + _0xffffff8000ec0e67: imul rcx, rdx + _0xffffff8000ec0e6b: add rcx, r15 + _0xffffff8000ec0e6e: sar rax, 2 + _0xffffff8000ec0e72: lea r15, [rdx + rax] + _0xffffff8000ec0e76: imul r15, r15 + _0xffffff8000ec0e7a: add r15, rcx + _0xffffff8000ec0e7d: sub rdx, rax + _0xffffff8000ec0e80: imul rdx, rdx + _0xffffff8000ec0e84: sub r15, rdx + _0xffffff8000ec0e87: movabs rax, 0xa2c29d0800000000 + _0xffffff8000ec0e91: sub rax, r15 + _0xffffff8000ec0e94: mov rcx, rax + _0xffffff8000ec0e97: and rcx, r15 + _0xffffff8000ec0e9a: xor rax, r15 + _0xffffff8000ec0e9d: lea rax, [rax + rcx*2] + _0xffffff8000ec0ea1: mov rcx, rax + _0xffffff8000ec0ea4: and rcx, r15 + _0xffffff8000ec0ea7: xor r15, rax + _0xffffff8000ec0eaa: lea r15, [r15 + rcx*2] + _0xffffff8000ec0eae: mov rax, r15 + _0xffffff8000ec0eb1: sar rax, 0x1e + _0xffffff8000ec0eb5: movabs rcx, 0x20140000800 + _0xffffff8000ec0ebf: and rcx, rax + _0xffffff8000ec0ec2: mov r10, r15 + _0xffffff8000ec0ec5: sar r10, 0x1f + _0xffffff8000ec0ec9: movabs r11, 0x240a0544a0008412 + _0xffffff8000ec0ed3: and r11, r10 + _0xffffff8000ec0ed6: add r11, rcx + _0xffffff8000ec0ed9: movabs rcx, 0x408100a4000400 + _0xffffff8000ec0ee3: xor rcx, r11 + _0xffffff8000ec0ee6: movabs r11, 0x9200102000000040 + _0xffffff8000ec0ef0: and r11, rax + _0xffffff8000ec0ef3: movabs rbx, 0x4910289000252124 + _0xffffff8000ec0efd: and rbx, r10 + _0xffffff8000ec0f00: add rbx, r11 + _0xffffff8000ec0f03: movabs r11, 0x4900081002000820 + _0xffffff8000ec0f0d: xor r11, rbx + _0xffffff8000ec0f10: add r11, rcx + _0xffffff8000ec0f13: movabs rbx, 0x40800002900000 + _0xffffff8000ec0f1d: and rbx, rax + _0xffffff8000ec0f20: movabs rax, 0x244008114a4288 + _0xffffff8000ec0f2a: and rax, r10 + _0xffffff8000ec0f2d: movabs rcx, 0xa568e45323dc8513 + _0xffffff8000ec0f37: sub rcx, rax + _0xffffff8000ec0f3a: movabs rax, 0x92a45229114a4289 + _0xffffff8000ec0f44: and rax, rcx + _0xffffff8000ec0f47: add rax, rbx + _0xffffff8000ec0f4a: add rax, r11 + _0xffffff8000ec0f4d: sar r15, 0x20 + _0xffffff8000ec0f51: mov ebx, 0xcaa6fde9 + _0xffffff8000ec0f56: xor rbx, r15 + _0xffffff8000ec0f59: movabs r15, 0xa49128a8942a4494 + _0xffffff8000ec0f63: mov rcx, rbx + _0xffffff8000ec0f66: and rcx, r15 + _0xffffff8000ec0f69: movabs r10, 0x6db371f9b8548d3c + _0xffffff8000ec0f73: sub r10, rcx + _0xffffff8000ec0f76: and r10, r15 + _0xffffff8000ec0f79: movabs r15, 0x124a454249452922 + _0xffffff8000ec0f83: mov rcx, rbx + _0xffffff8000ec0f86: and rcx, r15 + _0xffffff8000ec0f89: movabs r11, 0x120a044200010822 + _0xffffff8000ec0f93: add r11, rcx + _0xffffff8000ec0f96: and r11, r15 + _0xffffff8000ec0f99: add r11, r10 + _0xffffff8000ec0f9c: movabs r15, 0x4924921522909249 + _0xffffff8000ec0fa6: and rbx, r15 + _0xffffff8000ec0fa9: movabs rcx, 0x1249242a45212492 + _0xffffff8000ec0fb3: add rcx, rbx + _0xffffff8000ec0fb6: movabs rbx, 0x7ffbedfbfdeffe00 + _0xffffff8000ec0fc0: add rbx, rcx + _0xffffff8000ec0fc3: and rbx, r15 + _0xffffff8000ec0fc6: add rbx, r11 + _0xffffff8000ec0fc9: mov r15, rbx + _0xffffff8000ec0fcc: and r15, rax + _0xffffff8000ec0fcf: xor rbx, rax + _0xffffff8000ec0fd2: lea rbx, [rbx + r15*2] + _0xffffff8000ec0fd6: mov ecx, 0x9f7d7b66 + _0xffffff8000ec0fdb: mov rdx, qword ptr [rbp - 0x3b8] + _0xffffff8000ec0fe2: xor ecx, dword ptr [rdx + rbx*4] + _0xffffff8000ec0fe5: mov rbx, qword ptr [rbp - 0x3e0] + _0xffffff8000ec0fec: movzx eax, byte ptr [rbx] + _0xffffff8000ec0fef: mov r10d, eax + _0xffffff8000ec0ff2: and r10d, 0xdf + _0xffffff8000ec0ff9: xor eax, 0x617fefdf + _0xffffff8000ec0ffe: lea eax, [rax + r10*2 - 0x617fefdf] + _0xffffff8000ec1006: movsxd r15, eax + _0xffffff8000ec1009: movabs rax, 0x7fb75f5effe57bb9 + _0xffffff8000ec1013: mov r10, r15 + _0xffffff8000ec1016: and r10, rax + _0xffffff8000ec1019: xor r15, rax + _0xffffff8000ec101c: lea r15, [r15 + r10*2] + _0xffffff8000ec1020: add r15, qword ptr [r14 + 0x10] + _0xffffff8000ec1024: movabs rax, 0x8048a0a1001a8447 + _0xffffff8000ec102e: mov al, byte ptr [rax + r15] + _0xffffff8000ec1032: mov r10b, al + _0xffffff8000ec1035: add r10b, r10b + _0xffffff8000ec1038: xor al, 0x7a + _0xffffff8000ec103a: and r10b, 0xf4 + _0xffffff8000ec103e: add r10b, al + _0xffffff8000ec1041: add r10b, 0xa0 + _0xffffff8000ec1045: movzx r15d, r10b + _0xffffff8000ec1049: lea rax, [r15 + 0x6eaad3fc] + _0xffffff8000ec1050: lea r10, [rax + rax] + _0xffffff8000ec1054: mov r11d, r10d + _0xffffff8000ec1057: and r11d, 0x88090008 + _0xffffff8000ec105e: mov edx, eax + _0xffffff8000ec1060: and edx, 0x54449524 + _0xffffff8000ec1066: add edx, 0x28892a48 + _0xffffff8000ec106c: add edx, 0xbbfb7ffc + _0xffffff8000ec1072: and edx, 0x54449524 + _0xffffff8000ec1078: add edx, r11d + _0xffffff8000ec107b: and r10d, 0x10040100 + _0xffffff8000ec1082: mov r11d, eax + _0xffffff8000ec1085: and r11d, 0x92a2891 + _0xffffff8000ec108c: mov r9d, 0x1a5651a2 + _0xffffff8000ec1092: sub r9d, r11d + _0xffffff8000ec1095: and r9d, 0x92a2891 + _0xffffff8000ec109c: add r9d, r10d + _0xffffff8000ec109f: and eax, 0xa291424a + _0xffffff8000ec10a4: add eax, r9d + _0xffffff8000ec10a7: lea eax, [rax + rdx - 0x7d7effb6] + _0xffffff8000ec10ae: cmp r15b, 0x1a + _0xffffff8000ec10b2: sbb r15, r15 + _0xffffff8000ec10b5: and r15d, 0x100 + _0xffffff8000ec10bc: mov edx, eax + _0xffffff8000ec10be: and edx, r15d + _0xffffff8000ec10c1: xor r15d, eax + _0xffffff8000ec10c4: lea r15d, [r15 + rdx*2] + _0xffffff8000ec10c8: shl r15, 0x20 + _0xffffff8000ec10cc: movabs rax, 0xc2cdab1c00000000 + _0xffffff8000ec10d6: add rax, r15 + _0xffffff8000ec10d9: mov r15, rax + _0xffffff8000ec10dc: sar r15, 0x1e + _0xffffff8000ec10e0: movabs rdx, 0x24000408020820 + _0xffffff8000ec10ea: and rdx, r15 + _0xffffff8000ec10ed: mov r15, rax + _0xffffff8000ec10f0: sar r15, 0x1f + _0xffffff8000ec10f4: movabs r9, 0xff1bfff3e7fdf79c + _0xffffff8000ec10fe: and r9, r15 + _0xffffff8000ec1101: movabs r15, 0x807200060c010432 + _0xffffff8000ec110b: xor r15, r9 + _0xffffff8000ec110e: add r15, rdx + _0xffffff8000ec1111: sar rax, 0x20 + _0xffffff8000ec1115: xor rax, 0x7bb292a5 + _0xffffff8000ec111b: movabs rdx, 0x7f8dfff9884c696b + _0xffffff8000ec1125: add rdx, rax + _0xffffff8000ec1128: add rax, rax + _0xffffff8000ec112b: movabs r9, 0xff1bfff31098d2d6 + _0xffffff8000ec1135: and r9, rax + _0xffffff8000ec1138: sub rdx, r9 + _0xffffff8000ec113b: mov rax, rdx + _0xffffff8000ec113e: and rax, r15 + _0xffffff8000ec1141: xor rdx, r15 + _0xffffff8000ec1144: lea r15, [rdx + rax*2] + _0xffffff8000ec1148: mov r9d, 0x5462eedf + _0xffffff8000ec114e: xor r9d, dword ptr [r12 + r15*4] + _0xffffff8000ec1152: movzx eax, byte ptr [rbx + 0xa] + _0xffffff8000ec1156: mov edx, eax + _0xffffff8000ec1158: and edx, 0xcf + _0xffffff8000ec115e: xor eax, 0x7b767fcf + _0xffffff8000ec1163: lea eax, [rax + rdx*2 - 0x7b767fcf] + _0xffffff8000ec116a: movsxd r15, eax + _0xffffff8000ec116d: movabs rax, 0x7fffc77ffffef3ff + _0xffffff8000ec1177: mov rdx, r15 + _0xffffff8000ec117a: and rdx, rax + _0xffffff8000ec117d: xor r15, rax + _0xffffff8000ec1180: lea r15, [r15 + rdx*2] + _0xffffff8000ec1184: add r15, qword ptr [r14 + 0x60] + _0xffffff8000ec1188: movabs rax, 0x8000388000010c01 + _0xffffff8000ec1192: mov al, byte ptr [rax + r15] + _0xffffff8000ec1196: mov dl, al + _0xffffff8000ec1198: add dl, dl + _0xffffff8000ec119a: xor al, 0x5e + _0xffffff8000ec119c: and dl, 0xbc + _0xffffff8000ec119f: add dl, al + _0xffffff8000ec11a1: add dl, 0xfe + _0xffffff8000ec11a4: movzx r15d, dl + _0xffffff8000ec11a8: mov rax, r15 + _0xffffff8000ec11ab: and rax, 0x23 + _0xffffff8000ec11af: mov rdx, r15 + _0xffffff8000ec11b2: xor rdx, 0x77f80c23 + _0xffffff8000ec11b9: lea rax, [rdx + rax*2] + _0xffffff8000ec11bd: cmp r15b, 0x5c + _0xffffff8000ec11c1: sbb r15, r15 + _0xffffff8000ec11c4: and r15, 0x100 + _0xffffff8000ec11cb: mov rdx, rax + _0xffffff8000ec11ce: and rdx, r15 + _0xffffff8000ec11d1: xor r15, rax + _0xffffff8000ec11d4: lea rax, [r15 + rdx*2] + _0xffffff8000ec11d8: mov r15d, eax + _0xffffff8000ec11db: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec11e5: mul rdx + _0xffffff8000ec11e8: shr rdx, 4 + _0xffffff8000ec11ec: imul eax, edx, 0x18 + _0xffffff8000ec11ef: sub r15d, eax + _0xffffff8000ec11f2: shl r15, 0x20 + _0xffffff8000ec11f6: lea rax, [rdx + rdx*2] + _0xffffff8000ec11fa: shl rax, 0x23 + _0xffffff8000ec11fe: add rax, r15 + _0xffffff8000ec1201: movabs r15, 0x8807f38100000000 + _0xffffff8000ec120b: add r15, rax + _0xffffff8000ec120e: sar r15, 0x20 + _0xffffff8000ec1212: movabs rax, 0x3ffc7dbbffa9ff3f + _0xffffff8000ec121c: mov r10, r15 + _0xffffff8000ec121f: xor r10, rax + _0xffffff8000ec1222: sub rax, r10 + _0xffffff8000ec1225: add rax, r15 + _0xffffff8000ec1228: movabs r10, 0x40038244005600c1 + _0xffffff8000ec1232: and r10, rax + _0xffffff8000ec1235: movabs r11, 0xc0038244005600c1 + _0xffffff8000ec123f: xor r11, rax + _0xffffff8000ec1242: lea rax, [r11 + r10*2] + _0xffffff8000ec1246: xor r15, 0x6d974043 + _0xffffff8000ec124d: movabs r10, 0x3ffc7dbb923ebf7c + _0xffffff8000ec1257: add r10, r15 + _0xffffff8000ec125a: add r15, r15 + _0xffffff8000ec125d: movabs r11, 0x7ff8fb77247d7ef8 + _0xffffff8000ec1267: and r11, r15 + _0xffffff8000ec126a: sub r10, r11 + _0xffffff8000ec126d: mov r15, rax + _0xffffff8000ec1270: and r15, r10 + _0xffffff8000ec1273: xor r10, rax + _0xffffff8000ec1276: lea r15, [r10 + r15*2] + _0xffffff8000ec127a: mov eax, 0x20cc0819 + _0xffffff8000ec127f: mov r10, qword ptr [rbp - 0x3d8] + _0xffffff8000ec1286: xor eax, dword ptr [r10 + r15*4] + _0xffffff8000ec128a: lea r11d, [rax + r9] + _0xffffff8000ec128e: and eax, r9d + _0xffffff8000ec1291: add eax, eax + _0xffffff8000ec1293: sub r11d, eax + _0xffffff8000ec1296: xor r11d, 0xebd39da0 + _0xffffff8000ec129d: lea eax, [rcx + r11] + _0xffffff8000ec12a1: and r11d, ecx + _0xffffff8000ec12a4: add r11d, r11d + _0xffffff8000ec12a7: sub eax, r11d + _0xffffff8000ec12aa: xor eax, 0xe6cd8ac8 + _0xffffff8000ec12af: movzx ecx, byte ptr [rbx + 5] + _0xffffff8000ec12b3: mov r9d, ecx + _0xffffff8000ec12b6: and r9d, 0xbf + _0xffffff8000ec12bd: xor ecx, 0x7b97ffbf + _0xffffff8000ec12c3: lea ecx, [rcx + r9*2 - 0x7b97ffbf] + _0xffffff8000ec12cb: movsxd r15, ecx + _0xffffff8000ec12ce: movabs rcx, 0x67feaeb5ecdffd6f + _0xffffff8000ec12d8: mov r9, r15 + _0xffffff8000ec12db: and r9, rcx + _0xffffff8000ec12de: xor r15, rcx + _0xffffff8000ec12e1: lea r15, [r15 + r9*2] + _0xffffff8000ec12e5: add r15, qword ptr [r14 + 0x38] + _0xffffff8000ec12e9: movabs rcx, 0x9801514a13200291 + _0xffffff8000ec12f3: mov cl, byte ptr [rcx + r15] + _0xffffff8000ec12f7: mov r9b, cl + _0xffffff8000ec12fa: add r9b, r9b + _0xffffff8000ec12fd: xor cl, 0x7c + _0xffffff8000ec1300: and r9b, 0xf8 + _0xffffff8000ec1304: add r9b, cl + _0xffffff8000ec1307: add r9b, 0xb0 + _0xffffff8000ec130b: movzx r15d, r9b + _0xffffff8000ec130f: cmp r15b, 0x2c + _0xffffff8000ec1313: sbb rcx, rcx + _0xffffff8000ec1316: and rcx, 0x100 + _0xffffff8000ec131d: add rcx, r15 + _0xffffff8000ec1320: add rcx, 0x687086ff + _0xffffff8000ec1327: mov r15, rcx + _0xffffff8000ec132a: shl r15, 0x21 + _0xffffff8000ec132e: movabs r9, 0x2f1ef1aa00000000 + _0xffffff8000ec1338: and r9, r15 + _0xffffff8000ec133b: shl rcx, 0x20 + _0xffffff8000ec133f: movabs r15, 0x978f78d500000000 + _0xffffff8000ec1349: xor r15, rcx + _0xffffff8000ec134c: add r15, r9 + _0xffffff8000ec134f: mov rcx, r15 + _0xffffff8000ec1352: sar rcx, 0x1e + _0xffffff8000ec1356: movabs r9, 0x42020a20480a4904 + _0xffffff8000ec1360: and r9, rcx + _0xffffff8000ec1363: mov rcx, r15 + _0xffffff8000ec1366: sar rcx, 0x1f + _0xffffff8000ec136a: movabs r11, 0x3dfdf5dc37f5a6fa + _0xffffff8000ec1374: and r11, rcx + _0xffffff8000ec1377: movabs rcx, 0xe1010511e4052c83 + _0xffffff8000ec1381: xor rcx, r11 + _0xffffff8000ec1384: add rcx, r9 + _0xffffff8000ec1387: sar r15, 0x20 + _0xffffff8000ec138b: mov r9d, 0x831b32b9 + _0xffffff8000ec1391: xor r9, r15 + _0xffffff8000ec1394: movabs r15, 0x1efefaee98e1e1c4 + _0xffffff8000ec139e: add r15, r9 + _0xffffff8000ec13a1: add r9, r9 + _0xffffff8000ec13a4: movabs r11, 0x3dfdf5dd31c3c388 + _0xffffff8000ec13ae: and r11, r9 + _0xffffff8000ec13b1: sub r15, r11 + _0xffffff8000ec13b4: mov r9, r15 + _0xffffff8000ec13b7: xor r9, rcx + _0xffffff8000ec13ba: mov r11, rcx + _0xffffff8000ec13bd: and r11, r15 + _0xffffff8000ec13c0: movabs rbx, 0xa28a90a92484a909 + _0xffffff8000ec13ca: and rbx, r15 + _0xffffff8000ec13cd: movabs r12, 0x228a90a92484a909 + _0xffffff8000ec13d7: mov r13, rcx + _0xffffff8000ec13da: and r13, r12 + _0xffffff8000ec13dd: movabs rdx, 0x515215249095212 + _0xffffff8000ec13e7: add rdx, r13 + _0xffffff8000ec13ea: sub rdx, rbx + _0xffffff8000ec13ed: and rdx, r12 + _0xffffff8000ec13f0: and rbx, rcx + _0xffffff8000ec13f3: add rbx, rbx + _0xffffff8000ec13f6: add rbx, rdx + _0xffffff8000ec13f9: movabs r12, 0x9212a14522a4454 + _0xffffff8000ec1403: and r9, r12 + _0xffffff8000ec1406: and r11, r12 + _0xffffff8000ec1409: add r11, r11 + _0xffffff8000ec140c: add r11, r9 + _0xffffff8000ec140f: movabs r12, 0x14544542895112a2 + _0xffffff8000ec1419: and r15, r12 + _0xffffff8000ec141c: and rcx, r12 + _0xffffff8000ec141f: add rcx, r15 + _0xffffff8000ec1422: add rcx, r11 + _0xffffff8000ec1425: add rcx, rbx + _0xffffff8000ec1428: mov edx, 0xe6cd8ac8 + _0xffffff8000ec142d: mov r13, qword ptr [rbp - 0x3d0] + _0xffffff8000ec1434: xor edx, dword ptr [r13 + rcx*4] + _0xffffff8000ec1439: lea r9d, [rax + rdx] + _0xffffff8000ec143d: and edx, eax + _0xffffff8000ec143f: add edx, edx + _0xffffff8000ec1441: sub r9d, edx + _0xffffff8000ec1444: mov eax, r9d + _0xffffff8000ec1447: shr eax, 8 + _0xffffff8000ec144a: mov ecx, eax + _0xffffff8000ec144c: and ecx, 0x1ecd4d + _0xffffff8000ec1452: and eax, 0xe132b2 + _0xffffff8000ec1457: add eax, ecx + _0xffffff8000ec1459: xor eax, 0xea38b5b1 + _0xffffff8000ec145e: lea ecx, [rax + rax] + _0xffffff8000ec1461: and ecx, 0x62 + _0xffffff8000ec1464: neg ecx + _0xffffff8000ec1466: lea eax, [rax + rcx + 0xb1] + _0xffffff8000ec146d: mov cl, al + _0xffffff8000ec146f: xor cl, 0x7c + _0xffffff8000ec1472: mov dl, cl + _0xffffff8000ec1474: add dl, dl + _0xffffff8000ec1476: mov r11b, dl + _0xffffff8000ec1479: and r11b, 0x40 + _0xffffff8000ec147d: mov bl, cl + _0xffffff8000ec147f: and bl, 0xa1 + _0xffffff8000ec1482: or bl, 0x42 + _0xffffff8000ec1485: add bl, 0x60 + _0xffffff8000ec1488: and bl, 0xa1 + _0xffffff8000ec148b: or bl, r11b + _0xffffff8000ec148e: mov r11b, cl + _0xffffff8000ec1491: and r11b, 0x14 + _0xffffff8000ec1495: and dl, 0x20 + _0xffffff8000ec1498: or dl, r11b + _0xffffff8000ec149b: and cl, 0x4a + _0xffffff8000ec149e: or cl, dl + _0xffffff8000ec14a0: xor cl, 0x10 + _0xffffff8000ec14a3: add cl, bl + _0xffffff8000ec14a5: add al, al + _0xffffff8000ec14a7: and al, 0xf8 + _0xffffff8000ec14a9: mov dl, cl + _0xffffff8000ec14ab: xor dl, al + _0xffffff8000ec14ad: and al, cl + _0xffffff8000ec14af: add al, al + _0xffffff8000ec14b1: add al, dl + _0xffffff8000ec14b3: movzx eax, al + _0xffffff8000ec14b6: cmp al, 0x2c + _0xffffff8000ec14b8: sbb rbx, rbx + _0xffffff8000ec14bb: and ebx, 0x100 + _0xffffff8000ec14c1: add ebx, eax + _0xffffff8000ec14c3: add ebx, 0x50c4d2b9 + _0xffffff8000ec14c9: shl rbx, 0x20 + _0xffffff8000ec14cd: movabs r15, 0xaf3b2d1b00000000 + _0xffffff8000ec14d7: add r15, rbx + _0xffffff8000ec14da: mov rbx, r15 + _0xffffff8000ec14dd: sar rbx, 0x1f + _0xffffff8000ec14e1: movabs r12, 0xbffdbffd409db3b8 + _0xffffff8000ec14eb: and r12, rbx + _0xffffff8000ec14ee: sar r15, 0x20 + _0xffffff8000ec14f2: movabs rbx, 0x5ffedffea04ed9dc + _0xffffff8000ec14fc: xor rbx, r15 + _0xffffff8000ec14ff: add rbx, r12 + _0xffffff8000ec1502: add rbx, qword ptr [r14 + 0x98] + _0xffffff8000ec1509: movabs r15, 0xa00120015fb12624 + _0xffffff8000ec1513: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec1517: mov cl, al + _0xffffff8000ec1519: add cl, cl + _0xffffff8000ec151b: xor al, 0x2f + _0xffffff8000ec151d: and cl, 0x5e + _0xffffff8000ec1520: add cl, al + _0xffffff8000ec1522: add cl, 0xdd + _0xffffff8000ec1525: movzx eax, cl + _0xffffff8000ec1528: cmp al, 0xc + _0xffffff8000ec152a: sbb rbx, rbx + _0xffffff8000ec152d: and ebx, 0x100 + _0xffffff8000ec1533: add ebx, eax + _0xffffff8000ec1535: add ebx, 0x33920eab + _0xffffff8000ec153b: shl rbx, 0x20 + _0xffffff8000ec153f: movabs r15, 0xcc6df14900000000 + _0xffffff8000ec1549: add r15, rbx + _0xffffff8000ec154c: mov rbx, r15 + _0xffffff8000ec154f: sar rbx, 0x1f + _0xffffff8000ec1553: movabs r12, 0xff9bd98fef7cdfe + _0xffffff8000ec155d: and r12, rbx + _0xffffff8000ec1560: sar r15, 0x20 + _0xffffff8000ec1564: movabs rbx, 0x27fcdecc7f7be6ff + _0xffffff8000ec156e: xor rbx, r15 + _0xffffff8000ec1571: add rbx, r12 + _0xffffff8000ec1574: lea rbx, [r13 + rbx*4] + _0xffffff8000ec1579: movabs r15, 0x600c84ce02106404 + _0xffffff8000ec1583: mov eax, dword ptr [rbp - 0x3c4] + _0xffffff8000ec1589: xor eax, dword ptr [r15 + rbx] + _0xffffff8000ec158d: mov dword ptr [rbp - 0x3c4], eax + _0xffffff8000ec1593: mov ecx, eax + _0xffffff8000ec1595: shr ecx, 0xf + _0xffffff8000ec1598: and ecx, 0x2c + _0xffffff8000ec159b: mov edx, eax + _0xffffff8000ec159d: shr edx, 0x10 + _0xffffff8000ec15a0: add edx, 0x16 + _0xffffff8000ec15a3: sub edx, ecx + _0xffffff8000ec15a5: xor edx, 0x16 + _0xffffff8000ec15a8: mov cl, dl + _0xffffff8000ec15aa: add cl, cl + _0xffffff8000ec15ac: xor dl, 0x3c + _0xffffff8000ec15af: and cl, 0x78 + _0xffffff8000ec15b2: add cl, dl + _0xffffff8000ec15b4: add cl, 0xfc + _0xffffff8000ec15b7: movzx ecx, cl + _0xffffff8000ec15ba: cmp cl, 0x38 + _0xffffff8000ec15bd: sbb rbx, rbx + _0xffffff8000ec15c0: and ebx, 0x100 + _0xffffff8000ec15c6: add ebx, ecx + _0xffffff8000ec15c8: add ebx, 0x594dcf77 + _0xffffff8000ec15ce: shl rbx, 0x20 + _0xffffff8000ec15d2: movabs r15, 0xa6b2305100000000 + _0xffffff8000ec15dc: add r15, rbx + _0xffffff8000ec15df: mov rbx, r15 + _0xffffff8000ec15e2: sar rbx, 0x1f + _0xffffff8000ec15e6: movabs r12, 0xd06f73c9d1cea67e + _0xffffff8000ec15f0: and r12, rbx + _0xffffff8000ec15f3: sar r15, 0x20 + _0xffffff8000ec15f7: movabs rbx, 0x6837b9e4e8e7533f + _0xffffff8000ec1601: xor rbx, r15 + _0xffffff8000ec1604: add rbx, r12 + _0xffffff8000ec1607: add rbx, qword ptr [r14 + 0x180] + _0xffffff8000ec160e: movabs r15, 0x97c8461b1718acc1 + _0xffffff8000ec1618: mov cl, byte ptr [r15 + rbx] + _0xffffff8000ec161c: mov dl, cl + _0xffffff8000ec161e: add dl, dl + _0xffffff8000ec1620: xor cl, 0x71 + _0xffffff8000ec1623: and dl, 0xe2 + _0xffffff8000ec1626: add dl, cl + _0xffffff8000ec1628: dec dl + _0xffffff8000ec162a: movzx ecx, dl + _0xffffff8000ec162d: cmp cl, 0x70 + _0xffffff8000ec1630: sbb rbx, rbx + _0xffffff8000ec1633: and ebx, 0x100 + _0xffffff8000ec1639: add ebx, ecx + _0xffffff8000ec163b: add ebx, 0x60585b3e + _0xffffff8000ec1641: shl rbx, 0x20 + _0xffffff8000ec1645: movabs r15, 0x9fa7a45200000000 + _0xffffff8000ec164f: add r15, rbx + _0xffffff8000ec1652: mov rbx, r15 + _0xffffff8000ec1655: sar rbx, 0x1f + _0xffffff8000ec1659: movabs r12, 0x3df5ecf7dff7bfee + _0xffffff8000ec1663: and r12, rbx + _0xffffff8000ec1666: sar r15, 0x20 + _0xffffff8000ec166a: movabs rbx, 0x3efaf67beffbdff7 + _0xffffff8000ec1674: xor rbx, r15 + _0xffffff8000ec1677: add rbx, r12 + _0xffffff8000ec167a: lea rbx, [r10 + rbx*4] + _0xffffff8000ec167e: movabs r15, 0x414261040108024 + _0xffffff8000ec1688: mov edx, dword ptr [r15 + rbx] + _0xffffff8000ec168c: mov ecx, esi + _0xffffff8000ec168e: shr ecx, 7 + _0xffffff8000ec1691: mov r11d, 0xcc + _0xffffff8000ec1697: sub r11d, ecx + _0xffffff8000ec169a: mov ebx, r11d + _0xffffff8000ec169d: and ebx, ecx + _0xffffff8000ec169f: xor r11d, ecx + _0xffffff8000ec16a2: lea r11d, [r11 + rbx*2] + _0xffffff8000ec16a6: and r11d, ecx + _0xffffff8000ec16a9: neg r11d + _0xffffff8000ec16ac: mov ecx, esi + _0xffffff8000ec16ae: shr ecx, 8 + _0xffffff8000ec16b1: add ecx, 0xe6 + _0xffffff8000ec16b7: mov ebx, ecx + _0xffffff8000ec16b9: xor ebx, r11d + _0xffffff8000ec16bc: mov r15d, ebx + _0xffffff8000ec16bf: and r15d, 0x12 + _0xffffff8000ec16c3: mov r12d, ecx + _0xffffff8000ec16c6: and r12d, r11d + _0xffffff8000ec16c9: mov eax, r12d + _0xffffff8000ec16cc: and eax, 0x12 + _0xffffff8000ec16cf: add eax, eax + _0xffffff8000ec16d1: add eax, r15d + _0xffffff8000ec16d4: and ebx, 0xa4 + _0xffffff8000ec16da: and r12d, 0x4a5248a4 + _0xffffff8000ec16e1: add r12d, r12d + _0xffffff8000ec16e4: add r12d, ebx + _0xffffff8000ec16e7: and r11d, 0x49 + _0xffffff8000ec16eb: and ecx, 0x49 + _0xffffff8000ec16ee: add ecx, r11d + _0xffffff8000ec16f1: add ecx, r12d + _0xffffff8000ec16f4: add ecx, eax + _0xffffff8000ec16f6: xor ecx, 0xe6 + _0xffffff8000ec16fc: mov al, cl + _0xffffff8000ec16fe: xor al, 0x1e + _0xffffff8000ec1700: mov r11b, 0xf4 + _0xffffff8000ec1703: sub r11b, al + _0xffffff8000ec1706: mov bl, r11b + _0xffffff8000ec1709: xor bl, al + _0xffffff8000ec170b: and r11b, al + _0xffffff8000ec170e: add r11b, r11b + _0xffffff8000ec1711: add r11b, bl + _0xffffff8000ec1714: mov bl, r11b + _0xffffff8000ec1717: and bl, 9 + _0xffffff8000ec171a: mov r15b, al + _0xffffff8000ec171d: and r15b, 9 + _0xffffff8000ec1721: or r15b, 2 + _0xffffff8000ec1725: sub r15b, bl + _0xffffff8000ec1728: and bl, al + _0xffffff8000ec172a: mov r12b, al + _0xffffff8000ec172d: and r12b, 0xa2 + _0xffffff8000ec1731: mov r10b, r11b + _0xffffff8000ec1734: and r10b, r12b + _0xffffff8000ec1737: add r10b, r10b + _0xffffff8000ec173a: mov r13b, r11b + _0xffffff8000ec173d: and r13b, 0xa2 + _0xffffff8000ec1741: or r13b, 0x44 + _0xffffff8000ec1745: sub r13b, r12b + _0xffffff8000ec1748: and r13b, 0xa2 + _0xffffff8000ec174c: or r13b, r10b + _0xffffff8000ec174f: add bl, bl + _0xffffff8000ec1751: and r15b, 9 + _0xffffff8000ec1755: or r15b, bl + _0xffffff8000ec1758: and r11b, 0x54 + _0xffffff8000ec175c: and al, 0x54 + _0xffffff8000ec175e: add al, r11b + _0xffffff8000ec1761: add al, r15b + _0xffffff8000ec1764: add al, r13b + _0xffffff8000ec1767: add cl, cl + _0xffffff8000ec1769: and cl, 0x3c + _0xffffff8000ec176c: mov r10b, al + _0xffffff8000ec176f: xor r10b, cl + _0xffffff8000ec1772: and cl, al + _0xffffff8000ec1774: add cl, cl + _0xffffff8000ec1776: add cl, r10b + _0xffffff8000ec1779: movzx ebx, cl + _0xffffff8000ec177c: cmp bl, 0x12 + _0xffffff8000ec177f: sbb r15, r15 + _0xffffff8000ec1782: and r15, 0x100 + _0xffffff8000ec1789: add r15, rbx + _0xffffff8000ec178c: add r15, -0x12 + _0xffffff8000ec1790: movabs rbx, 0x1f73b7fe39f7feb6 + _0xffffff8000ec179a: mov r12, r15 + _0xffffff8000ec179d: and r12, rbx + _0xffffff8000ec17a0: xor r15, rbx + _0xffffff8000ec17a3: lea rbx, [r15 + r12*2] + _0xffffff8000ec17a7: add rbx, qword ptr [r14 + 0xb8] + _0xffffff8000ec17ae: movabs r15, 0xe08c4801c608014a + _0xffffff8000ec17b8: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec17bc: mov cl, al + _0xffffff8000ec17be: add cl, cl + _0xffffff8000ec17c0: xor al, 0x53 + _0xffffff8000ec17c2: and cl, 0xa6 + _0xffffff8000ec17c5: add cl, al + _0xffffff8000ec17c7: add cl, 0xf0 + _0xffffff8000ec17ca: movzx ebx, cl + _0xffffff8000ec17cd: cmp bl, 0x43 + _0xffffff8000ec17d0: sbb r15, r15 + _0xffffff8000ec17d3: and r15, 0x100 + _0xffffff8000ec17da: add r15, rbx + _0xffffff8000ec17dd: add r15, -0x43 + _0xffffff8000ec17e1: movabs rbx, 0x17fcff3ffecdfd5f + _0xffffff8000ec17eb: and rbx, r15 + _0xffffff8000ec17ee: movabs r12, 0x37fcff3ffecdfd5f + _0xffffff8000ec17f8: xor r12, r15 + _0xffffff8000ec17fb: lea rbx, [r12 + rbx*2] + _0xffffff8000ec17ff: mov r13, qword ptr [rbp - 0x3d0] + _0xffffff8000ec1806: lea rbx, [r13 + rbx*4] + _0xffffff8000ec180b: mov eax, r8d + _0xffffff8000ec180e: shr eax, 0xf + _0xffffff8000ec1811: and eax, 0x80 + _0xffffff8000ec1816: mov ecx, r8d + _0xffffff8000ec1819: shr ecx, 0x10 + _0xffffff8000ec181c: add ecx, 0xc0 + _0xffffff8000ec1822: sub ecx, eax + _0xffffff8000ec1824: xor ecx, 0xc0 + _0xffffff8000ec182a: mov al, cl + _0xffffff8000ec182c: add al, al + _0xffffff8000ec182e: xor cl, 0x69 + _0xffffff8000ec1831: and al, 0xd2 + _0xffffff8000ec1833: add al, cl + _0xffffff8000ec1835: add al, 0xb8 + _0xffffff8000ec1837: movzx r15d, al + _0xffffff8000ec183b: cmp r15b, 0x21 + _0xffffff8000ec183f: sbb r12, r12 + _0xffffff8000ec1842: and r12, 0x100 + _0xffffff8000ec1849: add r12, r15 + _0xffffff8000ec184c: add r12, -0x21 + _0xffffff8000ec1850: movabs r15, 0x5dafef2ffefddffd + _0xffffff8000ec185a: mov rax, r12 + _0xffffff8000ec185d: and rax, r15 + _0xffffff8000ec1860: xor r12, r15 + _0xffffff8000ec1863: lea r15, [r12 + rax*2] + _0xffffff8000ec1867: add r15, qword ptr [r14 + 0xe0] + _0xffffff8000ec186e: movabs r12, 0xa25010d001022003 + _0xffffff8000ec1878: mov al, byte ptr [r12 + r15] + _0xffffff8000ec187c: mov cl, al + _0xffffff8000ec187e: add cl, cl + _0xffffff8000ec1880: xor al, 0x3f + _0xffffff8000ec1882: and cl, 0x7e + _0xffffff8000ec1885: add cl, al + _0xffffff8000ec1887: add cl, 0xc8 + _0xffffff8000ec188a: movzx eax, cl + _0xffffff8000ec188d: cmp al, 7 + _0xffffff8000ec188f: sbb r15, r15 + _0xffffff8000ec1892: and r15d, 0x100 + _0xffffff8000ec1899: add r15d, eax + _0xffffff8000ec189c: add r15d, 0x4ab3a31d + _0xffffff8000ec18a3: shl r15, 0x20 + _0xffffff8000ec18a7: movabs r12, 0xb54c5cdc00000000 + _0xffffff8000ec18b1: add r12, r15 + _0xffffff8000ec18b4: mov r15, r12 + _0xffffff8000ec18b7: sar r15, 0x1f + _0xffffff8000ec18bb: movabs rax, 0x2fff5352fffffbfe + _0xffffff8000ec18c5: and rax, r15 + _0xffffff8000ec18c8: sar r12, 0x20 + _0xffffff8000ec18cc: movabs r15, 0x17ffa9a97ffffdff + _0xffffff8000ec18d6: xor r15, r12 + _0xffffff8000ec18d9: add r15, rax + _0xffffff8000ec18dc: mov r10, qword ptr [rbp - 0x3d8] + _0xffffff8000ec18e3: lea r15, [r10 + r15*4] + _0xffffff8000ec18e7: mov eax, edi + _0xffffff8000ec18e9: shr eax, 0x17 + _0xffffff8000ec18ec: and eax, 0x84 + _0xffffff8000ec18f1: mov ecx, edi + _0xffffff8000ec18f3: shr ecx, 0x18 + _0xffffff8000ec18f6: add ecx, 0xc2 + _0xffffff8000ec18fc: sub ecx, eax + _0xffffff8000ec18fe: xor ecx, 0xc2 + _0xffffff8000ec1904: mov al, cl + _0xffffff8000ec1906: add al, al + _0xffffff8000ec1908: xor cl, 0x6e + _0xffffff8000ec190b: and al, 0xdc + _0xffffff8000ec190d: add al, cl + _0xffffff8000ec190f: add al, 0xfc + _0xffffff8000ec1911: movzx eax, al + _0xffffff8000ec1914: cmp al, 0x6a + _0xffffff8000ec1916: sbb r12, r12 + _0xffffff8000ec1919: and r12d, 0x100 + _0xffffff8000ec1920: add r12d, eax + _0xffffff8000ec1923: add r12d, 0x37f63b81 + _0xffffff8000ec192a: shl r12, 0x20 + _0xffffff8000ec192e: movabs rax, 0xc809c41500000000 + _0xffffff8000ec1938: add rax, r12 + _0xffffff8000ec193b: mov r12, rax + _0xffffff8000ec193e: sar r12, 0x1f + _0xffffff8000ec1942: movabs rcx, 0xcecbdffedd4aef9a + _0xffffff8000ec194c: and rcx, r12 + _0xffffff8000ec194f: sar rax, 0x20 + _0xffffff8000ec1953: movabs r12, 0x6765efff6ea577cd + _0xffffff8000ec195d: xor r12, rax + _0xffffff8000ec1960: add r12, rcx + _0xffffff8000ec1963: add r12, qword ptr [r14 + 0x108] + _0xffffff8000ec196a: movabs rax, 0x989a1000915a8833 + _0xffffff8000ec1974: mov al, byte ptr [rax + r12] + _0xffffff8000ec1978: mov cl, al + _0xffffff8000ec197a: add cl, cl + _0xffffff8000ec197c: xor al, 0x6f + _0xffffff8000ec197e: and cl, 0xde + _0xffffff8000ec1981: add cl, al + _0xffffff8000ec1983: dec cl + _0xffffff8000ec1985: movzx eax, cl + _0xffffff8000ec1988: cmp al, 0x6e + _0xffffff8000ec198a: sbb r12, r12 + _0xffffff8000ec198d: and r12d, 0x100 + _0xffffff8000ec1994: add r12d, eax + _0xffffff8000ec1997: add r12d, 0x45c53f9a + _0xffffff8000ec199e: shl r12, 0x20 + _0xffffff8000ec19a2: movabs rax, 0xba3abff800000000 + _0xffffff8000ec19ac: add rax, r12 + _0xffffff8000ec19af: mov r12, rax + _0xffffff8000ec19b2: sar r12, 0x1f + _0xffffff8000ec19b6: movabs rcx, 0x3f5ff79efcedbffa + _0xffffff8000ec19c0: and rcx, r12 + _0xffffff8000ec19c3: sar rax, 0x20 + _0xffffff8000ec19c7: movabs r12, 0x3faffbcf7e76dffd + _0xffffff8000ec19d1: xor r12, rax + _0xffffff8000ec19d4: add r12, rcx + _0xffffff8000ec19d7: mov rax, qword ptr [rbp - 0x3b8] + _0xffffff8000ec19de: lea r12, [rax + r12*4] + _0xffffff8000ec19e2: movabs rcx, 0x14010c20624800c + _0xffffff8000ec19ec: mov r11d, dword ptr [rcx + r12] + _0xffffff8000ec19f0: movabs r12, 0xa001595a00000804 + _0xffffff8000ec19fa: xor r11d, dword ptr [r12 + r15] + _0xffffff8000ec19fe: movabs r15, 0x200c030004c80a84 + _0xffffff8000ec1a08: xor r11d, dword ptr [r15 + rbx] + _0xffffff8000ec1a0c: mov cl, r9b + _0xffffff8000ec1a0f: add cl, cl + _0xffffff8000ec1a11: mov bl, r9b + _0xffffff8000ec1a14: xor bl, 0x7a + _0xffffff8000ec1a17: mov r15b, 0xa0 + _0xffffff8000ec1a1a: sub r15b, bl + _0xffffff8000ec1a1d: mov r12b, r15b + _0xffffff8000ec1a20: and r12b, bl + _0xffffff8000ec1a23: xor r15b, bl + _0xffffff8000ec1a26: add r15b, bl + _0xffffff8000ec1a29: add r12b, r12b + _0xffffff8000ec1a2c: add r12b, r15b + _0xffffff8000ec1a2f: mov bl, cl + _0xffffff8000ec1a31: and bl, 0xb2 + _0xffffff8000ec1a34: xor bl, 0x18 + _0xffffff8000ec1a37: and cl, 0x4c + _0xffffff8000ec1a3a: xor cl, 0x18 + _0xffffff8000ec1a3d: mov r15b, cl + _0xffffff8000ec1a40: add r15b, bl + _0xffffff8000ec1a43: and cl, bl + _0xffffff8000ec1a45: add cl, cl + _0xffffff8000ec1a47: sub r15b, cl + _0xffffff8000ec1a4a: sar r15b, 1 + _0xffffff8000ec1a4d: add r12b, r15b + _0xffffff8000ec1a50: add r12b, 0xfa + _0xffffff8000ec1a54: xor r15b, 0xfa + _0xffffff8000ec1a58: sub r12b, r15b + _0xffffff8000ec1a5b: movzx ecx, r12b + _0xffffff8000ec1a5f: cmp cl, 0x1a + _0xffffff8000ec1a62: sbb rbx, rbx + _0xffffff8000ec1a65: and ebx, 0x100 + _0xffffff8000ec1a6b: add ebx, ecx + _0xffffff8000ec1a6d: add ebx, 0x4f23e636 + _0xffffff8000ec1a73: shl rbx, 0x20 + _0xffffff8000ec1a77: movabs r15, 0xb0dc19b000000000 + _0xffffff8000ec1a81: add r15, rbx + _0xffffff8000ec1a84: mov rbx, r15 + _0xffffff8000ec1a87: sar rbx, 0x1f + _0xffffff8000ec1a8b: movabs r12, 0xf6ddf79dbfffb7fc + _0xffffff8000ec1a95: and r12, rbx + _0xffffff8000ec1a98: sar r15, 0x20 + _0xffffff8000ec1a9c: movabs rbx, 0x7b6efbcedfffdbfe + _0xffffff8000ec1aa6: xor rbx, r15 + _0xffffff8000ec1aa9: add rbx, r12 + _0xffffff8000ec1aac: add rbx, qword ptr [r14 + 0x90] + _0xffffff8000ec1ab3: movabs r15, 0x8491043120002402 + _0xffffff8000ec1abd: mov cl, byte ptr [r15 + rbx] + _0xffffff8000ec1ac1: mov bl, cl + _0xffffff8000ec1ac3: add bl, bl + _0xffffff8000ec1ac5: xor cl, 0x79 + _0xffffff8000ec1ac8: and bl, 0xf2 + _0xffffff8000ec1acb: add bl, cl + _0xffffff8000ec1acd: add bl, 0xa0 + _0xffffff8000ec1ad0: movzx ecx, bl + _0xffffff8000ec1ad3: cmp cl, 0x19 + _0xffffff8000ec1ad6: sbb rbx, rbx + _0xffffff8000ec1ad9: and ebx, 0x100 + _0xffffff8000ec1adf: add ebx, ecx + _0xffffff8000ec1ae1: add ebx, 0x646d84dc + _0xffffff8000ec1ae7: shl rbx, 0x20 + _0xffffff8000ec1aeb: movabs r15, 0x9b927b0b00000000 + _0xffffff8000ec1af5: add r15, rbx + _0xffffff8000ec1af8: mov rbx, r15 + _0xffffff8000ec1afb: sar rbx, 0x1f + _0xffffff8000ec1aff: movabs r12, 0x3f736b5fffbbfbe2 + _0xffffff8000ec1b09: and r12, rbx + _0xffffff8000ec1b0c: sar r15, 0x20 + _0xffffff8000ec1b10: movabs rbx, 0x3fb9b5afffddfdf1 + _0xffffff8000ec1b1a: xor rbx, r15 + _0xffffff8000ec1b1d: add rbx, r12 + _0xffffff8000ec1b20: mov r12, qword ptr [rbp - 0x3c0] + _0xffffff8000ec1b27: lea rbx, [r12 + rbx*4] + _0xffffff8000ec1b2b: movabs r15, 0x11929400088083c + _0xffffff8000ec1b35: xor r11d, dword ptr [r15 + rbx] + _0xffffff8000ec1b39: mov ecx, r11d + _0xffffff8000ec1b3c: shr ecx, 0x17 + _0xffffff8000ec1b3f: and ecx, 0xd8 + _0xffffff8000ec1b45: mov ebx, r11d + _0xffffff8000ec1b48: shr ebx, 0x18 + _0xffffff8000ec1b4b: add ebx, 0xec + _0xffffff8000ec1b51: sub ebx, ecx + _0xffffff8000ec1b53: xor ebx, 0xec + _0xffffff8000ec1b59: mov cl, bl + _0xffffff8000ec1b5b: add cl, cl + _0xffffff8000ec1b5d: xor bl, 0x6f + _0xffffff8000ec1b60: and cl, 0xde + _0xffffff8000ec1b63: add cl, bl + _0xffffff8000ec1b65: dec cl + _0xffffff8000ec1b67: movzx ecx, cl + _0xffffff8000ec1b6a: cmp cl, 0x6e + _0xffffff8000ec1b6d: sbb rbx, rbx + _0xffffff8000ec1b70: and ebx, 0x100 + _0xffffff8000ec1b76: add ebx, ecx + _0xffffff8000ec1b78: add ebx, 0x5ecf58e4 + _0xffffff8000ec1b7e: shl rbx, 0x20 + _0xffffff8000ec1b82: movabs r15, 0xa130a6ae00000000 + _0xffffff8000ec1b8c: add r15, rbx + _0xffffff8000ec1b8f: mov rbx, r15 + _0xffffff8000ec1b92: sar rbx, 0x1f + _0xffffff8000ec1b96: movabs rcx, 0xbfdf7fecbbf7befa + _0xffffff8000ec1ba0: and rcx, rbx + _0xffffff8000ec1ba3: sar r15, 0x20 + _0xffffff8000ec1ba7: movabs rbx, 0x5fefbff65dfbdf7d + _0xffffff8000ec1bb1: xor rbx, r15 + _0xffffff8000ec1bb4: add rbx, rcx + _0xffffff8000ec1bb7: add rbx, qword ptr [r14 + 0x128] + _0xffffff8000ec1bbe: movabs r15, 0xa0104009a2042083 + _0xffffff8000ec1bc8: mov cl, byte ptr [r15 + rbx] + _0xffffff8000ec1bcc: mov bl, cl + _0xffffff8000ec1bce: add bl, bl + _0xffffff8000ec1bd0: xor cl, 0x47 + _0xffffff8000ec1bd3: and bl, 0x8e + _0xffffff8000ec1bd6: add bl, cl + _0xffffff8000ec1bd8: add bl, 0xfc + _0xffffff8000ec1bdb: movzx ecx, bl + _0xffffff8000ec1bde: cmp cl, 0x43 + _0xffffff8000ec1be1: sbb rbx, rbx + _0xffffff8000ec1be4: and ebx, 0x100 + _0xffffff8000ec1bea: add ebx, ecx + _0xffffff8000ec1bec: add ebx, 0x784e473d + _0xffffff8000ec1bf2: shl rbx, 0x20 + _0xffffff8000ec1bf6: movabs r15, 0x87b1b88000000000 + _0xffffff8000ec1c00: add r15, rbx + _0xffffff8000ec1c03: mov rbx, r15 + _0xffffff8000ec1c06: sar rbx, 0x1f + _0xffffff8000ec1c0a: movabs rcx, 0x2d7dfe7fb7dfc772 + _0xffffff8000ec1c14: and rcx, rbx + _0xffffff8000ec1c17: sar r15, 0x20 + _0xffffff8000ec1c1b: movabs rbx, 0x36beff3fdbefe3b9 + _0xffffff8000ec1c25: xor rbx, r15 + _0xffffff8000ec1c28: add rbx, rcx + _0xffffff8000ec1c2b: lea rbx, [rax + rbx*4] + _0xffffff8000ec1c2f: movabs r15, 0x250403009040711c + _0xffffff8000ec1c39: xor edx, dword ptr [r15 + rbx] + _0xffffff8000ec1c3d: mov ecx, r9d + _0xffffff8000ec1c40: shr ecx, 0x10 + _0xffffff8000ec1c43: mov ebx, ecx + _0xffffff8000ec1c45: and ebx, 0xab3b + _0xffffff8000ec1c4b: and ecx, 0x54c4 + _0xffffff8000ec1c51: add ecx, ebx + _0xffffff8000ec1c53: mov ebx, ecx + _0xffffff8000ec1c55: xor ebx, 0x63 + _0xffffff8000ec1c58: mov r15d, r9d + _0xffffff8000ec1c5b: shr r15d, 0xf + _0xffffff8000ec1c5f: and r15d, 0xc6 + _0xffffff8000ec1c66: sub ebx, r15d + _0xffffff8000ec1c69: and ecx, 0x63 + _0xffffff8000ec1c6c: lea ecx, [rbx + rcx*2] + _0xffffff8000ec1c6f: xor ecx, 0xa05418bb + _0xffffff8000ec1c75: mov ebx, ecx + _0xffffff8000ec1c77: and ebx, 0xad + _0xffffff8000ec1c7d: and ecx, 0x52 + _0xffffff8000ec1c80: add ecx, 0x24 + _0xffffff8000ec1c83: add ecx, 0x30 + _0xffffff8000ec1c86: and ecx, 0x52 + _0xffffff8000ec1c89: add ecx, ebx + _0xffffff8000ec1c8b: xor ecx, 0x88 + _0xffffff8000ec1c91: mov bl, cl + _0xffffff8000ec1c93: add bl, bl + _0xffffff8000ec1c95: mov r15b, bl + _0xffffff8000ec1c98: and r15b, 0x28 + _0xffffff8000ec1c9c: xor r15b, 0x62 + _0xffffff8000ec1ca0: and bl, 0xd6 + _0xffffff8000ec1ca3: xor bl, 0x62 + _0xffffff8000ec1ca6: mov al, bl + _0xffffff8000ec1ca8: add al, r15b + _0xffffff8000ec1cab: and bl, r15b + _0xffffff8000ec1cae: add bl, bl + _0xffffff8000ec1cb0: sub al, bl + _0xffffff8000ec1cb2: and al, 0xbc + _0xffffff8000ec1cb4: xor cl, 0x2b + _0xffffff8000ec1cb7: mov bl, cl + _0xffffff8000ec1cb9: add bl, bl + _0xffffff8000ec1cbb: add cl, 0x73 + _0xffffff8000ec1cbe: and bl, 0xea + _0xffffff8000ec1cc1: sub cl, bl + _0xffffff8000ec1cc3: mov bl, al + _0xffffff8000ec1cc5: xor bl, cl + _0xffffff8000ec1cc7: and cl, al + _0xffffff8000ec1cc9: add cl, cl + _0xffffff8000ec1ccb: add cl, bl + _0xffffff8000ec1ccd: movzx eax, cl + _0xffffff8000ec1cd0: cmp al, 0x5c + _0xffffff8000ec1cd2: sbb rbx, rbx + _0xffffff8000ec1cd5: and ebx, 0x100 + _0xffffff8000ec1cdb: add ebx, eax + _0xffffff8000ec1cdd: add ebx, 0x36fcee81 + _0xffffff8000ec1ce3: shl rbx, 0x20 + _0xffffff8000ec1ce7: movabs r15, 0xc903112300000000 + _0xffffff8000ec1cf1: add r15, rbx + _0xffffff8000ec1cf4: mov rbx, r15 + _0xffffff8000ec1cf7: sar rbx, 0x1f + _0xffffff8000ec1cfb: movabs rax, 0xaec3ee7fb73b9b60 + _0xffffff8000ec1d05: and rax, rbx + _0xffffff8000ec1d08: sar r15, 0x20 + _0xffffff8000ec1d0c: movabs rbx, 0x5761f73fdb9dcdb0 + _0xffffff8000ec1d16: xor rbx, r15 + _0xffffff8000ec1d19: add rbx, rax + _0xffffff8000ec1d1c: add rbx, qword ptr [r14 + 0xa0] + _0xffffff8000ec1d23: movabs r15, 0xa89e08c024623250 + _0xffffff8000ec1d2d: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec1d31: mov cl, al + _0xffffff8000ec1d33: add cl, cl + _0xffffff8000ec1d35: xor al, 0x6b + _0xffffff8000ec1d37: and cl, 0xd6 + _0xffffff8000ec1d3a: add cl, al + _0xffffff8000ec1d3c: add cl, 0xf8 + _0xffffff8000ec1d3f: movzx eax, cl + _0xffffff8000ec1d42: cmp al, 0x63 + _0xffffff8000ec1d44: sbb rbx, rbx + _0xffffff8000ec1d47: and ebx, 0x100 + _0xffffff8000ec1d4d: add ebx, eax + _0xffffff8000ec1d4f: add ebx, 0x5d436a14 + _0xffffff8000ec1d55: shl rbx, 0x20 + _0xffffff8000ec1d59: movabs r15, 0xa2bc958900000000 + _0xffffff8000ec1d63: add r15, rbx + _0xffffff8000ec1d66: mov rbx, r15 + _0xffffff8000ec1d69: sar rbx, 0x1f + _0xffffff8000ec1d6d: movabs rax, 0x23edefbbfffeef5e + _0xffffff8000ec1d77: and rax, rbx + _0xffffff8000ec1d7a: sar r15, 0x20 + _0xffffff8000ec1d7e: movabs rbx, 0x31f6f7ddffff77af + _0xffffff8000ec1d88: xor rbx, r15 + _0xffffff8000ec1d8b: add rbx, rax + _0xffffff8000ec1d8e: lea rbx, [r10 + rbx*4] + _0xffffff8000ec1d92: mov eax, esi + _0xffffff8000ec1d94: shr eax, 0x18 + _0xffffff8000ec1d97: imul ecx, eax, 0xfd44eb76 + _0xffffff8000ec1d9d: and ecx, 0xfd44eb76 + _0xffffff8000ec1da3: imul eax, eax, 0xfea275bb + _0xffffff8000ec1da9: xor eax, 0xfea275bb + _0xffffff8000ec1dae: add eax, ecx + _0xffffff8000ec1db0: imul eax, eax, 0x48170773 + _0xffffff8000ec1db6: add eax, 0xbcafc8ed + _0xffffff8000ec1dbb: mov ecx, esi + _0xffffff8000ec1dbd: shr ecx, 0x17 + _0xffffff8000ec1dc0: and ecx, 2 + _0xffffff8000ec1dc3: mov r15d, 0x43503713 + _0xffffff8000ec1dc9: sub r15d, ecx + _0xffffff8000ec1dcc: mov ecx, eax + _0xffffff8000ec1dce: and ecx, r15d + _0xffffff8000ec1dd1: xor r15d, eax + _0xffffff8000ec1dd4: lea eax, [r15 + rcx*2] + _0xffffff8000ec1dd8: xor eax, 0x455fbe10 + _0xffffff8000ec1ddd: lea ecx, [rax + rax] + _0xffffff8000ec1de0: and ecx, 0x22 + _0xffffff8000ec1de3: neg ecx + _0xffffff8000ec1de5: lea eax, [rax + rcx + 0x11] + _0xffffff8000ec1de9: mov cl, 1 + _0xffffff8000ec1deb: sub cl, al + _0xffffff8000ec1ded: mov r15b, cl + _0xffffff8000ec1df0: xor r15b, al + _0xffffff8000ec1df3: and cl, al + _0xffffff8000ec1df5: add cl, cl + _0xffffff8000ec1df7: add cl, r15b + _0xffffff8000ec1dfa: mov r15b, al + _0xffffff8000ec1dfd: shl r15b, cl + _0xffffff8000ec1e00: and r15b, 0x1a + _0xffffff8000ec1e04: xor al, 0x32 + _0xffffff8000ec1e06: mov cl, al + _0xffffff8000ec1e08: add cl, cl + _0xffffff8000ec1e0a: add al, 0x3f + _0xffffff8000ec1e0c: and cl, 0x7e + _0xffffff8000ec1e0f: sub al, cl + _0xffffff8000ec1e11: add al, r15b + _0xffffff8000ec1e14: movzx r15d, al + _0xffffff8000ec1e18: cmp r15b, 0xd + _0xffffff8000ec1e1c: sbb rax, rax + _0xffffff8000ec1e1f: and rax, 0x100 + _0xffffff8000ec1e25: add rax, r15 + _0xffffff8000ec1e28: add rax, -0xd + _0xffffff8000ec1e2c: movabs r15, 0x6dbb7bbbcefbff95 + _0xffffff8000ec1e36: mov rcx, rax + _0xffffff8000ec1e39: and rcx, r15 + _0xffffff8000ec1e3c: xor rax, r15 + _0xffffff8000ec1e3f: lea r15, [rax + rcx*2] + _0xffffff8000ec1e43: add r15, qword ptr [r14 + 0xc8] + _0xffffff8000ec1e4a: movabs rax, 0x924484443104006b + _0xffffff8000ec1e54: mov al, byte ptr [rax + r15] + _0xffffff8000ec1e58: mov cl, al + _0xffffff8000ec1e5a: add cl, cl + _0xffffff8000ec1e5c: xor al, 0x76 + _0xffffff8000ec1e5e: and cl, 0xec + _0xffffff8000ec1e61: add cl, al + _0xffffff8000ec1e63: movzx eax, cl + _0xffffff8000ec1e66: cmp al, 0x76 + _0xffffff8000ec1e68: sbb r15, r15 + _0xffffff8000ec1e6b: and r15d, 0x100 + _0xffffff8000ec1e72: add r15d, eax + _0xffffff8000ec1e75: add r15d, 0x5625a716 + _0xffffff8000ec1e7c: shl r15, 0x20 + _0xffffff8000ec1e80: movabs rax, 0xa9da587400000000 + _0xffffff8000ec1e8a: add rax, r15 + _0xffffff8000ec1e8d: mov r15, rax + _0xffffff8000ec1e90: sar r15, 0x1f + _0xffffff8000ec1e94: movabs rcx, 0x39f5b3f7d3bfddc4 + _0xffffff8000ec1e9e: and rcx, r15 + _0xffffff8000ec1ea1: sar rax, 0x20 + _0xffffff8000ec1ea5: movabs r15, 0x1cfad9fbe9dfeee2 + _0xffffff8000ec1eaf: xor r15, rax + _0xffffff8000ec1eb2: add r15, rcx + _0xffffff8000ec1eb5: mov rax, qword ptr [rbp - 0x3b8] + _0xffffff8000ec1ebc: lea r15, [rax + r15*4] + _0xffffff8000ec1ec0: mov ecx, edi + _0xffffff8000ec1ec2: shr ecx, 7 + _0xffffff8000ec1ec5: and ecx, 0xfa + _0xffffff8000ec1ecb: mov eax, edi + _0xffffff8000ec1ecd: shr eax, 8 + _0xffffff8000ec1ed0: add eax, 0x7d + _0xffffff8000ec1ed3: sub eax, ecx + _0xffffff8000ec1ed5: xor eax, 0x7d + _0xffffff8000ec1ed8: mov cl, al + _0xffffff8000ec1eda: add cl, cl + _0xffffff8000ec1edc: xor al, 0x7c + _0xffffff8000ec1ede: and cl, 0xf8 + _0xffffff8000ec1ee1: add cl, al + _0xffffff8000ec1ee3: add cl, 0xe8 + _0xffffff8000ec1ee6: movzx eax, cl + _0xffffff8000ec1ee9: cmp al, 0x64 + _0xffffff8000ec1eeb: sbb rcx, rcx + _0xffffff8000ec1eee: and rcx, 0x100 + _0xffffff8000ec1ef5: add rcx, rax + _0xffffff8000ec1ef8: add rcx, -0x64 + _0xffffff8000ec1efc: movabs rax, 0x5ffc71bddffdcb72 + _0xffffff8000ec1f06: mov r12, rcx + _0xffffff8000ec1f09: and r12, rax + _0xffffff8000ec1f0c: xor rcx, rax + _0xffffff8000ec1f0f: lea r12, [rcx + r12*2] + _0xffffff8000ec1f13: add r12, qword ptr [r14 + 0xf8] + _0xffffff8000ec1f1a: movabs rax, 0xa0038e422002348e + _0xffffff8000ec1f24: mov al, byte ptr [rax + r12] + _0xffffff8000ec1f28: mov cl, al + _0xffffff8000ec1f2a: add cl, cl + _0xffffff8000ec1f2c: xor al, 0x7f + _0xffffff8000ec1f2e: add al, cl + _0xffffff8000ec1f30: add al, 0xbf + _0xffffff8000ec1f32: movzx eax, al + _0xffffff8000ec1f35: cmp al, 0x3e + _0xffffff8000ec1f37: sbb r12, r12 + _0xffffff8000ec1f3a: and r12d, 0x100 + _0xffffff8000ec1f41: add r12d, eax + _0xffffff8000ec1f44: add r12d, 0x12206758 + _0xffffff8000ec1f4b: shl r12, 0x20 + _0xffffff8000ec1f4f: movabs rax, 0xeddf986a00000000 + _0xffffff8000ec1f59: add rax, r12 + _0xffffff8000ec1f5c: mov r12, rax + _0xffffff8000ec1f5f: sar r12, 0x1f + _0xffffff8000ec1f63: movabs rcx, 0x3bffbfa7ffe5a3bc + _0xffffff8000ec1f6d: and rcx, r12 + _0xffffff8000ec1f70: sar rax, 0x20 + _0xffffff8000ec1f74: movabs r12, 0x3dffdfd3fff2d1de + _0xffffff8000ec1f7e: xor r12, rax + _0xffffff8000ec1f81: add r12, rcx + _0xffffff8000ec1f84: lea r12, [r13 + r12*4] + _0xffffff8000ec1f89: mov al, r8b + _0xffffff8000ec1f8c: add al, al + _0xffffff8000ec1f8e: mov cl, r8b + _0xffffff8000ec1f91: xor cl, 0x4d + _0xffffff8000ec1f94: and al, 0x9a + _0xffffff8000ec1f96: add al, cl + _0xffffff8000ec1f98: movzx eax, al + _0xffffff8000ec1f9b: cmp al, 0x4d + _0xffffff8000ec1f9d: sbb rcx, rcx + _0xffffff8000ec1fa0: and ecx, 0x100 + _0xffffff8000ec1fa6: add ecx, eax + _0xffffff8000ec1fa8: add ecx, 0x17f81a1e + _0xffffff8000ec1fae: shl rcx, 0x20 + _0xffffff8000ec1fb2: movabs rax, 0xe807e59500000000 + _0xffffff8000ec1fbc: add rax, rcx + _0xffffff8000ec1fbf: mov rcx, rax + _0xffffff8000ec1fc2: sar rcx, 0x1f + _0xffffff8000ec1fc6: movabs r13, 0x4de96d7fbeffd2f8 + _0xffffff8000ec1fd0: and r13, rcx + _0xffffff8000ec1fd3: sar rax, 0x20 + _0xffffff8000ec1fd7: movabs rcx, 0x26f4b6bfdf7fe97c + _0xffffff8000ec1fe1: xor rcx, rax + _0xffffff8000ec1fe4: add rcx, r13 + _0xffffff8000ec1fe7: add rcx, qword ptr [r14 + 0xd0] + _0xffffff8000ec1fee: movabs r13, 0xd90b494020801684 + _0xffffff8000ec1ff8: mov al, byte ptr [r13 + rcx] + _0xffffff8000ec1ffd: mov cl, al + _0xffffff8000ec1fff: add cl, cl + _0xffffff8000ec2001: xor al, 0x2f + _0xffffff8000ec2003: and cl, 0x5e + _0xffffff8000ec2006: add cl, al + _0xffffff8000ec2008: add cl, 0xe0 + _0xffffff8000ec200b: movzx eax, cl + _0xffffff8000ec200e: cmp al, 0xf + _0xffffff8000ec2010: sbb r13, r13 + _0xffffff8000ec2013: and r13d, 0x100 + _0xffffff8000ec201a: add r13d, eax + _0xffffff8000ec201d: add r13d, 0x551f43fd + _0xffffff8000ec2024: shl r13, 0x20 + _0xffffff8000ec2028: movabs rax, 0xaae0bbf400000000 + _0xffffff8000ec2032: add rax, r13 + _0xffffff8000ec2035: mov r13, rax + _0xffffff8000ec2038: sar r13, 0x1f + _0xffffff8000ec203c: movabs rcx, 0x2ffdd5abad7bfbee + _0xffffff8000ec2046: and rcx, r13 + _0xffffff8000ec2049: sar rax, 0x20 + _0xffffff8000ec204d: movabs r13, 0x37feead5d6bdfdf7 + _0xffffff8000ec2057: xor r13, rax + _0xffffff8000ec205a: add r13, rcx + _0xffffff8000ec205d: mov rax, qword ptr [rbp - 0x3c0] + _0xffffff8000ec2064: lea r13, [rax + r13*4] + _0xffffff8000ec2068: movabs rcx, 0x200454a8a5080824 + _0xffffff8000ec2072: mov ecx, dword ptr [rcx + r13] + _0xffffff8000ec2076: movabs r13, 0x80080b00034b888 + _0xffffff8000ec2080: xor ecx, dword ptr [r13 + r12] + _0xffffff8000ec2085: movabs r12, 0x8c14981058804478 + _0xffffff8000ec208f: xor ecx, dword ptr [r12 + r15] + _0xffffff8000ec2093: movabs r15, 0x3824208800022144 + _0xffffff8000ec209d: xor ecx, dword ptr [r15 + rbx] + _0xffffff8000ec20a1: mov ebx, ecx + _0xffffff8000ec20a3: shr ebx, 7 + _0xffffff8000ec20a6: and ebx, 0x22 + _0xffffff8000ec20a9: mov r15d, ecx + _0xffffff8000ec20ac: shr r15d, 8 + _0xffffff8000ec20b0: add r15d, 0x91 + _0xffffff8000ec20b7: sub r15d, ebx + _0xffffff8000ec20ba: xor r15d, 0x91 + _0xffffff8000ec20c1: mov bl, r15b + _0xffffff8000ec20c4: add bl, bl + _0xffffff8000ec20c6: xor r15b, 0x7f + _0xffffff8000ec20ca: add r15b, bl + _0xffffff8000ec20cd: add r15b, 0xbf + _0xffffff8000ec20d1: movzx ebx, r15b + _0xffffff8000ec20d5: cmp bl, 0x3e + _0xffffff8000ec20d8: sbb r15, r15 + _0xffffff8000ec20db: and r15d, 0x100 + _0xffffff8000ec20e2: add r15d, ebx + _0xffffff8000ec20e5: add r15d, 0x4877195b + _0xffffff8000ec20ec: shl r15, 0x20 + _0xffffff8000ec20f0: movabs rbx, 0xb788e66700000000 + _0xffffff8000ec20fa: add rbx, r15 + _0xffffff8000ec20fd: mov r15, rbx + _0xffffff8000ec2100: sar r15, 0x1f + _0xffffff8000ec2104: movabs r12, 0x7aaeff9ff3e7f5ee + _0xffffff8000ec210e: and r12, r15 + _0xffffff8000ec2111: sar rbx, 0x20 + _0xffffff8000ec2115: movabs r15, 0x3d577fcff9f3faf7 + _0xffffff8000ec211f: xor r15, rbx + _0xffffff8000ec2122: add r15, r12 + _0xffffff8000ec2125: add r15, qword ptr [r14 + 0x158] + _0xffffff8000ec212c: movabs rbx, 0xc2a88030060c0509 + _0xffffff8000ec2136: mov bl, byte ptr [rbx + r15] + _0xffffff8000ec213a: mov r15b, bl + _0xffffff8000ec213d: add r15b, r15b + _0xffffff8000ec2140: xor bl, 0x75 + _0xffffff8000ec2143: and r15b, 0xea + _0xffffff8000ec2147: add r15b, bl + _0xffffff8000ec214a: add r15b, 0xfb + _0xffffff8000ec214e: movzx ebx, r15b + _0xffffff8000ec2152: cmp bl, 0x70 + _0xffffff8000ec2155: sbb r15, r15 + _0xffffff8000ec2158: and r15d, 0x100 + _0xffffff8000ec215f: add r15d, ebx + _0xffffff8000ec2162: add r15d, 0x67bf1f2b + _0xffffff8000ec2169: shl r15, 0x20 + _0xffffff8000ec216d: movabs rbx, 0x9840e06500000000 + _0xffffff8000ec2177: add rbx, r15 + _0xffffff8000ec217a: mov r15, rbx + _0xffffff8000ec217d: sar r15, 0x1f + _0xffffff8000ec2181: movabs r12, 0x39bdd59bffe6f6b2 + _0xffffff8000ec218b: and r12, r15 + _0xffffff8000ec218e: sar rbx, 0x20 + _0xffffff8000ec2192: movabs r15, 0x3cdeeacdfff37b59 + _0xffffff8000ec219c: xor r15, rbx + _0xffffff8000ec219f: add r15, r12 + _0xffffff8000ec21a2: mov r13, qword ptr [rbp - 0x3d0] + _0xffffff8000ec21a9: lea rbx, [r13 + r15*4] + _0xffffff8000ec21ae: movabs r15, 0xc8454c80032129c + _0xffffff8000ec21b8: xor edx, dword ptr [r15 + rbx] + _0xffffff8000ec21bc: shr r9d, 0x18 + _0xffffff8000ec21c0: mov ebx, r9d + _0xffffff8000ec21c3: or ebx, 0xffffffac + _0xffffff8000ec21c6: xor ebx, 0x53 + _0xffffff8000ec21c9: mov r15d, ebx + _0xffffff8000ec21cc: and r15d, 0xc3ddd513 + _0xffffff8000ec21d3: sub ebx, r15d + _0xffffff8000ec21d6: mov r12d, r9d + _0xffffff8000ec21d9: or r12d, 0xffffff53 + _0xffffff8000ec21e0: xor r12d, 0xac + _0xffffff8000ec21e7: and r12d, ebx + _0xffffff8000ec21ea: xor r12d, r15d + _0xffffff8000ec21ed: not r12d + _0xffffff8000ec21f0: imul ebx, r12d, 0xd42ad8c3 + _0xffffff8000ec21f7: xor ebx, 0x6084dc91 + _0xffffff8000ec21fd: imul r15d, r12d, 0xa855b186 + _0xffffff8000ec2204: and r15d, 0xc109b922 + _0xffffff8000ec220b: add r15d, ebx + _0xffffff8000ec220e: imul ebx, r15d, 0x40f097eb + _0xffffff8000ec2215: mov r15d, 0xffffffe5 + _0xffffff8000ec221b: sub r15d, r9d + _0xffffff8000ec221e: xor r9d, 0x1b + _0xffffff8000ec2222: add r9d, r15d + _0xffffff8000ec2225: add r9d, ebx + _0xffffff8000ec2228: xor r9d, 0x4495cfd3 + _0xffffff8000ec222f: mov ebx, r9d + _0xffffff8000ec2232: and ebx, 0x94 + _0xffffff8000ec2238: sub ebx, -0x80 + _0xffffff8000ec223b: mov r15d, r9d + _0xffffff8000ec223e: and r15d, 0x21 + _0xffffff8000ec2242: mov r12d, 0x424dcf42 + _0xffffff8000ec2248: sub r12d, r15d + _0xffffff8000ec224b: and r12d, 0x21 + _0xffffff8000ec224f: or r12d, ebx + _0xffffff8000ec2252: and r9d, 0x4a + _0xffffff8000ec2256: add r9d, 0x14 + _0xffffff8000ec225a: add r9d, 0x38 + _0xffffff8000ec225e: and r9d, 0x4a + _0xffffff8000ec2262: or r9d, r12d + _0xffffff8000ec2265: mov bl, r9b + _0xffffff8000ec2268: xor bl, 0xda + _0xffffff8000ec226b: mov r15b, bl + _0xffffff8000ec226e: add r15b, r15b + _0xffffff8000ec2271: add bl, 0xad + _0xffffff8000ec2274: and r15b, 0x5a + _0xffffff8000ec2278: sub bl, r15b + _0xffffff8000ec227b: mov al, bl + _0xffffff8000ec227d: add al, al + _0xffffff8000ec227f: xor bl, 0xbf + _0xffffff8000ec2282: and al, 0x7e + _0xffffff8000ec2284: add al, bl + _0xffffff8000ec2286: mov bl, 0x1b + _0xffffff8000ec2288: mul bl + _0xffffff8000ec228a: mov r15b, al + _0xffffff8000ec228d: mov al, r9b + _0xffffff8000ec2290: mul bl + _0xffffff8000ec2292: shr ax, 8 + _0xffffff8000ec2296: mov r12b, al + _0xffffff8000ec2299: shr r12b, 2 + _0xffffff8000ec229d: mov r10b, 0x26 + _0xffffff8000ec22a0: mov al, r12b + _0xffffff8000ec22a3: mul r10b + _0xffffff8000ec22a6: sub r9b, al + _0xffffff8000ec22a9: add r9b, r9b + _0xffffff8000ec22ac: mov al, r12b + _0xffffff8000ec22af: add al, 0xed + _0xffffff8000ec22b1: mul al + _0xffffff8000ec22b3: sub r9b, al + _0xffffff8000ec22b6: mov al, r12b + _0xffffff8000ec22b9: add al, 0x13 + _0xffffff8000ec22bb: mul al + _0xffffff8000ec22bd: add al, r9b + _0xffffff8000ec22c0: and al, 0xee + _0xffffff8000ec22c2: mul bl + _0xffffff8000ec22c4: mov r9b, al + _0xffffff8000ec22c7: xor r9b, r15b + _0xffffff8000ec22ca: and al, r15b + _0xffffff8000ec22cd: add al, al + _0xffffff8000ec22cf: add al, r9b + _0xffffff8000ec22d2: mov r9b, 0x13 + _0xffffff8000ec22d5: mul r9b + _0xffffff8000ec22d8: movzx r9d, al + _0xffffff8000ec22dc: cmp r9b, 0x36 + _0xffffff8000ec22e0: sbb rbx, rbx + _0xffffff8000ec22e3: and ebx, 0x100 + _0xffffff8000ec22e9: add ebx, r9d + _0xffffff8000ec22ec: add ebx, 0x1b695536 + _0xffffff8000ec22f2: shl rbx, 0x20 + _0xffffff8000ec22f6: movabs r15, 0xe496aa9400000000 + _0xffffff8000ec2300: add r15, rbx + _0xffffff8000ec2303: mov rbx, r15 + _0xffffff8000ec2306: sar rbx, 0x1f + _0xffffff8000ec230a: movabs r12, 0x8e36f3563aff97e2 + _0xffffff8000ec2314: and r12, rbx + _0xffffff8000ec2317: sar r15, 0x20 + _0xffffff8000ec231b: movabs rbx, 0x471b79ab1d7fcbf1 + _0xffffff8000ec2325: xor rbx, r15 + _0xffffff8000ec2328: add rbx, r12 + _0xffffff8000ec232b: add rbx, qword ptr [r14 + 0xa8] + _0xffffff8000ec2332: movabs r15, 0xb8e48654e280340f + _0xffffff8000ec233c: mov r9b, byte ptr [r15 + rbx] + _0xffffff8000ec2340: mov r10b, r9b + _0xffffff8000ec2343: add r10b, r10b + _0xffffff8000ec2346: xor r9b, 0x7e + _0xffffff8000ec234a: and r10b, 0xfc + _0xffffff8000ec234e: add r10b, r9b + _0xffffff8000ec2351: add r10b, 0xde + _0xffffff8000ec2355: movzx r9d, r10b + _0xffffff8000ec2359: cmp r9b, 0x5c + _0xffffff8000ec235d: sbb rbx, rbx + _0xffffff8000ec2360: and ebx, 0x100 + _0xffffff8000ec2366: add ebx, r9d + _0xffffff8000ec2369: add ebx, 0x502a5db6 + _0xffffff8000ec236f: shl rbx, 0x20 + _0xffffff8000ec2373: movabs r15, 0xafd5a1ee00000000 + _0xffffff8000ec237d: add r15, rbx + _0xffffff8000ec2380: mov rbx, r15 + _0xffffff8000ec2383: sar rbx, 0x1f + _0xffffff8000ec2387: movabs r12, 0x3befdfead77eb77e + _0xffffff8000ec2391: and r12, rbx + _0xffffff8000ec2394: sar r15, 0x20 + _0xffffff8000ec2398: movabs rbx, 0x3df7eff56bbf5bbf + _0xffffff8000ec23a2: xor rbx, r15 + _0xffffff8000ec23a5: add rbx, r12 + _0xffffff8000ec23a8: mov rax, qword ptr [rbp - 0x3b8] + _0xffffff8000ec23af: lea rbx, [rax + rbx*4] + _0xffffff8000ec23b3: mov r9b, sil + _0xffffff8000ec23b6: add r9b, r9b + _0xffffff8000ec23b9: mov r10b, r9b + _0xffffff8000ec23bc: and r10b, 0xd6 + _0xffffff8000ec23c0: xor r10b, 0xa0 + _0xffffff8000ec23c4: and r9b, 0x28 + _0xffffff8000ec23c8: xor r9b, 0xa0 + _0xffffff8000ec23cc: mov r15b, r9b + _0xffffff8000ec23cf: add r15b, r10b + _0xffffff8000ec23d2: and r9b, r10b + _0xffffff8000ec23d5: add r9b, r9b + _0xffffff8000ec23d8: sub r15b, r9b + _0xffffff8000ec23db: xor sil, 0x8b + _0xffffff8000ec23df: mov r9b, sil + _0xffffff8000ec23e2: and r9b, 0x54 + _0xffffff8000ec23e6: mov r10b, 0xfc + _0xffffff8000ec23e9: sub r10b, r9b + _0xffffff8000ec23ec: mov r9b, r15b + _0xffffff8000ec23ef: xor r9b, r10b + _0xffffff8000ec23f2: mov r12b, r10b + _0xffffff8000ec23f5: and r12b, r15b + _0xffffff8000ec23f8: mov al, r15b + _0xffffff8000ec23fb: and al, 0x48 + _0xffffff8000ec23fd: or al, 0x12 + _0xffffff8000ec23ff: mov r13b, sil + _0xffffff8000ec2402: and r13b, 0xa + _0xffffff8000ec2406: and sil, 0xa1 + _0xffffff8000ec240a: add sil, 0xa0 + _0xffffff8000ec240e: and sil, 0xa1 + _0xffffff8000ec2412: or sil, r13b + _0xffffff8000ec2415: mov r13b, sil + _0xffffff8000ec2418: and r13b, 9 + _0xffffff8000ec241c: and r10b, 0x40 + _0xffffff8000ec2420: or r10b, r13b + _0xffffff8000ec2423: sub al, r10b + _0xffffff8000ec2426: and r10b, r15b + _0xffffff8000ec2429: add r10b, r10b + _0xffffff8000ec242c: and al, 0x49 + _0xffffff8000ec242e: or al, r10b + _0xffffff8000ec2431: and r9b, 0x14 + _0xffffff8000ec2435: and r12b, 0x14 + _0xffffff8000ec2439: add r12b, r12b + _0xffffff8000ec243c: or r12b, r9b + _0xffffff8000ec243f: and r15b, 0xa2 + _0xffffff8000ec2443: and sil, 0xa2 + _0xffffff8000ec2447: add sil, r15b + _0xffffff8000ec244a: add sil, r12b + _0xffffff8000ec244d: add sil, al + _0xffffff8000ec2450: movzx r15d, sil + _0xffffff8000ec2454: cmp r15b, 0x7f + _0xffffff8000ec2458: sbb r12, r12 + _0xffffff8000ec245b: and r12, 0x100 + _0xffffff8000ec2462: add r12, r15 + _0xffffff8000ec2465: add r12, -0x7f + _0xffffff8000ec2469: movabs r15, 0x55fcfff7fdfb7cfd + _0xffffff8000ec2473: mov r13, r12 + _0xffffff8000ec2476: and r13, r15 + _0xffffff8000ec2479: xor r12, r15 + _0xffffff8000ec247c: lea r15, [r12 + r13*2] + _0xffffff8000ec2480: add r15, qword ptr [r14 + 0xb0] + _0xffffff8000ec2487: movabs r12, 0xaa03000802048303 + _0xffffff8000ec2491: mov al, byte ptr [r12 + r15] + _0xffffff8000ec2495: mov sil, al + _0xffffff8000ec2498: add sil, sil + _0xffffff8000ec249b: xor al, 0x7f + _0xffffff8000ec249d: add al, sil + _0xffffff8000ec24a0: add al, 0xf7 + _0xffffff8000ec24a2: movzx eax, al + _0xffffff8000ec24a5: cmp al, 0x76 + _0xffffff8000ec24a7: sbb r15, r15 + _0xffffff8000ec24aa: and r15d, 0x100 + _0xffffff8000ec24b1: add r15d, eax + _0xffffff8000ec24b4: add r15d, 0x3c3d4e57 + _0xffffff8000ec24bb: shl r15, 0x20 + _0xffffff8000ec24bf: movabs r12, 0xc3c2b13300000000 + _0xffffff8000ec24c9: add r12, r15 + _0xffffff8000ec24cc: mov r15, r12 + _0xffffff8000ec24cf: sar r15, 0x1f + _0xffffff8000ec24d3: movabs r13, 0x3f6df7da3ffffbc6 + _0xffffff8000ec24dd: and r13, r15 + _0xffffff8000ec24e0: sar r12, 0x20 + _0xffffff8000ec24e4: movabs r15, 0x3fb6fbed1ffffde3 + _0xffffff8000ec24ee: xor r15, r12 + _0xffffff8000ec24f1: add r15, r13 + _0xffffff8000ec24f4: mov rax, qword ptr [rbp - 0x3c0] + _0xffffff8000ec24fb: lea r15, [rax + r15*4] + _0xffffff8000ec24ff: mov esi, edi + _0xffffff8000ec2501: shr esi, 0xf + _0xffffff8000ec2504: and esi, 0x7e + _0xffffff8000ec2507: shr edi, 0x10 + _0xffffff8000ec250a: add edi, 0x3f + _0xffffff8000ec250d: sub edi, esi + _0xffffff8000ec250f: xor edi, 0x3f + _0xffffff8000ec2512: mov sil, dil + _0xffffff8000ec2515: add sil, sil + _0xffffff8000ec2518: xor dil, 0x7d + _0xffffff8000ec251c: and sil, 0xfa + _0xffffff8000ec2520: add sil, dil + _0xffffff8000ec2523: add sil, 0xbc + _0xffffff8000ec2527: movzx esi, sil + _0xffffff8000ec252b: cmp sil, 0x39 + _0xffffff8000ec252f: sbb r12, r12 + _0xffffff8000ec2532: and r12d, 0x100 + _0xffffff8000ec2539: add r12d, esi + _0xffffff8000ec253c: add r12d, 0x1bb8c142 + _0xffffff8000ec2543: shl r12, 0x20 + _0xffffff8000ec2547: movabs r13, 0xe4473e8500000000 + _0xffffff8000ec2551: add r13, r12 + _0xffffff8000ec2554: mov r12, r13 + _0xffffff8000ec2557: sar r12, 0x1f + _0xffffff8000ec255b: movabs rsi, 0x7fbbfff5fdf23eb8 + _0xffffff8000ec2565: and rsi, r12 + _0xffffff8000ec2568: sar r13, 0x20 + _0xffffff8000ec256c: movabs r12, 0x3fddfffafef91f5c + _0xffffff8000ec2576: xor r12, r13 + _0xffffff8000ec2579: add r12, rsi + _0xffffff8000ec257c: add r12, qword ptr [r14 + 0x100] + _0xffffff8000ec2583: movabs r13, 0xc02200050106e0a4 + _0xffffff8000ec258d: mov sil, byte ptr [r13 + r12] + _0xffffff8000ec2592: mov dil, sil + _0xffffff8000ec2595: add dil, dil + _0xffffff8000ec2598: xor sil, 0x79 + _0xffffff8000ec259c: and dil, 0xf2 + _0xffffff8000ec25a0: add dil, sil + _0xffffff8000ec25a3: add dil, 0xb0 + _0xffffff8000ec25a7: movzx esi, dil + _0xffffff8000ec25ab: cmp sil, 0x29 + _0xffffff8000ec25af: sbb r12, r12 + _0xffffff8000ec25b2: and r12d, 0x100 + _0xffffff8000ec25b9: add r12d, esi + _0xffffff8000ec25bc: add r12d, 0x7233eb11 + _0xffffff8000ec25c3: shl r12, 0x20 + _0xffffff8000ec25c7: movabs r13, 0x8dcc14c600000000 + _0xffffff8000ec25d1: add r13, r12 + _0xffffff8000ec25d4: mov r12, r13 + _0xffffff8000ec25d7: sar r12, 0x1f + _0xffffff8000ec25db: movabs rsi, 0x25b6bdfac8be73de + _0xffffff8000ec25e5: and rsi, r12 + _0xffffff8000ec25e8: sar r13, 0x20 + _0xffffff8000ec25ec: movabs r12, 0x32db5efd645f39ef + _0xffffff8000ec25f6: xor r12, r13 + _0xffffff8000ec25f9: add r12, rsi + _0xffffff8000ec25fc: mov r10, qword ptr [rbp - 0x3d8] + _0xffffff8000ec2603: lea r12, [r10 + r12*4] + _0xffffff8000ec2607: mov esi, r8d + _0xffffff8000ec260a: shr esi, 7 + _0xffffff8000ec260d: and esi, 0xf0 + _0xffffff8000ec2613: shr r8d, 8 + _0xffffff8000ec2617: add r8d, 0x78 + _0xffffff8000ec261b: sub r8d, esi + _0xffffff8000ec261e: xor r8d, 0x78 + _0xffffff8000ec2622: mov sil, r8b + _0xffffff8000ec2625: add sil, sil + _0xffffff8000ec2628: xor r8b, 0x7f + _0xffffff8000ec262c: add r8b, sil + _0xffffff8000ec262f: add r8b, 0xaf + _0xffffff8000ec2633: movzx esi, r8b + _0xffffff8000ec2637: cmp sil, 0x2e + _0xffffff8000ec263b: sbb r13, r13 + _0xffffff8000ec263e: and r13d, 0x100 + _0xffffff8000ec2645: add r13d, esi + _0xffffff8000ec2648: add r13d, 0x201b6f99 + _0xffffff8000ec264f: shl r13, 0x20 + _0xffffff8000ec2653: movabs rsi, 0xdfe4903900000000 + _0xffffff8000ec265d: add rsi, r13 + _0xffffff8000ec2660: mov r13, rsi + _0xffffff8000ec2663: sar r13, 0x1f + _0xffffff8000ec2667: movabs rdi, 0x381bfffb97efee7c + _0xffffff8000ec2671: and rdi, r13 + _0xffffff8000ec2674: sar rsi, 0x20 + _0xffffff8000ec2678: movabs r13, 0x1c0dfffdcbf7f73e + _0xffffff8000ec2682: xor r13, rsi + _0xffffff8000ec2685: add r13, rdi + _0xffffff8000ec2688: add r13, qword ptr [r14 + 0xd8] + _0xffffff8000ec268f: movabs rsi, 0xe3f20002340808c2 + _0xffffff8000ec2699: mov sil, byte ptr [rsi + r13] + _0xffffff8000ec269d: mov dil, sil + _0xffffff8000ec26a0: add dil, dil + _0xffffff8000ec26a3: xor sil, 0x73 + _0xffffff8000ec26a7: and dil, 0xe6 + _0xffffff8000ec26ab: add dil, sil + _0xffffff8000ec26ae: movzx esi, dil + _0xffffff8000ec26b2: cmp sil, 0x73 + _0xffffff8000ec26b6: sbb r13, r13 + _0xffffff8000ec26b9: and r13d, 0x100 + _0xffffff8000ec26c0: add r13d, esi + _0xffffff8000ec26c3: add r13d, 0x529fc039 + _0xffffff8000ec26ca: shl r13, 0x20 + _0xffffff8000ec26ce: movabs rsi, 0xad603f5400000000 + _0xffffff8000ec26d8: add rsi, r13 + _0xffffff8000ec26db: mov r13, rsi + _0xffffff8000ec26de: sar r13, 0x1f + _0xffffff8000ec26e2: movabs rdi, 0x2ddfff1779efa9fc + _0xffffff8000ec26ec: and rdi, r13 + _0xffffff8000ec26ef: sar rsi, 0x20 + _0xffffff8000ec26f3: movabs r13, 0x36efff8bbcf7d4fe + _0xffffff8000ec26fd: xor r13, rsi + _0xffffff8000ec2700: add r13, rdi + _0xffffff8000ec2703: mov rsi, qword ptr [rbp - 0x3d0] + _0xffffff8000ec270a: lea r13, [rsi + r13*4] + _0xffffff8000ec270e: movabs rdi, 0x244001d10c20ac08 + _0xffffff8000ec2718: mov edi, dword ptr [rdi + r13] + _0xffffff8000ec271c: movabs r13, 0x3492840a6e831844 + _0xffffff8000ec2726: xor edi, dword ptr [r13 + r12] + _0xffffff8000ec272b: movabs r12, 0x124104b80000874 + _0xffffff8000ec2735: xor edi, dword ptr [r12 + r15] + _0xffffff8000ec2739: movabs r15, 0x820402a51029104 + _0xffffff8000ec2743: xor edi, dword ptr [r15 + rbx] + _0xffffff8000ec2747: mov r8b, dil + _0xffffff8000ec274a: add r8b, r8b + _0xffffff8000ec274d: mov r9b, dil + _0xffffff8000ec2750: xor r9b, 0x7f + _0xffffff8000ec2754: add r9b, r8b + _0xffffff8000ec2757: add r9b, 0xf7 + _0xffffff8000ec275b: movzx r8d, r9b + _0xffffff8000ec275f: cmp r8b, 0x76 + _0xffffff8000ec2763: sbb rbx, rbx + _0xffffff8000ec2766: and ebx, 0x100 + _0xffffff8000ec276c: add ebx, r8d + _0xffffff8000ec276f: add ebx, 0x1844a013 + _0xffffff8000ec2775: shl rbx, 0x20 + _0xffffff8000ec2779: movabs r15, 0xe7bb5f7700000000 + _0xffffff8000ec2783: add r15, rbx + _0xffffff8000ec2786: mov rbx, r15 + _0xffffff8000ec2789: sar rbx, 0x1f + _0xffffff8000ec278d: movabs r12, 0xc8fff29b2ff9fc76 + _0xffffff8000ec2797: and r12, rbx + _0xffffff8000ec279a: sar r15, 0x20 + _0xffffff8000ec279e: movabs rbx, 0x647ff94d97fcfe3b + _0xffffff8000ec27a8: xor rbx, r15 + _0xffffff8000ec27ab: add rbx, r12 + _0xffffff8000ec27ae: add rbx, qword ptr [r14 + 0x130] + _0xffffff8000ec27b5: movabs r15, 0x9b8006b2680301c5 + _0xffffff8000ec27bf: mov r8b, byte ptr [r15 + rbx] + _0xffffff8000ec27c3: mov r9b, r8b + _0xffffff8000ec27c6: add r9b, r9b + _0xffffff8000ec27c9: xor r8b, 0x1b + _0xffffff8000ec27cd: and r9b, 0x36 + _0xffffff8000ec27d1: add r9b, r8b + _0xffffff8000ec27d4: add r9b, 0xfe + _0xffffff8000ec27d8: movzx ebx, r9b + _0xffffff8000ec27dc: cmp bl, 0x19 + _0xffffff8000ec27df: sbb r15, r15 + _0xffffff8000ec27e2: and r15, 0x100 + _0xffffff8000ec27e9: add r15, rbx + _0xffffff8000ec27ec: add r15, -0x19 + _0xffffff8000ec27f0: movabs rbx, 0x1bcdf5f7fe3ff7e8 + _0xffffff8000ec27fa: and rbx, r15 + _0xffffff8000ec27fd: movabs r12, 0x3bcdf5f7fe3ff7e8 + _0xffffff8000ec2807: xor r12, r15 + _0xffffff8000ec280a: lea rbx, [r12 + rbx*2] + _0xffffff8000ec280e: lea rbx, [rax + rbx*4] + _0xffffff8000ec2812: movabs r15, 0x10c8282007002060 + _0xffffff8000ec281c: xor edx, dword ptr [r15 + rbx] + _0xffffff8000ec2820: mov r8d, edx + _0xffffff8000ec2823: shr r8d, 7 + _0xffffff8000ec2827: and r8d, 0xae + _0xffffff8000ec282e: mov r9d, edx + _0xffffff8000ec2831: shr r9d, 8 + _0xffffff8000ec2835: add r9d, 0xd7 + _0xffffff8000ec283c: sub r9d, r8d + _0xffffff8000ec283f: xor r9d, 0xd7 + _0xffffff8000ec2846: mov r8b, r9b + _0xffffff8000ec2849: add r8b, r8b + _0xffffff8000ec284c: xor r9b, 0x75 + _0xffffff8000ec2850: and r8b, 0xea + _0xffffff8000ec2854: add r8b, r9b + _0xffffff8000ec2857: add r8b, 0xfb + _0xffffff8000ec285b: movzx r8d, r8b + _0xffffff8000ec285f: cmp r8b, 0x70 + _0xffffff8000ec2863: sbb rbx, rbx + _0xffffff8000ec2866: and ebx, 0x100 + _0xffffff8000ec286c: add ebx, r8d + _0xffffff8000ec286f: add ebx, 0x450f7595 + _0xffffff8000ec2875: shl rbx, 0x20 + _0xffffff8000ec2879: movabs r15, 0xbaf089fb00000000 + _0xffffff8000ec2883: add r15, rbx + _0xffffff8000ec2886: mov rbx, r15 + _0xffffff8000ec2889: sar rbx, 0x1f + _0xffffff8000ec288d: movabs r12, 0xde7fffd7ffaa9fb2 + _0xffffff8000ec2897: and r12, rbx + _0xffffff8000ec289a: sar r15, 0x20 + _0xffffff8000ec289e: movabs rbx, 0x6f3fffebffd54fd9 + _0xffffff8000ec28a8: xor rbx, r15 + _0xffffff8000ec28ab: add rbx, r12 + _0xffffff8000ec28ae: add rbx, qword ptr [r14 + 0x1b8] + _0xffffff8000ec28b5: movabs r15, 0x90c00014002ab027 + _0xffffff8000ec28bf: mov r8b, byte ptr [r15 + rbx] + _0xffffff8000ec28c3: mov r9b, r8b + _0xffffff8000ec28c6: add r9b, r9b + _0xffffff8000ec28c9: xor r8b, 0x2f + _0xffffff8000ec28cd: and r9b, 0x5e + _0xffffff8000ec28d1: add r9b, r8b + _0xffffff8000ec28d4: add r9b, 0xdb + _0xffffff8000ec28d8: movzx r8d, r9b + _0xffffff8000ec28dc: cmp r8b, 0xa + _0xffffff8000ec28e0: sbb rbx, rbx + _0xffffff8000ec28e3: and ebx, 0x100 + _0xffffff8000ec28e9: add ebx, r8d + _0xffffff8000ec28ec: add ebx, 0x65dff436 + _0xffffff8000ec28f2: shl rbx, 0x20 + _0xffffff8000ec28f6: movabs r15, 0x9a200bc000000000 + _0xffffff8000ec2900: add r15, rbx + _0xffffff8000ec2903: mov rbx, r15 + _0xffffff8000ec2906: sar rbx, 0x1f + _0xffffff8000ec290a: movabs r12, 0x3ff3db957bd7ee7e + _0xffffff8000ec2914: and r12, rbx + _0xffffff8000ec2917: sar r15, 0x20 + _0xffffff8000ec291b: movabs rbx, 0x3ff9edcabdebf73f + _0xffffff8000ec2925: xor rbx, r15 + _0xffffff8000ec2928: add rbx, r12 + _0xffffff8000ec292b: lea rbx, [rsi + rbx*4] + _0xffffff8000ec292f: mov r8d, dword ptr [rbp - 0x3c4] + _0xffffff8000ec2936: shr r8d, 0x17 + _0xffffff8000ec293a: and r8d, 0x3a + _0xffffff8000ec293e: mov r9d, dword ptr [rbp - 0x3c4] + _0xffffff8000ec2945: shr r9d, 0x18 + _0xffffff8000ec2949: add r9d, 0x1d + _0xffffff8000ec294d: sub r9d, r8d + _0xffffff8000ec2950: xor r9d, 0x1d + _0xffffff8000ec2954: mov r8b, r9b + _0xffffff8000ec2957: add r8b, r8b + _0xffffff8000ec295a: xor r9b, 0x6f + _0xffffff8000ec295e: and r8b, 0xde + _0xffffff8000ec2962: add r8b, r9b + _0xffffff8000ec2965: add r8b, 0xe0 + _0xffffff8000ec2969: movzx r8d, r8b + _0xffffff8000ec296d: cmp r8b, 0x4f + _0xffffff8000ec2971: sbb r15, r15 + _0xffffff8000ec2974: and r15d, 0x100 + _0xffffff8000ec297b: add r15d, r8d + _0xffffff8000ec297e: add r15d, 0x6e1d50d6 + _0xffffff8000ec2985: shl r15, 0x20 + _0xffffff8000ec2989: movabs r12, 0x91e2aedb00000000 + _0xffffff8000ec2993: add r12, r15 + _0xffffff8000ec2996: mov r15, r12 + _0xffffff8000ec2999: sar r15, 0x1f + _0xffffff8000ec299d: movabs r13, 0xf6ce77fdb1f0affe + _0xffffff8000ec29a7: and r13, r15 + _0xffffff8000ec29aa: sar r12, 0x20 + _0xffffff8000ec29ae: movabs r15, 0x7b673bfed8f857ff + _0xffffff8000ec29b8: xor r15, r12 + _0xffffff8000ec29bb: add r15, r13 + _0xffffff8000ec29be: add r15, qword ptr [r14 + 0x188] + _0xffffff8000ec29c5: movabs r12, 0x8498c4012707a801 + _0xffffff8000ec29cf: mov r8b, byte ptr [r12 + r15] + _0xffffff8000ec29d3: mov r9b, r8b + _0xffffff8000ec29d6: add r9b, r9b + _0xffffff8000ec29d9: xor r8b, 0x53 + _0xffffff8000ec29dd: and r9b, 0xa6 + _0xffffff8000ec29e1: add r9b, r8b + _0xffffff8000ec29e4: dec r9b + _0xffffff8000ec29e7: movzx r8d, r9b + _0xffffff8000ec29eb: cmp r8b, 0x52 + _0xffffff8000ec29ef: sbb r15, r15 + _0xffffff8000ec29f2: and r15d, 0x100 + _0xffffff8000ec29f9: add r15d, r8d + _0xffffff8000ec29fc: add r15d, 0x2b1d4a3b + _0xffffff8000ec2a03: shl r15, 0x20 + _0xffffff8000ec2a07: movabs r12, 0xd4e2b57300000000 + _0xffffff8000ec2a11: add r12, r15 + _0xffffff8000ec2a14: mov r15, r12 + _0xffffff8000ec2a17: sar r15, 0x1f + _0xffffff8000ec2a1b: movabs r13, 0x3cf6fefabda5f7da + _0xffffff8000ec2a25: and r13, r15 + _0xffffff8000ec2a28: sar r12, 0x20 + _0xffffff8000ec2a2c: movabs r15, 0x3e7b7f7d5ed2fbed + _0xffffff8000ec2a36: xor r15, r12 + _0xffffff8000ec2a39: add r15, r13 + _0xffffff8000ec2a3c: mov rax, qword ptr [rbp - 0x3b8] + _0xffffff8000ec2a43: lea r15, [rax + r15*4] + _0xffffff8000ec2a47: movabs r12, 0x612020a84b4104c + _0xffffff8000ec2a51: mov r8d, dword ptr [r12 + r15] + _0xffffff8000ec2a55: mov r9b, r11b + _0xffffff8000ec2a58: add r9b, r9b + _0xffffff8000ec2a5b: mov r15b, r11b + _0xffffff8000ec2a5e: xor r15b, 0x79 + _0xffffff8000ec2a62: and r9b, 0xf2 + _0xffffff8000ec2a66: add r9b, r15b + _0xffffff8000ec2a69: add r9b, 0xa0 + _0xffffff8000ec2a6d: movzx r9d, r9b + _0xffffff8000ec2a71: cmp r9b, 0x19 + _0xffffff8000ec2a75: sbb r15, r15 + _0xffffff8000ec2a78: and r15d, 0x100 + _0xffffff8000ec2a7f: add r15d, r9d + _0xffffff8000ec2a82: add r15d, 0x3ef892b8 + _0xffffff8000ec2a89: shl r15, 0x20 + _0xffffff8000ec2a8d: movabs r12, 0xc1076d2f00000000 + _0xffffff8000ec2a97: add r12, r15 + _0xffffff8000ec2a9a: mov r15, r12 + _0xffffff8000ec2a9d: sar r15, 0x1f + _0xffffff8000ec2aa1: movabs r13, 0xffffffeffffcefea + _0xffffff8000ec2aab: and r13, r15 + _0xffffff8000ec2aae: sar r12, 0x20 + _0xffffff8000ec2ab2: movabs r15, 0x7ffffff7fffe77f5 + _0xffffff8000ec2abc: xor r15, r12 + _0xffffff8000ec2abf: add r15, r13 + _0xffffff8000ec2ac2: add r15, qword ptr [r14 + 0x110] + _0xffffff8000ec2ac9: movabs r12, 0x800000080001880b + _0xffffff8000ec2ad3: mov r9b, byte ptr [r12 + r15] + _0xffffff8000ec2ad7: mov r15b, r9b + _0xffffff8000ec2ada: add r15b, r15b + _0xffffff8000ec2add: xor r9b, 0x73 + _0xffffff8000ec2ae1: and r15b, 0xe6 + _0xffffff8000ec2ae5: add r15b, r9b + _0xffffff8000ec2ae8: add r15b, 0xf0 + _0xffffff8000ec2aec: movzx r9d, r15b + _0xffffff8000ec2af0: cmp r9b, 0x63 + _0xffffff8000ec2af4: sbb r15, r15 + _0xffffff8000ec2af7: and r15d, 0x100 + _0xffffff8000ec2afe: add r15d, r9d + _0xffffff8000ec2b01: add r15d, 0x3e094b23 + _0xffffff8000ec2b08: shl r15, 0x20 + _0xffffff8000ec2b0c: movabs r12, 0xc1f6b47a00000000 + _0xffffff8000ec2b16: add r12, r15 + _0xffffff8000ec2b19: mov r15, r12 + _0xffffff8000ec2b1c: sar r15, 0x1f + _0xffffff8000ec2b20: movabs r13, 0x13ffffeff9dbbfde + _0xffffff8000ec2b2a: and r13, r15 + _0xffffff8000ec2b2d: sar r12, 0x20 + _0xffffff8000ec2b31: movabs r15, 0x29fffff7fceddfef + _0xffffff8000ec2b3b: xor r15, r12 + _0xffffff8000ec2b3e: add r15, r13 + _0xffffff8000ec2b41: mov rax, qword ptr [rbp - 0x3c0] + _0xffffff8000ec2b48: lea r15, [rax + r15*4] + _0xffffff8000ec2b4c: movabs r12, 0x580000200c488044 + _0xffffff8000ec2b56: xor r8d, dword ptr [r12 + r15] + _0xffffff8000ec2b5a: mov r9d, ecx + _0xffffff8000ec2b5d: shr r9d, 0xf + _0xffffff8000ec2b61: and r9d, 0xc + _0xffffff8000ec2b65: mov r15d, ecx + _0xffffff8000ec2b68: shr r15d, 0x10 + _0xffffff8000ec2b6c: add r15d, 0x86 + _0xffffff8000ec2b73: sub r15d, r9d + _0xffffff8000ec2b76: xor r15d, 0x86 + _0xffffff8000ec2b7d: mov r9b, r15b + _0xffffff8000ec2b80: add r9b, r9b + _0xffffff8000ec2b83: xor r15b, 0x6b + _0xffffff8000ec2b87: and r9b, 0xd6 + _0xffffff8000ec2b8b: add r9b, r15b + _0xffffff8000ec2b8e: add r9b, 0xf8 + _0xffffff8000ec2b92: movzx r9d, r9b + _0xffffff8000ec2b96: cmp r9b, 0x63 + _0xffffff8000ec2b9a: sbb r15, r15 + _0xffffff8000ec2b9d: and r15d, 0x100 + _0xffffff8000ec2ba4: add r15d, r9d + _0xffffff8000ec2ba7: add r15d, 0x58f34f49 + _0xffffff8000ec2bae: shl r15, 0x20 + _0xffffff8000ec2bb2: movabs r12, 0xa70cb05400000000 + _0xffffff8000ec2bbc: add r12, r15 + _0xffffff8000ec2bbf: mov r15, r12 + _0xffffff8000ec2bc2: sar r15, 0x1f + _0xffffff8000ec2bc6: movabs r13, 0x2def8fdfecbcbb1e + _0xffffff8000ec2bd0: and r13, r15 + _0xffffff8000ec2bd3: sar r12, 0x20 + _0xffffff8000ec2bd7: movabs r15, 0x16f7c7eff65e5d8f + _0xffffff8000ec2be1: xor r15, r12 + _0xffffff8000ec2be4: add r15, r13 + _0xffffff8000ec2be7: add r15, qword ptr [r14 + 0x160] + _0xffffff8000ec2bee: movabs r12, 0xe908381009a1a271 + _0xffffff8000ec2bf8: mov r9b, byte ptr [r12 + r15] + _0xffffff8000ec2bfc: mov r15b, r9b + _0xffffff8000ec2bff: add r15b, r15b + _0xffffff8000ec2c02: xor r9b, 0x5c + _0xffffff8000ec2c06: and r15b, 0xb8 + _0xffffff8000ec2c0a: add r15b, r9b + _0xffffff8000ec2c0d: add r15b, 0xf8 + _0xffffff8000ec2c11: movzx r9d, r15b + _0xffffff8000ec2c15: cmp r9b, 0x54 + _0xffffff8000ec2c19: sbb r15, r15 + _0xffffff8000ec2c1c: and r15d, 0x100 + _0xffffff8000ec2c23: add r15d, r9d + _0xffffff8000ec2c26: add r15d, 0x603aecdc + _0xffffff8000ec2c2d: shl r15, 0x20 + _0xffffff8000ec2c31: movabs r12, 0x9fc512d000000000 + _0xffffff8000ec2c3b: add r12, r15 + _0xffffff8000ec2c3e: mov r15, r12 + _0xffffff8000ec2c41: sar r15, 0x1f + _0xffffff8000ec2c45: movabs r13, 0x36587b897ff76f3e + _0xffffff8000ec2c4f: and r13, r15 + _0xffffff8000ec2c52: sar r12, 0x20 + _0xffffff8000ec2c56: movabs r15, 0x1b2c3dc4bffbb79f + _0xffffff8000ec2c60: xor r15, r12 + _0xffffff8000ec2c63: add r15, r13 + _0xffffff8000ec2c66: lea r15, [r10 + r15*4] + _0xffffff8000ec2c6a: movabs r12, 0x934f08ed00112184 + _0xffffff8000ec2c74: xor r8d, dword ptr [r12 + r15] + _0xffffff8000ec2c78: mov r9d, edi + _0xffffff8000ec2c7b: shr r9d, 7 + _0xffffff8000ec2c7f: and r9d, 0x44 + _0xffffff8000ec2c83: mov r15d, edi + _0xffffff8000ec2c86: shr r15d, 8 + _0xffffff8000ec2c8a: add r15d, 0xa2 + _0xffffff8000ec2c91: sub r15d, r9d + _0xffffff8000ec2c94: xor r15d, 0xa2 + _0xffffff8000ec2c9b: mov r9b, r15b + _0xffffff8000ec2c9e: add r9b, r9b + _0xffffff8000ec2ca1: xor r15b, 0x73 + _0xffffff8000ec2ca5: and r9b, 0xe6 + _0xffffff8000ec2ca9: add r9b, r15b + _0xffffff8000ec2cac: movzx r9d, r9b + _0xffffff8000ec2cb0: cmp r9b, 0x73 + _0xffffff8000ec2cb4: sbb r15, r15 + _0xffffff8000ec2cb7: and r15d, 0x100 + _0xffffff8000ec2cbe: add r15d, r9d + _0xffffff8000ec2cc1: add r15d, 0x10a03c58 + _0xffffff8000ec2cc8: shl r15, 0x20 + _0xffffff8000ec2ccc: movabs r12, 0xef5fc33500000000 + _0xffffff8000ec2cd6: add r12, r15 + _0xffffff8000ec2cd9: mov r15, r12 + _0xffffff8000ec2cdc: sar r15, 0x1f + _0xffffff8000ec2ce0: movabs r13, 0xfe6bbfcabcff5afe + _0xffffff8000ec2cea: and r13, r15 + _0xffffff8000ec2ced: sar r12, 0x20 + _0xffffff8000ec2cf1: movabs r15, 0x7f35dfe55e7fad7f + _0xffffff8000ec2cfb: xor r15, r12 + _0xffffff8000ec2cfe: add r15, r13 + _0xffffff8000ec2d01: add r15, qword ptr [r14 + 0x138] + _0xffffff8000ec2d08: movabs r12, 0x80ca201aa1805281 + _0xffffff8000ec2d12: mov r9b, byte ptr [r12 + r15] + _0xffffff8000ec2d16: mov r15b, r9b + _0xffffff8000ec2d19: add r15b, r15b + _0xffffff8000ec2d1c: xor r9b, 0x77 + _0xffffff8000ec2d20: and r15b, 0xee + _0xffffff8000ec2d24: add r15b, r9b + _0xffffff8000ec2d27: add r15b, 0x8c + _0xffffff8000ec2d2b: movzx r9d, r15b + _0xffffff8000ec2d2f: cmp r9b, 3 + _0xffffff8000ec2d33: sbb r15, r15 + _0xffffff8000ec2d36: and r15d, 0x100 + _0xffffff8000ec2d3d: add r15d, r9d + _0xffffff8000ec2d40: add r15d, 0x51f723e4 + _0xffffff8000ec2d47: shl r15, 0x20 + _0xffffff8000ec2d4b: movabs r12, 0xae08dc1900000000 + _0xffffff8000ec2d55: add r12, r15 + _0xffffff8000ec2d58: mov r15, r12 + _0xffffff8000ec2d5b: sar r15, 0x1f + _0xffffff8000ec2d5f: movabs r13, 0x3ff7f47fbd5f77fe + _0xffffff8000ec2d69: and r13, r15 + _0xffffff8000ec2d6c: sar r12, 0x20 + _0xffffff8000ec2d70: movabs r15, 0x3ffbfa3fdeafbbff + _0xffffff8000ec2d7a: xor r15, r12 + _0xffffff8000ec2d7d: add r15, r13 + _0xffffff8000ec2d80: lea r15, [rsi + r15*4] + _0xffffff8000ec2d84: movabs r12, 0x10170085411004 + _0xffffff8000ec2d8e: xor r8d, dword ptr [r12 + r15] + _0xffffff8000ec2d92: mov r9b, r8b + _0xffffff8000ec2d95: add r9b, r9b + _0xffffff8000ec2d98: mov r15b, r8b + _0xffffff8000ec2d9b: xor r15b, 0x73 + _0xffffff8000ec2d9f: and r9b, 0xe6 + _0xffffff8000ec2da3: add r9b, r15b + _0xffffff8000ec2da6: add r9b, 0xf0 + _0xffffff8000ec2daa: movzx r9d, r9b + _0xffffff8000ec2dae: cmp r9b, 0x63 + _0xffffff8000ec2db2: sbb r15, r15 + _0xffffff8000ec2db5: and r15d, 0x100 + _0xffffff8000ec2dbc: add r15d, r9d + _0xffffff8000ec2dbf: add r15d, 0x2df2f90c + _0xffffff8000ec2dc6: shl r15, 0x20 + _0xffffff8000ec2dca: movabs r12, 0xd20d069100000000 + _0xffffff8000ec2dd4: add r12, r15 + _0xffffff8000ec2dd7: mov r15, r12 + _0xffffff8000ec2dda: sar r15, 0x1f + _0xffffff8000ec2dde: movabs r13, 0xff7ffcf50def9f7e + _0xffffff8000ec2de8: and r13, r15 + _0xffffff8000ec2deb: sar r12, 0x20 + _0xffffff8000ec2def: movabs r15, 0x7fbffe7a86f7cfbf + _0xffffff8000ec2df9: xor r15, r12 + _0xffffff8000ec2dfc: add r15, r13 + _0xffffff8000ec2dff: add r15, qword ptr [r14 + 0x190] + _0xffffff8000ec2e06: movabs r12, 0x8040018579083041 + _0xffffff8000ec2e10: mov r9b, byte ptr [r12 + r15] + _0xffffff8000ec2e14: mov r15b, r9b + _0xffffff8000ec2e17: add r15b, r15b + _0xffffff8000ec2e1a: xor r9b, 0x7f + _0xffffff8000ec2e1e: add r9b, r15b + _0xffffff8000ec2e21: add r9b, 0xfc + _0xffffff8000ec2e25: movzx r9d, r9b + _0xffffff8000ec2e29: cmp r9b, 0x7b + _0xffffff8000ec2e2d: sbb r15, r15 + _0xffffff8000ec2e30: and r15d, 0x100 + _0xffffff8000ec2e37: add r15d, r9d + _0xffffff8000ec2e3a: add r15d, 0x4fa1df64 + _0xffffff8000ec2e41: shl r15, 0x20 + _0xffffff8000ec2e45: movabs r12, 0xb05e202100000000 + _0xffffff8000ec2e4f: add r12, r15 + _0xffffff8000ec2e52: mov r15, r12 + _0xffffff8000ec2e55: sar r15, 0x1f + _0xffffff8000ec2e59: movabs r13, 0x3ffffeed7dbfd7de + _0xffffff8000ec2e63: and r13, r15 + _0xffffff8000ec2e66: sar r12, 0x20 + _0xffffff8000ec2e6a: movabs r15, 0x1fffff76bedfebef + _0xffffff8000ec2e74: xor r15, r12 + _0xffffff8000ec2e77: add r15, r13 + _0xffffff8000ec2e7a: lea r15, [rax + r15*4] + _0xffffff8000ec2e7e: movabs r12, 0x8000022504805044 + _0xffffff8000ec2e88: mov eax, dword ptr [r12 + r15] + _0xffffff8000ec2e8c: movabs r15, 0x1848d508502304 + _0xffffff8000ec2e96: xor eax, dword ptr [r15 + rbx] + _0xffffff8000ec2e9a: mov dword ptr [rbp - 0x3b0], eax + _0xffffff8000ec2ea0: mov esi, dword ptr [rbp - 0x3c4] + _0xffffff8000ec2ea6: shr esi, 7 + _0xffffff8000ec2ea9: and esi, 0x7a + _0xffffff8000ec2eac: mov r9d, dword ptr [rbp - 0x3c4] + _0xffffff8000ec2eb3: shr r9d, 8 + _0xffffff8000ec2eb7: add r9d, 0x3d + _0xffffff8000ec2ebb: sub r9d, esi + _0xffffff8000ec2ebe: xor r9d, 0x3d + _0xffffff8000ec2ec2: mov sil, r9b + _0xffffff8000ec2ec5: add sil, sil + _0xffffff8000ec2ec8: xor r9b, 0x2f + _0xffffff8000ec2ecc: and sil, 0x5e + _0xffffff8000ec2ed0: add sil, r9b + _0xffffff8000ec2ed3: add sil, 0xdd + _0xffffff8000ec2ed7: movzx esi, sil + _0xffffff8000ec2edb: cmp sil, 0xc + _0xffffff8000ec2edf: sbb rbx, rbx + _0xffffff8000ec2ee2: and ebx, 0x100 + _0xffffff8000ec2ee8: add ebx, esi + _0xffffff8000ec2eea: add ebx, 0x70475146 + _0xffffff8000ec2ef0: shl rbx, 0x20 + _0xffffff8000ec2ef4: movabs r15, 0x8fb8aeae00000000 + _0xffffff8000ec2efe: add r15, rbx + _0xffffff8000ec2f01: mov rbx, r15 + _0xffffff8000ec2f04: sar rbx, 0x1f + _0xffffff8000ec2f08: movabs r12, 0x3ebbefbbd5f1efae + _0xffffff8000ec2f12: and r12, rbx + _0xffffff8000ec2f15: sar r15, 0x20 + _0xffffff8000ec2f19: movabs rbx, 0x1f5df7ddeaf8f7d7 + _0xffffff8000ec2f23: xor rbx, r15 + _0xffffff8000ec2f26: add rbx, r12 + _0xffffff8000ec2f29: add rbx, qword ptr [r14 + 0x178] + _0xffffff8000ec2f30: movabs r15, 0xe0a2082215070829 + _0xffffff8000ec2f3a: mov sil, byte ptr [r15 + rbx] + _0xffffff8000ec2f3e: mov r9b, sil + _0xffffff8000ec2f41: add r9b, r9b + _0xffffff8000ec2f44: xor sil, 0x5f + _0xffffff8000ec2f48: and r9b, 0xbe + _0xffffff8000ec2f4c: add r9b, sil + _0xffffff8000ec2f4f: add r9b, 0xb9 + _0xffffff8000ec2f53: movzx esi, r9b + _0xffffff8000ec2f57: cmp sil, 0x18 + _0xffffff8000ec2f5b: sbb rbx, rbx + _0xffffff8000ec2f5e: and ebx, 0x100 + _0xffffff8000ec2f64: add ebx, esi + _0xffffff8000ec2f66: add ebx, 0x6441e47d + _0xffffff8000ec2f6c: shl rbx, 0x20 + _0xffffff8000ec2f70: movabs r15, 0x9bbe1b6b00000000 + _0xffffff8000ec2f7a: add r15, rbx + _0xffffff8000ec2f7d: mov rbx, r15 + _0xffffff8000ec2f80: sar rbx, 0x1f + _0xffffff8000ec2f84: movabs r12, 0x23fff6ef01cb67fc + _0xffffff8000ec2f8e: and r12, rbx + _0xffffff8000ec2f91: sar r15, 0x20 + _0xffffff8000ec2f95: movabs rbx, 0x31fffb7780e5b3fe + _0xffffff8000ec2f9f: xor rbx, r15 + _0xffffff8000ec2fa2: add rbx, r12 + _0xffffff8000ec2fa5: mov rsi, qword ptr [rbp - 0x3d0] + _0xffffff8000ec2fac: lea rbx, [rsi + rbx*4] + _0xffffff8000ec2fb0: movabs r15, 0x38001221fc693008 + _0xffffff8000ec2fba: mov r9d, dword ptr [r15 + rbx] + _0xffffff8000ec2fbe: mov r10d, r11d + _0xffffff8000ec2fc1: shr r10d, 0xf + _0xffffff8000ec2fc5: and r10d, 0x5a + _0xffffff8000ec2fc9: mov ebx, r11d + _0xffffff8000ec2fcc: shr ebx, 0x10 + _0xffffff8000ec2fcf: add ebx, 0x2d + _0xffffff8000ec2fd2: sub ebx, r10d + _0xffffff8000ec2fd5: xor ebx, 0x2d + _0xffffff8000ec2fd8: mov r10b, bl + _0xffffff8000ec2fdb: add r10b, r10b + _0xffffff8000ec2fde: xor bl, 0x3f + _0xffffff8000ec2fe1: and r10b, 0x7e + _0xffffff8000ec2fe5: add r10b, bl + _0xffffff8000ec2fe8: add r10b, 0xc8 + _0xffffff8000ec2fec: movzx ebx, r10b + _0xffffff8000ec2ff0: cmp bl, 7 + _0xffffff8000ec2ff3: sbb r15, r15 + _0xffffff8000ec2ff6: and r15, 0x100 + _0xffffff8000ec2ffd: add r15, rbx + _0xffffff8000ec3000: add r15, -7 + _0xffffff8000ec3004: movabs rbx, 0x2ffeffef6dbabe7f + _0xffffff8000ec300e: mov r12, r15 + _0xffffff8000ec3011: and r12, rbx + _0xffffff8000ec3014: xor r15, rbx + _0xffffff8000ec3017: lea rbx, [r15 + r12*2] + _0xffffff8000ec301b: add rbx, qword ptr [r14 + 0x120] + _0xffffff8000ec3022: movabs r15, 0xd001001092454181 + _0xffffff8000ec302c: mov r10b, byte ptr [r15 + rbx] + _0xffffff8000ec3030: mov bl, r10b + _0xffffff8000ec3033: add bl, bl + _0xffffff8000ec3035: xor r10b, 0x59 + _0xffffff8000ec3039: and bl, 0xb2 + _0xffffff8000ec303c: add bl, r10b + _0xffffff8000ec303f: add bl, 0xf0 + _0xffffff8000ec3042: movzx r10d, bl + _0xffffff8000ec3046: cmp r10b, 0x49 + _0xffffff8000ec304a: sbb rbx, rbx + _0xffffff8000ec304d: and ebx, 0x100 + _0xffffff8000ec3053: add ebx, r10d + _0xffffff8000ec3056: add ebx, 0x37168cd7 + _0xffffff8000ec305c: shl rbx, 0x20 + _0xffffff8000ec3060: movabs r15, 0xc8e972e000000000 + _0xffffff8000ec306a: add r15, rbx + _0xffffff8000ec306d: mov rbx, r15 + _0xffffff8000ec3070: sar rbx, 0x1f + _0xffffff8000ec3074: movabs r12, 0x1dbdf7fc7cdef2dc + _0xffffff8000ec307e: and r12, rbx + _0xffffff8000ec3081: sar r15, 0x20 + _0xffffff8000ec3085: movabs rbx, 0x2edefbfe3e6f796e + _0xffffff8000ec308f: xor rbx, r15 + _0xffffff8000ec3092: add rbx, r12 + _0xffffff8000ec3095: mov r10, qword ptr [rbp - 0x3d8] + _0xffffff8000ec309c: lea rbx, [r10 + rbx*4] + _0xffffff8000ec30a0: movabs r15, 0x4484100706421a48 + _0xffffff8000ec30aa: xor r9d, dword ptr [r15 + rbx] + _0xffffff8000ec30ae: mov bl, cl + _0xffffff8000ec30b0: add bl, bl + _0xffffff8000ec30b2: mov r15b, cl + _0xffffff8000ec30b5: xor r15b, 0x2f + _0xffffff8000ec30b9: and bl, 0x5e + _0xffffff8000ec30bc: add bl, r15b + _0xffffff8000ec30bf: add bl, 0xe0 + _0xffffff8000ec30c2: movzx ebx, bl + _0xffffff8000ec30c5: cmp bl, 0xf + _0xffffff8000ec30c8: sbb r15, r15 + _0xffffff8000ec30cb: and r15d, 0x100 + _0xffffff8000ec30d2: add r15d, ebx + _0xffffff8000ec30d5: add r15d, 0x228b17c2 + _0xffffff8000ec30dc: shl r15, 0x20 + _0xffffff8000ec30e0: movabs rbx, 0xdd74e82f00000000 + _0xffffff8000ec30ea: add rbx, r15 + _0xffffff8000ec30ed: mov r15, rbx + _0xffffff8000ec30f0: sar r15, 0x1f + _0xffffff8000ec30f4: movabs r12, 0xffdafaf43e9bffd8 + _0xffffff8000ec30fe: and r12, r15 + _0xffffff8000ec3101: sar rbx, 0x20 + _0xffffff8000ec3105: movabs r15, 0x7fed7d7a1f4dffec + _0xffffff8000ec310f: xor r15, rbx + _0xffffff8000ec3112: add r15, r12 + _0xffffff8000ec3115: add r15, qword ptr [r14 + 0x150] + _0xffffff8000ec311c: movabs rbx, 0x80128285e0b20014 + _0xffffff8000ec3126: mov bl, byte ptr [rbx + r15] + _0xffffff8000ec312a: mov r15b, bl + _0xffffff8000ec312d: add r15b, r15b + _0xffffff8000ec3130: xor bl, 0x6f + _0xffffff8000ec3133: and r15b, 0xde + _0xffffff8000ec3137: add r15b, bl + _0xffffff8000ec313a: add r15b, 0xf9 + _0xffffff8000ec313e: movzx ebx, r15b + _0xffffff8000ec3142: cmp bl, 0x68 + _0xffffff8000ec3145: sbb r15, r15 + _0xffffff8000ec3148: and r15d, 0x100 + _0xffffff8000ec314f: add r15d, ebx + _0xffffff8000ec3152: add r15d, 0x11ca6677 + _0xffffff8000ec3159: shl r15, 0x20 + _0xffffff8000ec315d: movabs rbx, 0xee35992100000000 + _0xffffff8000ec3167: add rbx, r15 + _0xffffff8000ec316a: mov r15, rbx + _0xffffff8000ec316d: sar r15, 0x1f + _0xffffff8000ec3171: movabs r12, 0x3fffefcaf7ced7ea + _0xffffff8000ec317b: and r12, r15 + _0xffffff8000ec317e: sar rbx, 0x20 + _0xffffff8000ec3182: movabs r15, 0x3ffff7e57be76bf5 + _0xffffff8000ec318c: xor r15, rbx + _0xffffff8000ec318f: add r15, r12 + _0xffffff8000ec3192: mov rax, qword ptr [rbp - 0x3c0] + _0xffffff8000ec3199: lea rbx, [rax + r15*4] + _0xffffff8000ec319d: movabs r15, 0x206a1062502c + _0xffffff8000ec31a7: xor r9d, dword ptr [r15 + rbx] + _0xffffff8000ec31ab: mov ebx, edi + _0xffffff8000ec31ad: shr ebx, 0x17 + _0xffffff8000ec31b0: and ebx, 0xa0 + _0xffffff8000ec31b6: mov r15d, edi + _0xffffff8000ec31b9: shr r15d, 0x18 + _0xffffff8000ec31bd: add r15d, 0x50 + _0xffffff8000ec31c1: sub r15d, ebx + _0xffffff8000ec31c4: xor r15d, 0x50 + _0xffffff8000ec31c8: mov bl, r15b + _0xffffff8000ec31cb: add bl, bl + _0xffffff8000ec31cd: xor r15b, 0x7e + _0xffffff8000ec31d1: and bl, 0xfc + _0xffffff8000ec31d4: add bl, r15b + _0xffffff8000ec31d7: add bl, 0xde + _0xffffff8000ec31da: movzx ebx, bl + _0xffffff8000ec31dd: cmp bl, 0x5c + _0xffffff8000ec31e0: sbb r15, r15 + _0xffffff8000ec31e3: and r15d, 0x100 + _0xffffff8000ec31ea: add r15d, ebx + _0xffffff8000ec31ed: add r15d, 0x2d5e431c + _0xffffff8000ec31f4: shl r15, 0x20 + _0xffffff8000ec31f8: movabs rbx, 0xd2a1bc8800000000 + _0xffffff8000ec3202: add rbx, r15 + _0xffffff8000ec3205: mov r15, rbx + _0xffffff8000ec3208: sar r15, 0x1f + _0xffffff8000ec320c: movabs r12, 0xb37f37ffa3dfb7f0 + _0xffffff8000ec3216: and r12, r15 + _0xffffff8000ec3219: sar rbx, 0x20 + _0xffffff8000ec321d: movabs r15, 0x59bf9bffd1efdbf8 + _0xffffff8000ec3227: xor r15, rbx + _0xffffff8000ec322a: add r15, r12 + _0xffffff8000ec322d: add r15, qword ptr [r14 + 0x148] + _0xffffff8000ec3234: movabs rbx, 0xa64064002e102408 + _0xffffff8000ec323e: mov bl, byte ptr [rbx + r15] + _0xffffff8000ec3242: mov r15b, bl + _0xffffff8000ec3245: add r15b, r15b + _0xffffff8000ec3248: xor bl, 0x1b + _0xffffff8000ec324b: and r15b, 0x36 + _0xffffff8000ec324f: add r15b, bl + _0xffffff8000ec3252: add r15b, 0xe8 + _0xffffff8000ec3256: movzx ebx, r15b + _0xffffff8000ec325a: cmp bl, 3 + _0xffffff8000ec325d: sbb r15, r15 + _0xffffff8000ec3260: and r15d, 0x100 + _0xffffff8000ec3267: add r15d, ebx + _0xffffff8000ec326a: add r15d, 0x4d8ec2db + _0xffffff8000ec3271: shl r15, 0x20 + _0xffffff8000ec3275: movabs rbx, 0xb2713d2200000000 + _0xffffff8000ec327f: add rbx, r15 + _0xffffff8000ec3282: mov r15, rbx + _0xffffff8000ec3285: sar r15, 0x1f + _0xffffff8000ec3289: movabs r12, 0x339f1bb4ffd55f9c + _0xffffff8000ec3293: and r12, r15 + _0xffffff8000ec3296: sar rbx, 0x20 + _0xffffff8000ec329a: movabs r15, 0x39cf8dda7feaafce + _0xffffff8000ec32a4: xor r15, rbx + _0xffffff8000ec32a7: add r15, r12 + _0xffffff8000ec32aa: mov rax, qword ptr [rbp - 0x3b8] + _0xffffff8000ec32b1: lea rbx, [rax + r15*4] + _0xffffff8000ec32b5: movabs r15, 0x18c1c896005540c8 + _0xffffff8000ec32bf: xor r9d, dword ptr [r15 + rbx] + _0xffffff8000ec32c3: mov ebx, r9d + _0xffffff8000ec32c6: shr ebx, 0xf + _0xffffff8000ec32c9: and ebx, 0x6c + _0xffffff8000ec32cc: mov r15d, r9d + _0xffffff8000ec32cf: shr r15d, 0x10 + _0xffffff8000ec32d3: add r15d, 0xb6 + _0xffffff8000ec32da: sub r15d, ebx + _0xffffff8000ec32dd: xor r15d, 0xb6 + _0xffffff8000ec32e4: mov bl, r15b + _0xffffff8000ec32e7: add bl, bl + _0xffffff8000ec32e9: xor r15b, 0x59 + _0xffffff8000ec32ed: and bl, 0xb2 + _0xffffff8000ec32f0: add bl, r15b + _0xffffff8000ec32f3: add bl, 0xf0 + _0xffffff8000ec32f6: movzx ebx, bl + _0xffffff8000ec32f9: cmp bl, 0x49 + _0xffffff8000ec32fc: sbb r15, r15 + _0xffffff8000ec32ff: and r15d, 0x100 + _0xffffff8000ec3306: add r15d, ebx + _0xffffff8000ec3309: add r15d, 0x6c8f5e9e + _0xffffff8000ec3310: shl r15, 0x20 + _0xffffff8000ec3314: movabs rbx, 0x9370a11900000000 + _0xffffff8000ec331e: add rbx, r15 + _0xffffff8000ec3321: mov r15, rbx + _0xffffff8000ec3324: sar r15, 0x1f + _0xffffff8000ec3328: movabs r12, 0xbfcfe3adddf7df54 + _0xffffff8000ec3332: and r12, r15 + _0xffffff8000ec3335: sar rbx, 0x20 + _0xffffff8000ec3339: movabs r15, 0x5fe7f1d6eefbefaa + _0xffffff8000ec3343: xor r15, rbx + _0xffffff8000ec3346: add r15, r12 + _0xffffff8000ec3349: add r15, qword ptr [r14 + 0x1e0] + _0xffffff8000ec3350: movabs rbx, 0xa0180e2911041056 + _0xffffff8000ec335a: mov bl, byte ptr [rbx + r15] + _0xffffff8000ec335e: mov r15b, bl + _0xffffff8000ec3361: add r15b, r15b + _0xffffff8000ec3364: xor bl, 0x6c + _0xffffff8000ec3367: and r15b, 0xd8 + _0xffffff8000ec336b: add r15b, bl + _0xffffff8000ec336e: add r15b, 0xe0 + _0xffffff8000ec3372: movzx ebx, r15b + _0xffffff8000ec3376: cmp bl, 0x4c + _0xffffff8000ec3379: sbb r15, r15 + _0xffffff8000ec337c: and r15, 0x100 + _0xffffff8000ec3383: add r15, rbx + _0xffffff8000ec3386: add r15, -0x4c + _0xffffff8000ec338a: movabs rbx, 0x1fecfdb7ff5ef379 + _0xffffff8000ec3394: and rbx, r15 + _0xffffff8000ec3397: movabs r12, 0x3fecfdb7ff5ef379 + _0xffffff8000ec33a1: xor r12, r15 + _0xffffff8000ec33a4: lea rbx, [r12 + rbx*2] + _0xffffff8000ec33a8: lea rbx, [r10 + rbx*4] + _0xffffff8000ec33ac: movabs r15, 0x4c09200284321c + _0xffffff8000ec33b6: mov eax, dword ptr [rbp - 0x3b0] + _0xffffff8000ec33bc: xor eax, dword ptr [r15 + rbx] + _0xffffff8000ec33c0: mov dword ptr [rbp - 0x3b0], eax + _0xffffff8000ec33c6: mov ebx, ecx + _0xffffff8000ec33c8: shr ebx, 0x17 + _0xffffff8000ec33cb: and ebx, 0x36 + _0xffffff8000ec33ce: shr ecx, 0x18 + _0xffffff8000ec33d1: add ecx, 0x1b + _0xffffff8000ec33d4: sub ecx, ebx + _0xffffff8000ec33d6: xor ecx, 0x1b + _0xffffff8000ec33d9: mov bl, cl + _0xffffff8000ec33db: add bl, bl + _0xffffff8000ec33dd: xor cl, 0x76 + _0xffffff8000ec33e0: and bl, 0xec + _0xffffff8000ec33e3: add bl, cl + _0xffffff8000ec33e5: movzx ecx, bl + _0xffffff8000ec33e8: cmp cl, 0x76 + _0xffffff8000ec33eb: sbb rbx, rbx + _0xffffff8000ec33ee: and ebx, 0x100 + _0xffffff8000ec33f4: add ebx, ecx + _0xffffff8000ec33f6: add ebx, 0x7c77b81c + _0xffffff8000ec33fc: shl rbx, 0x20 + _0xffffff8000ec3400: movabs r15, 0x8388476e00000000 + _0xffffff8000ec340a: add r15, rbx + _0xffffff8000ec340d: mov rbx, r15 + _0xffffff8000ec3410: sar rbx, 0x1f + _0xffffff8000ec3414: movabs r12, 0xb395e7fbbf96dbe2 + _0xffffff8000ec341e: and r12, rbx + _0xffffff8000ec3421: sar r15, 0x20 + _0xffffff8000ec3425: movabs rbx, 0x59caf3fddfcb6df1 + _0xffffff8000ec342f: xor rbx, r15 + _0xffffff8000ec3432: add rbx, r12 + _0xffffff8000ec3435: add rbx, qword ptr [r14 + 0x168] + _0xffffff8000ec343c: movabs r15, 0xa6350c022034920f + _0xffffff8000ec3446: mov cl, byte ptr [r15 + rbx] + _0xffffff8000ec344a: mov bl, cl + _0xffffff8000ec344c: add bl, bl + _0xffffff8000ec344e: xor cl, 0x3f + _0xffffff8000ec3451: and bl, 0x7e + _0xffffff8000ec3454: add bl, cl + _0xffffff8000ec3456: add bl, 0xfc + _0xffffff8000ec3459: movzx ecx, bl + _0xffffff8000ec345c: cmp cl, 0x3b + _0xffffff8000ec345f: sbb rbx, rbx + _0xffffff8000ec3462: and ebx, 0x100 + _0xffffff8000ec3468: add ebx, ecx + _0xffffff8000ec346a: add ebx, 0x4e4c0e71 + _0xffffff8000ec3470: shl rbx, 0x20 + _0xffffff8000ec3474: movabs r15, 0xb1b3f15400000000 + _0xffffff8000ec347e: add r15, rbx + _0xffffff8000ec3481: mov rbx, r15 + _0xffffff8000ec3484: sar rbx, 0x1f + _0xffffff8000ec3488: movabs r12, 0x2ffe3dd7fdfff3fa + _0xffffff8000ec3492: and r12, rbx + _0xffffff8000ec3495: sar r15, 0x20 + _0xffffff8000ec3499: movabs rbx, 0x37ff1eebfefff9fd + _0xffffff8000ec34a3: xor rbx, r15 + _0xffffff8000ec34a6: add rbx, r12 + _0xffffff8000ec34a9: mov rax, qword ptr [rbp - 0x3b8] + _0xffffff8000ec34b0: lea rbx, [rax + rbx*4] + _0xffffff8000ec34b4: mov eax, dword ptr [rbp - 0x3c4] + _0xffffff8000ec34ba: mov cl, al + _0xffffff8000ec34bc: add cl, cl + _0xffffff8000ec34be: xor al, 0x7d + _0xffffff8000ec34c0: and cl, 0xfa + _0xffffff8000ec34c3: add cl, al + _0xffffff8000ec34c5: add cl, 0x98 + _0xffffff8000ec34c8: movzx eax, cl + _0xffffff8000ec34cb: cmp al, 0x15 + _0xffffff8000ec34cd: sbb r15, r15 + _0xffffff8000ec34d0: and r15d, 0x100 + _0xffffff8000ec34d7: add r15d, eax + _0xffffff8000ec34da: add r15d, 0x1be2eab3 + _0xffffff8000ec34e1: shl r15, 0x20 + _0xffffff8000ec34e5: movabs r12, 0xe41d153800000000 + _0xffffff8000ec34ef: add r12, r15 + _0xffffff8000ec34f2: mov r15, r12 + _0xffffff8000ec34f5: sar r15, 0x1f + _0xffffff8000ec34f9: movabs r13, 0x1befdd79fefd7bcc + _0xffffff8000ec3503: and r13, r15 + _0xffffff8000ec3506: sar r12, 0x20 + _0xffffff8000ec350a: movabs r15, 0xdf7eebcff7ebde6 + _0xffffff8000ec3514: xor r15, r12 + _0xffffff8000ec3517: add r15, r13 + _0xffffff8000ec351a: add r15, qword ptr [r14 + 0x170] + _0xffffff8000ec3521: movabs r12, 0xf20811430081421a + _0xffffff8000ec352b: mov al, byte ptr [r12 + r15] + _0xffffff8000ec352f: mov cl, al + _0xffffff8000ec3531: add cl, cl + _0xffffff8000ec3533: xor al, 0x7c + _0xffffff8000ec3535: and cl, 0xf8 + _0xffffff8000ec3538: add cl, al + _0xffffff8000ec353a: add cl, 0xdc + _0xffffff8000ec353d: movzx eax, cl + _0xffffff8000ec3540: cmp al, 0x58 + _0xffffff8000ec3542: sbb r15, r15 + _0xffffff8000ec3545: and r15d, 0x100 + _0xffffff8000ec354c: add r15d, eax + _0xffffff8000ec354f: add r15d, 0x5368ef29 + _0xffffff8000ec3556: shl r15, 0x20 + _0xffffff8000ec355a: movabs r12, 0xac97107f00000000 + _0xffffff8000ec3564: add r12, r15 + _0xffffff8000ec3567: mov r15, r12 + _0xffffff8000ec356a: sar r15, 0x1f + _0xffffff8000ec356e: movabs r13, 0x2f9fcbfdda52fa98 + _0xffffff8000ec3578: and r13, r15 + _0xffffff8000ec357b: sar r12, 0x20 + _0xffffff8000ec357f: movabs r15, 0x37cfe5feed297d4c + _0xffffff8000ec3589: xor r15, r12 + _0xffffff8000ec358c: add r15, r13 + _0xffffff8000ec358f: mov rax, qword ptr [rbp - 0x3c0] + _0xffffff8000ec3596: lea r15, [rax + r15*4] + _0xffffff8000ec359a: movabs r12, 0x20c068044b5a0ad0 + _0xffffff8000ec35a4: mov r15d, dword ptr [r12 + r15] + _0xffffff8000ec35a8: mov ecx, r11d + _0xffffff8000ec35ab: shr ecx, 7 + _0xffffff8000ec35ae: and ecx, 0x34 + _0xffffff8000ec35b1: shr r11d, 8 + _0xffffff8000ec35b5: add r11d, 0x9a + _0xffffff8000ec35bc: sub r11d, ecx + _0xffffff8000ec35bf: xor r11d, 0x9a + _0xffffff8000ec35c6: mov cl, r11b + _0xffffff8000ec35c9: add cl, cl + _0xffffff8000ec35cb: xor r11b, 0x53 + _0xffffff8000ec35cf: and cl, 0xa6 + _0xffffff8000ec35d2: add cl, r11b + _0xffffff8000ec35d5: add cl, 0xf0 + _0xffffff8000ec35d8: movzx ecx, cl + _0xffffff8000ec35db: cmp cl, 0x43 + _0xffffff8000ec35de: sbb r12, r12 + _0xffffff8000ec35e1: and r12d, 0x100 + _0xffffff8000ec35e8: add r12d, ecx + _0xffffff8000ec35eb: add r12d, 0x2768353e + _0xffffff8000ec35f2: shl r12, 0x20 + _0xffffff8000ec35f6: movabs r13, 0xd897ca7f00000000 + _0xffffff8000ec3600: add r13, r12 + _0xffffff8000ec3603: mov r12, r13 + _0xffffff8000ec3606: sar r12, 0x1f + _0xffffff8000ec360a: movabs rcx, 0xf7b7edeffdedcde6 + _0xffffff8000ec3614: and rcx, r12 + _0xffffff8000ec3617: sar r13, 0x20 + _0xffffff8000ec361b: movabs r12, 0x7bdbf6f7fef6e6f3 + _0xffffff8000ec3625: xor r12, r13 + _0xffffff8000ec3628: add r12, rcx + _0xffffff8000ec362b: add r12, qword ptr [r14 + 0x118] + _0xffffff8000ec3632: movabs r13, 0x842409080109190d + _0xffffff8000ec363c: mov cl, byte ptr [r13 + r12] + _0xffffff8000ec3641: mov r11b, cl + _0xffffff8000ec3644: add r11b, r11b + _0xffffff8000ec3647: xor cl, 0x51 + _0xffffff8000ec364a: and r11b, 0xa2 + _0xffffff8000ec364e: add r11b, cl + _0xffffff8000ec3651: movzx r12d, r11b + _0xffffff8000ec3655: cmp r12b, 0x51 + _0xffffff8000ec3659: sbb r13, r13 + _0xffffff8000ec365c: and r13, 0x100 + _0xffffff8000ec3663: add r13, r12 + _0xffffff8000ec3666: add r13, -0x51 + _0xffffff8000ec366a: movabs r12, 0x1ed9fff71fff7ef8 + _0xffffff8000ec3674: and r12, r13 + _0xffffff8000ec3677: movabs rcx, 0x3ed9fff71fff7ef8 + _0xffffff8000ec3681: xor rcx, r13 + _0xffffff8000ec3684: lea r12, [rcx + r12*2] + _0xffffff8000ec3688: lea r12, [rsi + r12*4] + _0xffffff8000ec368c: movabs r13, 0x498002380020420 + _0xffffff8000ec3696: xor r15d, dword ptr [r13 + r12] + _0xffffff8000ec369b: movabs r12, 0x200384500400180c + _0xffffff8000ec36a5: xor r15d, dword ptr [r12 + rbx] + _0xffffff8000ec36a9: mov ecx, edi + _0xffffff8000ec36ab: shr ecx, 0xf + _0xffffff8000ec36ae: and ecx, 0x92 + _0xffffff8000ec36b4: shr edi, 0x10 + _0xffffff8000ec36b7: add edi, 0xc9 + _0xffffff8000ec36bd: sub edi, ecx + _0xffffff8000ec36bf: xor edi, 0xc9 + _0xffffff8000ec36c5: mov cl, dil + _0xffffff8000ec36c8: add cl, cl + _0xffffff8000ec36ca: xor dil, 0x79 + _0xffffff8000ec36ce: and cl, 0xf2 + _0xffffff8000ec36d1: add cl, dil + _0xffffff8000ec36d4: add cl, 0xb0 + _0xffffff8000ec36d7: movzx ecx, cl + _0xffffff8000ec36da: cmp cl, 0x29 + _0xffffff8000ec36dd: sbb rbx, rbx + _0xffffff8000ec36e0: and ebx, 0x100 + _0xffffff8000ec36e6: add ebx, ecx + _0xffffff8000ec36e8: add ebx, 0x48092a9e + _0xffffff8000ec36ee: shl rbx, 0x20 + _0xffffff8000ec36f2: movabs r12, 0xb7f6d53900000000 + _0xffffff8000ec36fc: add r12, rbx + _0xffffff8000ec36ff: mov rbx, r12 + _0xffffff8000ec3702: sar rbx, 0x1f + _0xffffff8000ec3706: movabs r13, 0xdbe7fffab7360bda + _0xffffff8000ec3710: and r13, rbx + _0xffffff8000ec3713: sar r12, 0x20 + _0xffffff8000ec3717: movabs rbx, 0x6df3fffd5b9b05ed + _0xffffff8000ec3721: xor rbx, r12 + _0xffffff8000ec3724: add rbx, r13 + _0xffffff8000ec3727: add rbx, qword ptr [r14 + 0x140] + _0xffffff8000ec372e: movabs r12, 0x920c0002a464fa13 + _0xffffff8000ec3738: mov cl, byte ptr [r12 + rbx] + _0xffffff8000ec373c: mov dil, cl + _0xffffff8000ec373f: add dil, dil + _0xffffff8000ec3742: xor cl, 0x77 + _0xffffff8000ec3745: and dil, 0xee + _0xffffff8000ec3749: add dil, cl + _0xffffff8000ec374c: add dil, 0xf0 + _0xffffff8000ec3750: movzx ecx, dil + _0xffffff8000ec3754: cmp cl, 0x67 + _0xffffff8000ec3757: sbb rbx, rbx + _0xffffff8000ec375a: and ebx, 0x100 + _0xffffff8000ec3760: add ebx, ecx + _0xffffff8000ec3762: add ebx, 0x43f9de6b + _0xffffff8000ec3768: shl rbx, 0x20 + _0xffffff8000ec376c: movabs r12, 0xbc06212e00000000 + _0xffffff8000ec3776: add r12, rbx + _0xffffff8000ec3779: mov rbx, r12 + _0xffffff8000ec377c: sar rbx, 0x1f + _0xffffff8000ec3780: movabs r13, 0x3f5dba3ff77ffefc + _0xffffff8000ec378a: and r13, rbx + _0xffffff8000ec378d: sar r12, 0x20 + _0xffffff8000ec3791: movabs rbx, 0x3faedd1ffbbfff7e + _0xffffff8000ec379b: xor rbx, r12 + _0xffffff8000ec379e: add rbx, r13 + _0xffffff8000ec37a1: lea rbx, [r10 + rbx*4] + _0xffffff8000ec37a5: movabs r12, 0x1448b8011000208 + _0xffffff8000ec37af: xor r15d, dword ptr [r12 + rbx] + _0xffffff8000ec37b3: mov ecx, r15d + _0xffffff8000ec37b6: shr ecx, 0x17 + _0xffffff8000ec37b9: and ecx, 0xfc + _0xffffff8000ec37bf: mov edi, r15d + _0xffffff8000ec37c2: shr edi, 0x18 + _0xffffff8000ec37c5: add edi, 0x7e + _0xffffff8000ec37c8: sub edi, ecx + _0xffffff8000ec37ca: xor edi, 0x7e + _0xffffff8000ec37cd: mov cl, dil + _0xffffff8000ec37d0: add cl, cl + _0xffffff8000ec37d2: xor dil, 0x3f + _0xffffff8000ec37d6: and cl, 0x7e + _0xffffff8000ec37d9: add cl, dil + _0xffffff8000ec37dc: add cl, 0xfc + _0xffffff8000ec37df: movzx ecx, cl + _0xffffff8000ec37e2: cmp cl, 0x3b + _0xffffff8000ec37e5: sbb rbx, rbx + _0xffffff8000ec37e8: and ebx, 0x100 + _0xffffff8000ec37ee: add ebx, ecx + _0xffffff8000ec37f0: add ebx, 0x588fbf2e + _0xffffff8000ec37f6: shl rbx, 0x20 + _0xffffff8000ec37fa: movabs r12, 0xa770409700000000 + _0xffffff8000ec3804: add r12, rbx + _0xffffff8000ec3807: mov rbx, r12 + _0xffffff8000ec380a: sar rbx, 0x1f + _0xffffff8000ec380e: movabs r13, 0xf9f977af7cce3f6e + _0xffffff8000ec3818: and r13, rbx + _0xffffff8000ec381b: sar r12, 0x20 + _0xffffff8000ec381f: movabs rbx, 0x7cfcbbd7be671fb7 + _0xffffff8000ec3829: xor rbx, r12 + _0xffffff8000ec382c: add rbx, r13 + _0xffffff8000ec382f: add rbx, qword ptr [r14 + 0x208] + _0xffffff8000ec3836: movabs r12, 0x830344284198e049 + _0xffffff8000ec3840: mov cl, byte ptr [r12 + rbx] + _0xffffff8000ec3844: mov dil, cl + _0xffffff8000ec3847: add dil, dil + _0xffffff8000ec384a: xor cl, 0x55 + _0xffffff8000ec384d: and dil, 0xaa + _0xffffff8000ec3851: add dil, cl + _0xffffff8000ec3854: add dil, 0xbf + _0xffffff8000ec3858: movzx ecx, dil + _0xffffff8000ec385c: cmp cl, 0x14 + _0xffffff8000ec385f: sbb rbx, rbx + _0xffffff8000ec3862: and ebx, 0x100 + _0xffffff8000ec3868: add ebx, ecx + _0xffffff8000ec386a: add ebx, 0x618b7fdf + _0xffffff8000ec3870: shl rbx, 0x20 + _0xffffff8000ec3874: movabs r12, 0x9e74800d00000000 + _0xffffff8000ec387e: add r12, rbx + _0xffffff8000ec3881: mov rbx, r12 + _0xffffff8000ec3884: sar rbx, 0x1f + _0xffffff8000ec3888: movabs r13, 0x154249a7e97adf9e + _0xffffff8000ec3892: and r13, rbx + _0xffffff8000ec3895: sar r12, 0x20 + _0xffffff8000ec3899: movabs rbx, 0x2aa124d3f4bd6fcf + _0xffffff8000ec38a3: xor rbx, r12 + _0xffffff8000ec38a6: add rbx, r13 + _0xffffff8000ec38a9: mov rax, qword ptr [rbp - 0x3b8] + _0xffffff8000ec38b0: lea rbx, [rax + rbx*4] + _0xffffff8000ec38b4: movabs r12, 0x557b6cb02d0a40c4 + _0xffffff8000ec38be: mov eax, dword ptr [rbp - 0x3b0] + _0xffffff8000ec38c4: xor eax, dword ptr [r12 + rbx] + _0xffffff8000ec38c8: mov dword ptr [rbp - 0x3b0], eax + _0xffffff8000ec38ce: mov cl, al + _0xffffff8000ec38d0: add cl, cl + _0xffffff8000ec38d2: mov dil, al + _0xffffff8000ec38d5: xor dil, 0x7f + _0xffffff8000ec38d9: add dil, cl + _0xffffff8000ec38dc: add dil, 0xfc + _0xffffff8000ec38e0: movzx ecx, dil + _0xffffff8000ec38e4: cmp cl, 0x7b + _0xffffff8000ec38e7: sbb rbx, rbx + _0xffffff8000ec38ea: and ebx, 0x100 + _0xffffff8000ec38f0: add ebx, ecx + _0xffffff8000ec38f2: add ebx, 0x76bb174d + _0xffffff8000ec38f8: shl rbx, 0x20 + _0xffffff8000ec38fc: movabs r12, 0x8944e83800000000 + _0xffffff8000ec3906: add r12, rbx + _0xffffff8000ec3909: mov rbx, r12 + _0xffffff8000ec390c: sar rbx, 0x1f + _0xffffff8000ec3910: movabs r13, 0xff973799fdc7fafe + _0xffffff8000ec391a: and r13, rbx + _0xffffff8000ec391d: sar r12, 0x20 + _0xffffff8000ec3921: movabs rbx, 0x7fcb9bccfee3fd7f + _0xffffff8000ec392b: xor rbx, r12 + _0xffffff8000ec392e: add rbx, r13 + _0xffffff8000ec3931: add rbx, qword ptr [r14 + 0x410] + _0xffffff8000ec3938: movabs r12, 0x80346433011c0281 + _0xffffff8000ec3942: mov cl, byte ptr [r12 + rbx] + _0xffffff8000ec3946: mov byte ptr [rbp - 0x3f1], cl + _0xffffff8000ec394c: mov edi, r9d + _0xffffff8000ec394f: shr edi, 7 + _0xffffff8000ec3952: and edi, 0xf8 + _0xffffff8000ec3958: mov r11d, r9d + _0xffffff8000ec395b: shr r11d, 8 + _0xffffff8000ec395f: add r11d, 0xfc + _0xffffff8000ec3966: sub r11d, edi + _0xffffff8000ec3969: xor r11d, 0xfc + _0xffffff8000ec3970: mov dil, r11b + _0xffffff8000ec3973: add dil, dil + _0xffffff8000ec3976: xor r11b, 0x5f + _0xffffff8000ec397a: and dil, 0xbe + _0xffffff8000ec397e: add dil, r11b + _0xffffff8000ec3981: add dil, 0xb9 + _0xffffff8000ec3985: movzx edi, dil + _0xffffff8000ec3989: cmp dil, 0x18 + _0xffffff8000ec398d: sbb rbx, rbx + _0xffffff8000ec3990: and ebx, 0x100 + _0xffffff8000ec3996: add ebx, edi + _0xffffff8000ec3998: add ebx, 0x7758cf9e + _0xffffff8000ec399e: shl rbx, 0x20 + _0xffffff8000ec39a2: movabs r12, 0x88a7304a00000000 + _0xffffff8000ec39ac: add r12, rbx + _0xffffff8000ec39af: mov rbx, r12 + _0xffffff8000ec39b2: sar rbx, 0x1f + _0xffffff8000ec39b6: movabs r13, 0xd4f777bd8b5c2d54 + _0xffffff8000ec39c0: and r13, rbx + _0xffffff8000ec39c3: sar r12, 0x20 + _0xffffff8000ec39c7: movabs rbx, 0x6a7bbbdec5ae16aa + _0xffffff8000ec39d1: xor rbx, r12 + _0xffffff8000ec39d4: add rbx, r13 + _0xffffff8000ec39d7: add rbx, qword ptr [r14 + 0x1d8] + _0xffffff8000ec39de: movabs r12, 0x958444213a51e956 + _0xffffff8000ec39e8: mov dil, byte ptr [r12 + rbx] + _0xffffff8000ec39ec: mov r11b, dil + _0xffffff8000ec39ef: add r11b, r11b + _0xffffff8000ec39f2: xor dil, 0x4b + _0xffffff8000ec39f6: and r11b, 0x96 + _0xffffff8000ec39fa: add r11b, dil + _0xffffff8000ec39fd: add r11b, 0xfd + _0xffffff8000ec3a01: movzx ebx, r11b + _0xffffff8000ec3a05: cmp bl, 0x48 + _0xffffff8000ec3a08: sbb r12, r12 + _0xffffff8000ec3a0b: and r12, 0x100 + _0xffffff8000ec3a12: add r12, rbx + _0xffffff8000ec3a15: lea rbx, [r12 - 0x48] + _0xffffff8000ec3a1a: mov edi, 1 + _0xffffff8000ec3a1f: sub edi, ebx + _0xffffff8000ec3a21: mov r11d, edi + _0xffffff8000ec3a24: and r11d, ebx + _0xffffff8000ec3a27: xor edi, ebx + _0xffffff8000ec3a29: lea ecx, [rdi + r11*2] + _0xffffff8000ec3a2d: mov r13, rbx + _0xffffff8000ec3a30: xor r13, 0x1a15396c + _0xffffff8000ec3a37: movabs rdi, 0xffffffff2f30012a + _0xffffff8000ec3a41: and rdi, r13 + _0xffffff8000ec3a44: and r13d, 0xd0cffed5 + _0xffffff8000ec3a4b: add r13, rdi + _0xffffff8000ec3a4e: xor r13, 0x1a15396c + _0xffffff8000ec3a55: shl r13, cl + _0xffffff8000ec3a58: movabs rdi, 0x3f7bf71f7d597df2 + _0xffffff8000ec3a62: and rdi, r13 + _0xffffff8000ec3a65: movabs r13, 0x3ffffffffffffffe + _0xffffff8000ec3a6f: xor rbx, r13 + _0xffffff8000ec3a72: lea r12, [r12 + r12 - 0x90] + _0xffffff8000ec3a7a: movabs r13, 0x8408e082a6820e + _0xffffff8000ec3a84: and r13, r12 + _0xffffff8000ec3a87: xor r13, 2 + _0xffffff8000ec3a8b: add r13, rbx + _0xffffff8000ec3a8e: add r13, rdi + _0xffffff8000ec3a91: mov edi, 0x9308dc61 + _0xffffff8000ec3a96: mov r11d, dword ptr [rsi + r13*4] + _0xffffff8000ec3a9a: xor r11d, edi + _0xffffff8000ec3a9d: mov ebx, r15d + _0xffffff8000ec3aa0: shr ebx, 7 + _0xffffff8000ec3aa3: and ebx, 0x7c + _0xffffff8000ec3aa6: mov r12d, r15d + _0xffffff8000ec3aa9: shr r12d, 8 + _0xffffff8000ec3aad: add r12d, 0xbe + _0xffffff8000ec3ab4: sub r12d, ebx + _0xffffff8000ec3ab7: xor r12d, 0xbe + _0xffffff8000ec3abe: mov bl, r12b + _0xffffff8000ec3ac1: add bl, bl + _0xffffff8000ec3ac3: xor r12b, 0x51 + _0xffffff8000ec3ac7: and bl, 0xa2 + _0xffffff8000ec3aca: add bl, r12b + _0xffffff8000ec3acd: movzx ebx, bl + _0xffffff8000ec3ad0: cmp bl, 0x51 + _0xffffff8000ec3ad3: sbb r12, r12 + _0xffffff8000ec3ad6: and r12d, 0x100 + _0xffffff8000ec3add: add r12d, ebx + _0xffffff8000ec3ae0: add r12d, 0x26302729 + _0xffffff8000ec3ae7: shl r12, 0x20 + _0xffffff8000ec3aeb: movabs rbx, 0xd9cfd88600000000 + _0xffffff8000ec3af5: add rbx, r12 + _0xffffff8000ec3af8: mov r12, rbx + _0xffffff8000ec3afb: sar r12, 0x1f + _0xffffff8000ec3aff: movabs r13, 0xdd7feebb7f39ff7a + _0xffffff8000ec3b09: and r13, r12 + _0xffffff8000ec3b0c: sar rbx, 0x20 + _0xffffff8000ec3b10: movabs r12, 0x6ebff75dbf9cffbd + _0xffffff8000ec3b1a: xor r12, rbx + _0xffffff8000ec3b1d: add r12, r13 + _0xffffff8000ec3b20: add r12, qword ptr [r14 + 0x1f8] + _0xffffff8000ec3b27: movabs rbx, 0x914008a240630043 + _0xffffff8000ec3b31: mov bl, byte ptr [rbx + r12] + _0xffffff8000ec3b35: mov byte ptr [rbp - 0x3e1], bl + _0xffffff8000ec3b3b: mov ebx, r8d + _0xffffff8000ec3b3e: shr ebx, 0xf + _0xffffff8000ec3b41: and ebx, 0x22 + _0xffffff8000ec3b44: mov r12d, r8d + _0xffffff8000ec3b47: shr r12d, 0x10 + _0xffffff8000ec3b4b: add r12d, 0x11 + _0xffffff8000ec3b4f: sub r12d, ebx + _0xffffff8000ec3b52: xor r12d, 0x11 + _0xffffff8000ec3b56: mov bl, r12b + _0xffffff8000ec3b59: add bl, bl + _0xffffff8000ec3b5b: xor r12b, 0x5c + _0xffffff8000ec3b5f: and bl, 0xb8 + _0xffffff8000ec3b62: add bl, r12b + _0xffffff8000ec3b65: add bl, 0xf8 + _0xffffff8000ec3b68: movzx ebx, bl + _0xffffff8000ec3b6b: cmp bl, 0x54 + _0xffffff8000ec3b6e: sbb r12, r12 + _0xffffff8000ec3b71: and r12d, 0x100 + _0xffffff8000ec3b78: add r12d, ebx + _0xffffff8000ec3b7b: add r12d, 0x45a4b3e9 + _0xffffff8000ec3b82: shl r12, 0x20 + _0xffffff8000ec3b86: movabs rbx, 0xba5b4bc300000000 + _0xffffff8000ec3b90: add rbx, r12 + _0xffffff8000ec3b93: mov r12, rbx + _0xffffff8000ec3b96: sar r12, 0x1f + _0xffffff8000ec3b9a: movabs r13, 0x6bffbfbcc7fee9fc + _0xffffff8000ec3ba4: and r13, r12 + _0xffffff8000ec3ba7: sar rbx, 0x20 + _0xffffff8000ec3bab: movabs r12, 0x35ffdfde63ff74fe + _0xffffff8000ec3bb5: xor r12, rbx + _0xffffff8000ec3bb8: add r12, r13 + _0xffffff8000ec3bbb: add r12, qword ptr [r14 + 0x1a0] + _0xffffff8000ec3bc2: movabs rbx, 0xca0020219c008b02 + _0xffffff8000ec3bcc: mov bl, byte ptr [rbx + r12] + _0xffffff8000ec3bd0: mov byte ptr [rbp - 0x3c4], bl + _0xffffff8000ec3bd6: mov ebx, edx + _0xffffff8000ec3bd8: shr ebx, 0x17 + _0xffffff8000ec3bdb: and ebx, 0xc2 + _0xffffff8000ec3be1: mov r12d, edx + _0xffffff8000ec3be4: shr r12d, 0x18 + _0xffffff8000ec3be8: add r12d, 0xe1 + _0xffffff8000ec3bef: sub r12d, ebx + _0xffffff8000ec3bf2: xor r12d, 0xe1 + _0xffffff8000ec3bf9: mov bl, r12b + _0xffffff8000ec3bfc: add bl, bl + _0xffffff8000ec3bfe: xor r12b, 0x47 + _0xffffff8000ec3c02: and bl, 0x8e + _0xffffff8000ec3c05: add bl, r12b + _0xffffff8000ec3c08: add bl, 0xfc + _0xffffff8000ec3c0b: movzx ebx, bl + _0xffffff8000ec3c0e: cmp bl, 0x43 + _0xffffff8000ec3c11: sbb r12, r12 + _0xffffff8000ec3c14: and r12d, 0x100 + _0xffffff8000ec3c1b: add r12d, ebx + _0xffffff8000ec3c1e: add r12d, 0x3f4ed61a + _0xffffff8000ec3c25: shl r12, 0x20 + _0xffffff8000ec3c29: movabs rbx, 0xc0b129a300000000 + _0xffffff8000ec3c33: add rbx, r12 + _0xffffff8000ec3c36: mov r12, rbx + _0xffffff8000ec3c39: sar r12, 0x1f + _0xffffff8000ec3c3d: movabs r13, 0x76ffc7fffddd7e6c + _0xffffff8000ec3c47: and r13, r12 + _0xffffff8000ec3c4a: sar rbx, 0x20 + _0xffffff8000ec3c4e: movabs r12, 0x3b7fe3fffeeebf36 + _0xffffff8000ec3c58: xor r12, rbx + _0xffffff8000ec3c5b: add r12, r13 + _0xffffff8000ec3c5e: add r12, qword ptr [r14 + 0x1c8] + _0xffffff8000ec3c65: movabs rbx, 0xc4801c00011140ca + _0xffffff8000ec3c6f: mov bl, byte ptr [rbx + r12] + _0xffffff8000ec3c73: mov byte ptr [rbp - 0x3e2], bl + _0xffffff8000ec3c79: mov ebx, r8d + _0xffffff8000ec3c7c: shr ebx, 7 + _0xffffff8000ec3c7f: and ebx, 0xbe + _0xffffff8000ec3c85: mov r12d, r8d + _0xffffff8000ec3c88: shr r12d, 8 + _0xffffff8000ec3c8c: add r12d, 0xdf + _0xffffff8000ec3c93: sub r12d, ebx + _0xffffff8000ec3c96: xor r12d, 0xdf + _0xffffff8000ec3c9d: mov bl, r12b + _0xffffff8000ec3ca0: add bl, bl + _0xffffff8000ec3ca2: xor r12b, 0x77 + _0xffffff8000ec3ca6: and bl, 0xee + _0xffffff8000ec3ca9: add bl, r12b + _0xffffff8000ec3cac: add bl, 0x8c + _0xffffff8000ec3caf: movzx ebx, bl + _0xffffff8000ec3cb2: cmp bl, 3 + _0xffffff8000ec3cb5: sbb r12, r12 + _0xffffff8000ec3cb8: and r12d, 0x100 + _0xffffff8000ec3cbf: add r12d, ebx + _0xffffff8000ec3cc2: add r12d, 0x10c90865 + _0xffffff8000ec3cc9: shl r12, 0x20 + _0xffffff8000ec3ccd: movabs rbx, 0xef36f79800000000 + _0xffffff8000ec3cd7: add rbx, r12 + _0xffffff8000ec3cda: mov r12, rbx + _0xffffff8000ec3cdd: sar r12, 0x1f + _0xffffff8000ec3ce1: movabs r13, 0xdfdfb7ed2a5fed3e + _0xffffff8000ec3ceb: and r13, r12 + _0xffffff8000ec3cee: sar rbx, 0x20 + _0xffffff8000ec3cf2: movabs r12, 0x6fefdbf6952ff69f + _0xffffff8000ec3cfc: xor r12, rbx + _0xffffff8000ec3cff: add r12, r13 + _0xffffff8000ec3d02: add r12, qword ptr [r14 + 0x198] + _0xffffff8000ec3d09: movabs rbx, 0x901024096ad00961 + _0xffffff8000ec3d13: mov bl, byte ptr [rbx + r12] + _0xffffff8000ec3d17: mov byte ptr [rbp - 0x3e3], bl + _0xffffff8000ec3d1d: mov ebx, edx + _0xffffff8000ec3d1f: shr ebx, 0xf + _0xffffff8000ec3d22: and ebx, 0x1a + _0xffffff8000ec3d25: mov r12d, edx + _0xffffff8000ec3d28: shr r12d, 0x10 + _0xffffff8000ec3d2c: add r12d, 0x8d + _0xffffff8000ec3d33: sub r12d, ebx + _0xffffff8000ec3d36: xor r12d, 0x8d + _0xffffff8000ec3d3d: mov bl, r12b + _0xffffff8000ec3d40: add bl, bl + _0xffffff8000ec3d42: xor r12b, 0x71 + _0xffffff8000ec3d46: and bl, 0xe2 + _0xffffff8000ec3d49: add bl, r12b + _0xffffff8000ec3d4c: dec bl + _0xffffff8000ec3d4e: movzx ebx, bl + _0xffffff8000ec3d51: cmp bl, 0x70 + _0xffffff8000ec3d54: sbb r12, r12 + _0xffffff8000ec3d57: and r12d, 0x100 + _0xffffff8000ec3d5e: add r12d, ebx + _0xffffff8000ec3d61: add r12d, 0x1513ed5c + _0xffffff8000ec3d68: shl r12, 0x20 + _0xffffff8000ec3d6c: movabs rbx, 0xeaec123400000000 + _0xffffff8000ec3d76: add rbx, r12 + _0xffffff8000ec3d79: mov r12, rbx + _0xffffff8000ec3d7c: sar r12, 0x1f + _0xffffff8000ec3d80: movabs r13, 0xdfff63ef9fb7d26e + _0xffffff8000ec3d8a: and r13, r12 + _0xffffff8000ec3d8d: sar rbx, 0x20 + _0xffffff8000ec3d91: movabs r12, 0x6fffb1f7cfdbe937 + _0xffffff8000ec3d9b: xor r12, rbx + _0xffffff8000ec3d9e: add r12, r13 + _0xffffff8000ec3da1: add r12, qword ptr [r14 + 0x1c0] + _0xffffff8000ec3da8: movabs rbx, 0x90004e08302416c9 + _0xffffff8000ec3db2: mov bl, byte ptr [rbx + r12] + _0xffffff8000ec3db6: mov byte ptr [rbp - 0x3e5], bl + _0xffffff8000ec3dbc: mov ebx, r9d + _0xffffff8000ec3dbf: shr ebx, 0x17 + _0xffffff8000ec3dc2: and ebx, 0x44 + _0xffffff8000ec3dc5: mov r12d, r9d + _0xffffff8000ec3dc8: shr r12d, 0x18 + _0xffffff8000ec3dcc: add r12d, 0xa2 + _0xffffff8000ec3dd3: sub r12d, ebx + _0xffffff8000ec3dd6: xor r12d, 0xa2 + _0xffffff8000ec3ddd: mov bl, r12b + _0xffffff8000ec3de0: add bl, bl + _0xffffff8000ec3de2: xor r12b, 0x1b + _0xffffff8000ec3de6: and bl, 0x36 + _0xffffff8000ec3de9: add bl, r12b + _0xffffff8000ec3dec: add bl, 0xe8 + _0xffffff8000ec3def: movzx ebx, bl + _0xffffff8000ec3df2: cmp bl, 3 + _0xffffff8000ec3df5: sbb r12, r12 + _0xffffff8000ec3df8: and r12d, 0x100 + _0xffffff8000ec3dff: add r12d, ebx + _0xffffff8000ec3e02: add r12d, 0x69aff90e + _0xffffff8000ec3e09: shl r12, 0x20 + _0xffffff8000ec3e0d: movabs rbx, 0x965006ef00000000 + _0xffffff8000ec3e17: add rbx, r12 + _0xffffff8000ec3e1a: mov r12, rbx + _0xffffff8000ec3e1d: sar r12, 0x1f + _0xffffff8000ec3e21: movabs r13, 0xf4bac7df26ffacfe + _0xffffff8000ec3e2b: and r13, r12 + _0xffffff8000ec3e2e: sar rbx, 0x20 + _0xffffff8000ec3e32: movabs r12, 0x7a5d63ef937fd67f + _0xffffff8000ec3e3c: xor r12, rbx + _0xffffff8000ec3e3f: add r12, r13 + _0xffffff8000ec3e42: add r12, qword ptr [r14 + 0x1e8] + _0xffffff8000ec3e49: movabs rbx, 0x85a29c106c802981 + _0xffffff8000ec3e53: mov bl, byte ptr [rbx + r12] + _0xffffff8000ec3e57: mov r12d, r8d + _0xffffff8000ec3e5a: shr r12d, 0x17 + _0xffffff8000ec3e5e: and r12d, 0x78 + _0xffffff8000ec3e62: shr r8d, 0x18 + _0xffffff8000ec3e66: add r8d, 0x3c + _0xffffff8000ec3e6a: sub r8d, r12d + _0xffffff8000ec3e6d: xor r8d, 0x3c + _0xffffff8000ec3e71: mov r12b, r8b + _0xffffff8000ec3e74: add r12b, r12b + _0xffffff8000ec3e77: xor r8b, 0x53 + _0xffffff8000ec3e7b: and r12b, 0xa6 + _0xffffff8000ec3e7f: add r12b, r8b + _0xffffff8000ec3e82: dec r12b + _0xffffff8000ec3e85: movzx r8d, r12b + _0xffffff8000ec3e89: cmp r8b, 0x52 + _0xffffff8000ec3e8d: sbb r12, r12 + _0xffffff8000ec3e90: and r12d, 0x100 + _0xffffff8000ec3e97: add r12d, r8d + _0xffffff8000ec3e9a: add r12d, 0x18752ca8 + _0xffffff8000ec3ea1: shl r12, 0x20 + _0xffffff8000ec3ea5: movabs r13, 0xe78ad30600000000 + _0xffffff8000ec3eaf: add r13, r12 + _0xffffff8000ec3eb2: mov r12, r13 + _0xffffff8000ec3eb5: sar r12, 0x1f + _0xffffff8000ec3eb9: movabs r8, 0x54fdfefcbdeb55fe + _0xffffff8000ec3ec3: and r8, r12 + _0xffffff8000ec3ec6: sar r13, 0x20 + _0xffffff8000ec3eca: movabs r12, 0x2a7eff7e5ef5aaff + _0xffffff8000ec3ed4: xor r12, r13 + _0xffffff8000ec3ed7: add r12, r8 + _0xffffff8000ec3eda: add r12, qword ptr [r14 + 0x1a8] + _0xffffff8000ec3ee1: movabs r13, 0xd5810081a10a5501 + _0xffffff8000ec3eeb: mov r8b, byte ptr [r13 + r12] + _0xffffff8000ec3ef0: mov r12d, r15d + _0xffffff8000ec3ef3: shr r12d, 0xf + _0xffffff8000ec3ef7: and r12d, 2 + _0xffffff8000ec3efb: mov r13d, r15d + _0xffffff8000ec3efe: shr r13d, 0x10 + _0xffffff8000ec3f02: add r13d, 0x81 + _0xffffff8000ec3f09: sub r13d, r12d + _0xffffff8000ec3f0c: xor r13d, 0x81 + _0xffffff8000ec3f13: mov r12b, r13b + _0xffffff8000ec3f16: add r12b, r12b + _0xffffff8000ec3f19: xor r13b, 0x77 + _0xffffff8000ec3f1d: and r12b, 0xee + _0xffffff8000ec3f21: add r12b, r13b + _0xffffff8000ec3f24: add r12b, 0xf0 + _0xffffff8000ec3f28: movzx r12d, r12b + _0xffffff8000ec3f2c: cmp r12b, 0x67 + _0xffffff8000ec3f30: sbb r13, r13 + _0xffffff8000ec3f33: and r13d, 0x100 + _0xffffff8000ec3f3a: add r13d, r12d + _0xffffff8000ec3f3d: add r13d, 0x2d0114ca + _0xffffff8000ec3f44: shl r13, 0x20 + _0xffffff8000ec3f48: movabs r12, 0xd2feeacf00000000 + _0xffffff8000ec3f52: add r12, r13 + _0xffffff8000ec3f55: mov r13, r12 + _0xffffff8000ec3f58: sar r13, 0x1f + _0xffffff8000ec3f5c: movabs rax, 0xfeefbffefffddf9c + _0xffffff8000ec3f66: and rax, r13 + _0xffffff8000ec3f69: sar r12, 0x20 + _0xffffff8000ec3f6d: movabs r13, 0x7f77dfff7ffeefce + _0xffffff8000ec3f77: xor r13, r12 + _0xffffff8000ec3f7a: add r13, rax + _0xffffff8000ec3f7d: add r13, qword ptr [r14 + 0x200] + _0xffffff8000ec3f84: movabs r12, 0x8088200080011032 + _0xffffff8000ec3f8e: mov al, byte ptr [r12 + r13] + _0xffffff8000ec3f92: mov r12b, r9b + _0xffffff8000ec3f95: add r12b, r12b + _0xffffff8000ec3f98: xor r9b, 0x6f + _0xffffff8000ec3f9c: and r12b, 0xde + _0xffffff8000ec3fa0: add r12b, r9b + _0xffffff8000ec3fa3: add r12b, 0xf9 + _0xffffff8000ec3fa7: movzx r9d, r12b + _0xffffff8000ec3fab: cmp r9b, 0x68 + _0xffffff8000ec3faf: sbb r12, r12 + _0xffffff8000ec3fb2: and r12d, 0x100 + _0xffffff8000ec3fb9: add r12d, r9d + _0xffffff8000ec3fbc: add r12d, 0x74c22b04 + _0xffffff8000ec3fc3: shl r12, 0x20 + _0xffffff8000ec3fc7: movabs r13, 0x8b3dd49400000000 + _0xffffff8000ec3fd1: add r13, r12 + _0xffffff8000ec3fd4: mov r12, r13 + _0xffffff8000ec3fd7: sar r12, 0x1f + _0xffffff8000ec3fdb: movabs r9, 0xffd6dfbfdfffafbe + _0xffffff8000ec3fe5: and r9, r12 + _0xffffff8000ec3fe8: sar r13, 0x20 + _0xffffff8000ec3fec: movabs r12, 0x7feb6fdfefffd7df + _0xffffff8000ec3ff6: xor r12, r13 + _0xffffff8000ec3ff9: add r12, r9 + _0xffffff8000ec3ffc: add r12, qword ptr [r14 + 0x1d0] + _0xffffff8000ec4003: movabs r13, 0x8014902010002821 + _0xffffff8000ec400d: mov r9b, byte ptr [r13 + r12] + _0xffffff8000ec4012: mov byte ptr [rbp - 0x3e4], r9b + _0xffffff8000ec4019: mov r9b, r15b + _0xffffff8000ec401c: add r9b, r9b + _0xffffff8000ec401f: xor r15b, 0x7c + _0xffffff8000ec4023: and r9b, 0xf8 + _0xffffff8000ec4027: add r9b, r15b + _0xffffff8000ec402a: add r9b, 0xdc + _0xffffff8000ec402e: movzx r9d, r9b + _0xffffff8000ec4032: cmp r9b, 0x58 + _0xffffff8000ec4036: sbb r15, r15 + _0xffffff8000ec4039: and r15d, 0x100 + _0xffffff8000ec4040: add r15d, r9d + _0xffffff8000ec4043: add r15d, 0x33c8b6e7 + _0xffffff8000ec404a: shl r15, 0x20 + _0xffffff8000ec404e: movabs r12, 0xcc3748c100000000 + _0xffffff8000ec4058: add r12, r15 + _0xffffff8000ec405b: mov r15, r12 + _0xffffff8000ec405e: sar r15, 0x1f + _0xffffff8000ec4062: movabs r13, 0xfff262ffebffcf6a + _0xffffff8000ec406c: and r13, r15 + _0xffffff8000ec406f: sar r12, 0x20 + _0xffffff8000ec4073: movabs r15, 0x7ff9317ff5ffe7b5 + _0xffffff8000ec407d: xor r15, r12 + _0xffffff8000ec4080: add r15, r13 + _0xffffff8000ec4083: add r15, qword ptr [r14 + 0x1f0] + _0xffffff8000ec408a: movabs r12, 0x8006ce800a00184b + _0xffffff8000ec4094: mov r9b, byte ptr [r12 + r15] + _0xffffff8000ec4098: mov r15b, dl + _0xffffff8000ec409b: add r15b, r15b + _0xffffff8000ec409e: xor dl, 0x1b + _0xffffff8000ec40a1: and r15b, 0x36 + _0xffffff8000ec40a5: add r15b, dl + _0xffffff8000ec40a8: add r15b, 0xfe + _0xffffff8000ec40ac: movzx edx, r15b + _0xffffff8000ec40b0: cmp dl, 0x19 + _0xffffff8000ec40b3: sbb r15, r15 + _0xffffff8000ec40b6: and r15d, 0x100 + _0xffffff8000ec40bd: add r15d, edx + _0xffffff8000ec40c0: add r15d, 0x1fb064ff + _0xffffff8000ec40c7: shl r15, 0x20 + _0xffffff8000ec40cb: movabs r12, 0xe04f9ae800000000 + _0xffffff8000ec40d5: add r12, r15 + _0xffffff8000ec40d8: mov r15, r12 + _0xffffff8000ec40db: sar r15, 0x1f + _0xffffff8000ec40df: movabs r13, 0x1fdfedf935d7e33c + _0xffffff8000ec40e9: and r13, r15 + _0xffffff8000ec40ec: sar r12, 0x20 + _0xffffff8000ec40f0: movabs r15, 0xfeff6fc9aebf19e + _0xffffff8000ec40fa: xor r15, r12 + _0xffffff8000ec40fd: add r15, r13 + _0xffffff8000ec4100: add r15, qword ptr [r14 + 0x1b0] + _0xffffff8000ec4107: movabs r12, 0xf010090365140e62 + _0xffffff8000ec4111: mov r15b, byte ptr [r12 + r15] + _0xffffff8000ec4115: mov r12, qword ptr [rbp - 0x3e0] + _0xffffff8000ec411c: mov cl, byte ptr [rbp - 0x3f1] + _0xffffff8000ec4122: mov byte ptr [r12], cl + _0xffffff8000ec4126: mov cl, al + _0xffffff8000ec4128: add cl, cl + _0xffffff8000ec412a: xor al, 0x6f + _0xffffff8000ec412c: and cl, 0xde + _0xffffff8000ec412f: add cl, al + _0xffffff8000ec4131: add cl, 0xbf + _0xffffff8000ec4134: movzx r13d, cl + _0xffffff8000ec4138: mov eax, 0x76813b8c + _0xffffff8000ec413d: sub rax, r13 + _0xffffff8000ec4140: mov rcx, rax + _0xffffff8000ec4143: and rcx, r13 + _0xffffff8000ec4146: xor rax, r13 + _0xffffff8000ec4149: lea rax, [rax + rcx*2] + _0xffffff8000ec414d: mov rcx, rax + _0xffffff8000ec4150: and rcx, r13 + _0xffffff8000ec4153: xor rax, r13 + _0xffffff8000ec4156: lea rax, [rax + rcx*2] + _0xffffff8000ec415a: cmp r13b, 0x2e + _0xffffff8000ec415e: sbb r13, r13 + _0xffffff8000ec4161: and r13, 0x100 + _0xffffff8000ec4168: mov rcx, rax + _0xffffff8000ec416b: and rcx, r13 + _0xffffff8000ec416e: xor r13, rax + _0xffffff8000ec4171: lea rax, [r13 + rcx*2] + _0xffffff8000ec4176: mov r13d, eax + _0xffffff8000ec4179: movabs rcx, 0xfc0fc0fc0fc0fc1 + _0xffffff8000ec4183: mul rcx + _0xffffff8000ec4186: shr rdx, 2 + _0xffffff8000ec418a: imul eax, edx, 0x41 + _0xffffff8000ec418d: sub r13d, eax + _0xffffff8000ec4190: shl r13, 0x20 + _0xffffff8000ec4194: movabs rax, 0x4100000000 + _0xffffff8000ec419e: imul rax, rdx + _0xffffff8000ec41a2: add rax, r13 + _0xffffff8000ec41a5: movabs r13, 0x97ec44600000000 + _0xffffff8000ec41af: and r13, rax + _0xffffff8000ec41b2: movabs rcx, 0x897ec44600000000 + _0xffffff8000ec41bc: xor rcx, rax + _0xffffff8000ec41bf: lea r13, [rcx + r13*2] + _0xffffff8000ec41c3: mov rax, r13 + _0xffffff8000ec41c6: sar rax, 0x1f + _0xffffff8000ec41ca: xor rax, 0x1adc6ed9 + _0xffffff8000ec41d0: mov rcx, rax + _0xffffff8000ec41d3: sar rcx, 0x17 + _0xffffff8000ec41d7: movabs rdx, 0x39f7ffb5ffc + _0xffffff8000ec41e1: mov r12, rcx + _0xffffff8000ec41e4: xor r12, rdx + _0xffffff8000ec41e7: add rcx, rdx + _0xffffff8000ec41ea: sub rcx, r12 + _0xffffff8000ec41ed: shl rcx, 0x16 + _0xffffff8000ec41f1: and rax, 0x6cfdf6 + _0xffffff8000ec41f7: add rax, rcx + _0xffffff8000ec41fa: movabs r12, 0x982001281a85edd5 + _0xffffff8000ec4204: xor r12, rax + _0xffffff8000ec4207: add rax, rax + _0xffffff8000ec420a: movabs rcx, 0x3040025001910208 + _0xffffff8000ec4214: and rcx, rax + _0xffffff8000ec4217: xor rcx, 0x900000 + _0xffffff8000ec421e: add rcx, r12 + _0xffffff8000ec4221: sar r13, 0x20 + _0xffffff8000ec4225: movabs r12, 0x67dffed7ff367efb + _0xffffff8000ec422f: xor r12, r13 + _0xffffff8000ec4232: mov r13, rcx + _0xffffff8000ec4235: and r13, r12 + _0xffffff8000ec4238: xor r12, rcx + _0xffffff8000ec423b: lea r12, [r12 + r13*2] + _0xffffff8000ec423f: mov eax, 0xfb62aa87 + _0xffffff8000ec4244: xor eax, dword ptr [r10 + r12*4] + _0xffffff8000ec4248: mov cl, r15b + _0xffffff8000ec424b: add cl, cl + _0xffffff8000ec424d: xor r15b, 0x17 + _0xffffff8000ec4251: and cl, 0x2e + _0xffffff8000ec4254: add cl, r15b + _0xffffff8000ec4257: add cl, 0xf0 + _0xffffff8000ec425a: movzx ecx, cl + _0xffffff8000ec425d: mov edx, ecx + _0xffffff8000ec425f: xor edx, 0x11bad059 + _0xffffff8000ec4265: mov r15b, cl + _0xffffff8000ec4268: add r15b, cl + _0xffffff8000ec426b: movzx r15d, r15b + _0xffffff8000ec426f: and r15d, 0xb2 + _0xffffff8000ec4276: add r15d, edx + _0xffffff8000ec4279: cmp cl, 7 + _0xffffff8000ec427c: sbb r12, r12 + _0xffffff8000ec427f: and r12d, 0x100 + _0xffffff8000ec4286: mov ecx, r15d + _0xffffff8000ec4289: and ecx, r12d + _0xffffff8000ec428c: mov edx, r15d + _0xffffff8000ec428f: and edx, 0xa42a48a4 + _0xffffff8000ec4295: add ecx, ecx + _0xffffff8000ec4297: add ecx, edx + _0xffffff8000ec4299: mov edx, r15d + _0xffffff8000ec429c: and edx, 0x12851509 + _0xffffff8000ec42a2: add edx, 0x50a2a12 + _0xffffff8000ec42a8: sub edx, r12d + _0xffffff8000ec42ab: and edx, 0x12851509 + _0xffffff8000ec42b1: add edx, ecx + _0xffffff8000ec42b3: and r15d, 0x4950a252 + _0xffffff8000ec42ba: add r15d, edx + _0xffffff8000ec42bd: shl r15, 0x20 + _0xffffff8000ec42c1: movabs r12, 0x28b841400000000 + _0xffffff8000ec42cb: and r12, r15 + _0xffffff8000ec42ce: mov r13d, 0xfe40b45c + _0xffffff8000ec42d4: add r12, r13 + _0xffffff8000ec42d7: movabs rcx, 0x84008840a004 + _0xffffff8000ec42e1: and rcx, r12 + _0xffffff8000ec42e4: movabs rdx, 0xfd747beb00000000 + _0xffffff8000ec42ee: and rdx, r15 + _0xffffff8000ec42f1: add rdx, r13 + _0xffffff8000ec42f4: movabs r15, 0x252401488840a004 + _0xffffff8000ec42fe: and r15, rdx + _0xffffff8000ec4301: add r15, rcx + _0xffffff8000ec4304: movabs r13, 0x252485488944a924 + _0xffffff8000ec430e: and r13, r15 + _0xffffff8000ec4311: movabs r15, 0x2001052000050 + _0xffffff8000ec431b: and r15, r12 + _0xffffff8000ec431e: movabs rcx, 0x4850288252000050 + _0xffffff8000ec4328: and rcx, rdx + _0xffffff8000ec432b: movabs rsi, 0x10a45124a45284a4 + _0xffffff8000ec4335: add rsi, rcx + _0xffffff8000ec4338: sub rsi, r15 + _0xffffff8000ec433b: movabs r15, 0x4852289252294250 + _0xffffff8000ec4345: and r15, rsi + _0xffffff8000ec4348: add r15, r13 + _0xffffff8000ec434b: movabs r13, 0x289000424001408 + _0xffffff8000ec4355: and r13, r12 + _0xffffff8000ec4358: movabs r12, 0x9000522124001408 + _0xffffff8000ec4362: and r12, rdx + _0xffffff8000ec4365: movabs rcx, 0x2512a44a49242912 + _0xffffff8000ec436f: add rcx, r12 + _0xffffff8000ec4372: sub rcx, r13 + _0xffffff8000ec4375: movabs r12, 0x9289522524921488 + _0xffffff8000ec437f: and r12, rcx + _0xffffff8000ec4382: add r12, r15 + _0xffffff8000ec4385: movabs r15, 0x6e452fa000000000 + _0xffffff8000ec438f: and r15, r12 + _0xffffff8000ec4392: movabs r13, 0xee452fa000000000 + _0xffffff8000ec439c: xor r13, r12 + _0xffffff8000ec439f: lea r15, [r13 + r15*2] + _0xffffff8000ec43a4: mov r12, r15 + _0xffffff8000ec43a7: sar r12, 0x20 + _0xffffff8000ec43ab: xor r12, 0x5ed239fe + _0xffffff8000ec43b2: movabs r13, 0x737b7fddc529e217 + _0xffffff8000ec43bc: add r13, r12 + _0xffffff8000ec43bf: add r12, r12 + _0xffffff8000ec43c2: movabs rcx, 0x26f6ffbb8a53c42e + _0xffffff8000ec43cc: and rcx, r12 + _0xffffff8000ec43cf: sub r13, rcx + _0xffffff8000ec43d2: sar r15, 0x1f + _0xffffff8000ec43d6: movabs r12, 0xe6f6ffbb37f7b7d2 + _0xffffff8000ec43e0: and r12, r15 + _0xffffff8000ec43e3: add r12, 0x4e539d41 + _0xffffff8000ec43ea: movabs r15, 0x288925250a291254 + _0xffffff8000ec43f4: and r15, r12 + _0xffffff8000ec43f7: lea rcx, [r12 + r12] + _0xffffff8000ec43fb: movabs rdx, 0x11000040004004a8 + _0xffffff8000ec4405: and rdx, rcx + _0xffffff8000ec4408: add rdx, r15 + _0xffffff8000ec440b: movabs r15, 0x880002000200254 + _0xffffff8000ec4415: xor r15, rdx + _0xffffff8000ec4418: add r15, r13 + _0xffffff8000ec441b: movabs r13, 0x8000409010900 + _0xffffff8000ec4425: and r13, rcx + _0xffffff8000ec4428: movabs rdx, 0x2544892a484a489 + _0xffffff8000ec4432: mov rsi, r12 + _0xffffff8000ec4435: and rsi, rdx + _0xffffff8000ec4438: movabs r10, 0x84ac91274d89cd92 + _0xffffff8000ec4442: sub r10, rsi + _0xffffff8000ec4445: and r10, rdx + _0xffffff8000ec4448: add r10, r13 + _0xffffff8000ec444b: add r10, r15 + _0xffffff8000ec444e: movabs r15, 0x1522924851524922 + _0xffffff8000ec4458: and r15, r12 + _0xffffff8000ec445b: movabs r12, 0x801000022200004 + _0xffffff8000ec4465: and r12, rcx + _0xffffff8000ec4468: add r12, r15 + _0xffffff8000ec446b: movabs r15, 0x400800011100002 + _0xffffff8000ec4475: xor r15, r12 + _0xffffff8000ec4478: add r15, r10 + _0xffffff8000ec447b: mov r12, qword ptr [rbp - 0x3c0] + _0xffffff8000ec4482: xor edi, dword ptr [r12 + r15*4] + _0xffffff8000ec4486: mov ecx, edi + _0xffffff8000ec4488: xor ecx, r11d + _0xffffff8000ec448b: and ecx, 0x9144948a + _0xffffff8000ec4491: mov edx, edi + _0xffffff8000ec4493: and edx, 0x4a294254 + _0xffffff8000ec4499: mov esi, r11d + _0xffffff8000ec449c: and esi, 0x4a294254 + _0xffffff8000ec44a2: add esi, 0x145284a8 + _0xffffff8000ec44a8: sub esi, edx + _0xffffff8000ec44aa: and esi, 0x4a294254 + _0xffffff8000ec44b0: add esi, ecx + _0xffffff8000ec44b2: and edi, 0x24922921 + _0xffffff8000ec44b8: and r11d, 0x24922921 + _0xffffff8000ec44bf: add r11d, edi + _0xffffff8000ec44c2: and r11d, 0x24922921 + _0xffffff8000ec44c9: add r11d, esi + _0xffffff8000ec44cc: xor r11d, 0xfb62aa87 + _0xffffff8000ec44d3: lea ecx, [rax + r11] + _0xffffff8000ec44d7: and r11d, eax + _0xffffff8000ec44da: add r11d, r11d + _0xffffff8000ec44dd: sub ecx, r11d + _0xffffff8000ec44e0: xor ecx, 0x45f9c7a2 + _0xffffff8000ec44e6: mov eax, ecx + _0xffffff8000ec44e8: and eax, 0x54921289 + _0xffffff8000ec44ed: add eax, 0x29242512 + _0xffffff8000ec44f2: mov dl, r8b + _0xffffff8000ec44f5: add dl, dl + _0xffffff8000ec44f7: xor r8b, 0x70 + _0xffffff8000ec44fb: and dl, 0xe0 + _0xffffff8000ec44fe: add dl, r8b + _0xffffff8000ec4501: add dl, 0xc0 + _0xffffff8000ec4504: movzx r15d, dl + _0xffffff8000ec4508: mov r13, r15 + _0xffffff8000ec450b: and r13, 0xaf + _0xffffff8000ec4512: mov rdx, r15 + _0xffffff8000ec4515: xor rdx, 0x44a5d0af + _0xffffff8000ec451c: lea r13, [rdx + r13*2] + _0xffffff8000ec4520: movabs rdx, 0x29212a1514924454 + _0xffffff8000ec452a: and rdx, r13 + _0xffffff8000ec452d: movabs rsi, 0x44949142a145290a + _0xffffff8000ec4537: and rsi, r13 + _0xffffff8000ec453a: cmp r15b, 0x30 + _0xffffff8000ec453e: sbb r15, r15 + _0xffffff8000ec4541: and r15, 0x100 + _0xffffff8000ec4548: xor rsi, r15 + _0xffffff8000ec454b: add rsi, rdx + _0xffffff8000ec454e: and r15, r13 + _0xffffff8000ec4551: add r15, r15 + _0xffffff8000ec4554: add r15, rsi + _0xffffff8000ec4557: movabs rdx, 0x924a44a84a2892a1 + _0xffffff8000ec4561: and r13, rdx + _0xffffff8000ec4564: movabs rsi, 0x2494895094512542 + _0xffffff8000ec456e: sub rsi, r13 + _0xffffff8000ec4571: and rsi, rdx + _0xffffff8000ec4574: add rsi, r15 + _0xffffff8000ec4577: mov r15, rsi + _0xffffff8000ec457a: shl r15, 0x20 + _0xffffff8000ec457e: movabs r13, 0x4894854500000000 + _0xffffff8000ec4588: and r13, r15 + _0xffffff8000ec458b: movabs rdx, 0xa2492a2800000000 + _0xffffff8000ec4595: and rdx, r15 + _0xffffff8000ec4598: add rdx, r13 + _0xffffff8000ec459b: shl rsi, 0x21 + _0xffffff8000ec459f: movabs r13, 0x2204000000000000 + _0xffffff8000ec45a9: and r13, rsi + _0xffffff8000ec45ac: movabs rsi, 0x1522509200000000 + _0xffffff8000ec45b6: and r15, rsi + _0xffffff8000ec45b9: movabs rdi, 0xa44a12515229224 + _0xffffff8000ec45c3: add rdi, r15 + _0xffffff8000ec45c6: movabs r15, 0xefe000000000000 + _0xffffff8000ec45d0: add r15, rdi + _0xffffff8000ec45d3: and r15, rsi + _0xffffff8000ec45d6: add r15, r13 + _0xffffff8000ec45d9: add r15, rdx + _0xffffff8000ec45dc: movabs r13, 0xaa582f2100000000 + _0xffffff8000ec45e6: add r13, r15 + _0xffffff8000ec45e9: mov r15, r13 + _0xffffff8000ec45ec: sar r15, 0x1e + _0xffffff8000ec45f0: movabs rdx, 0x224a28041044210 + _0xffffff8000ec45fa: and rdx, r15 + _0xffffff8000ec45fd: mov r15, r13 + _0xffffff8000ec4600: sar r15, 0x1f + _0xffffff8000ec4604: movabs rsi, 0xfdda5d7fbcf3bdec + _0xffffff8000ec460e: and rsi, r15 + _0xffffff8000ec4611: movabs r15, 0x8112d1402186210a + _0xffffff8000ec461b: xor r15, rsi + _0xffffff8000ec461e: add r15, rdx + _0xffffff8000ec4621: sar r13, 0x20 + _0xffffff8000ec4625: xor r13, 0x417fe3c2 + _0xffffff8000ec462c: movabs rdx, 0x7eed2ebf9f063d34 + _0xffffff8000ec4636: add rdx, r13 + _0xffffff8000ec4639: add r13, r13 + _0xffffff8000ec463c: movabs rsi, 0xfdda5d7f3e0c7a68 + _0xffffff8000ec4646: and rsi, r13 + _0xffffff8000ec4649: sub rdx, rsi + _0xffffff8000ec464c: mov r13, rdx + _0xffffff8000ec464f: and r13, r15 + _0xffffff8000ec4652: xor rdx, r15 + _0xffffff8000ec4655: lea r15, [rdx + r13*2] + _0xffffff8000ec4659: mov esi, 0x45f9c7a2 + _0xffffff8000ec465e: mov r13, qword ptr [rbp - 0x3b8] + _0xffffff8000ec4665: xor esi, dword ptr [r13 + r15*4] + _0xffffff8000ec466a: mov edx, esi + _0xffffff8000ec466c: and edx, 0x54921289 + _0xffffff8000ec4672: sub eax, edx + _0xffffff8000ec4674: and eax, 0x54921289 + _0xffffff8000ec4679: mov edi, ecx + _0xffffff8000ec467b: and edi, 0x89254854 + _0xffffff8000ec4681: add edi, 0x124a90a8 + _0xffffff8000ec4687: mov edx, esi + _0xffffff8000ec4689: and edx, 0x89254854 + _0xffffff8000ec468f: sub edi, edx + _0xffffff8000ec4691: and edi, 0x89254854 + _0xffffff8000ec4697: add edi, eax + _0xffffff8000ec4699: xor esi, ecx + _0xffffff8000ec469b: and esi, 0x2248a522 + _0xffffff8000ec46a1: add esi, edi + _0xffffff8000ec46a3: mov cl, sil + _0xffffff8000ec46a6: add cl, cl + _0xffffff8000ec46a8: mov al, sil + _0xffffff8000ec46ab: xor al, 0xe7 + _0xffffff8000ec46ad: and cl, 0xe0 + _0xffffff8000ec46b0: xor cl, 0x20 + _0xffffff8000ec46b3: add cl, al + _0xffffff8000ec46b5: mov al, sil + _0xffffff8000ec46b8: and al, 3 + _0xffffff8000ec46ba: mov dl, 0xd2 + _0xffffff8000ec46bc: mul dl + _0xffffff8000ec46be: mov dl, al + _0xffffff8000ec46c0: mov r8b, sil + _0xffffff8000ec46c3: sar r8b, 2 + _0xffffff8000ec46c7: mov al, 0xd2 + _0xffffff8000ec46c9: sub al, r8b + _0xffffff8000ec46cc: mul al + _0xffffff8000ec46ce: sub dl, al + _0xffffff8000ec46d0: mov al, r8b + _0xffffff8000ec46d3: add al, 0xd2 + _0xffffff8000ec46d5: mul al + _0xffffff8000ec46d7: add al, dl + _0xffffff8000ec46d9: mov dl, 0xd9 + _0xffffff8000ec46db: mul dl + _0xffffff8000ec46dd: and al, 0x2e + _0xffffff8000ec46df: mov dl, al + _0xffffff8000ec46e1: xor dl, cl + _0xffffff8000ec46e3: and cl, al + _0xffffff8000ec46e5: add cl, cl + _0xffffff8000ec46e7: add cl, dl + _0xffffff8000ec46e9: movzx eax, cl + _0xffffff8000ec46ec: cmp al, 7 + _0xffffff8000ec46ee: sbb r15, r15 + _0xffffff8000ec46f1: and r15d, 0x100 + _0xffffff8000ec46f8: add r15d, eax + _0xffffff8000ec46fb: add r15d, 0x3592a76b + _0xffffff8000ec4702: shl r15, 0x20 + _0xffffff8000ec4706: movabs rax, 0xca6d588e00000000 + _0xffffff8000ec4710: add rax, r15 + _0xffffff8000ec4713: mov r15, rax + _0xffffff8000ec4716: sar r15, 0x1f + _0xffffff8000ec471a: movabs rcx, 0x8debcd9b8be2befc + _0xffffff8000ec4724: and rcx, r15 + _0xffffff8000ec4727: sar rax, 0x20 + _0xffffff8000ec472b: movabs r15, 0x46f5e6cdc5f15f7e + _0xffffff8000ec4735: xor r15, rax + _0xffffff8000ec4738: add r15, rcx + _0xffffff8000ec473b: add r15, qword ptr [r14 + 0x430] + _0xffffff8000ec4742: movabs rax, 0xb90a19323a0ea082 + _0xffffff8000ec474c: mov al, byte ptr [rax + r15] + _0xffffff8000ec4750: mov byte ptr [rbp - 0x3f1], al + _0xffffff8000ec4756: mov cl, bl + _0xffffff8000ec4758: add cl, cl + _0xffffff8000ec475a: xor bl, 0x3b + _0xffffff8000ec475d: and cl, 0x76 + _0xffffff8000ec4760: add cl, bl + _0xffffff8000ec4762: add cl, 0xcd + _0xffffff8000ec4765: movzx ebx, cl + _0xffffff8000ec4768: mov r15d, 0xb23b7eff + _0xffffff8000ec476e: add r15, rbx + _0xffffff8000ec4771: movabs r12, 0x7fffffff4dc480f9 + _0xffffff8000ec477b: and r12, r15 + _0xffffff8000ec477e: movabs r13, 0xffffffff4dc480f9 + _0xffffff8000ec4788: xor r13, r15 + _0xffffff8000ec478b: lea r15, [r13 + r12*2] + _0xffffff8000ec4790: movabs r12, 0x48a15095114a4894 + _0xffffff8000ec479a: mov r13, r15 + _0xffffff8000ec479d: and r13, r12 + _0xffffff8000ec47a0: movabs rcx, 0x9142a12a22949128 + _0xffffff8000ec47aa: sub rcx, r13 + _0xffffff8000ec47ad: and rcx, r12 + _0xffffff8000ec47b0: cmp bl, 8 + _0xffffff8000ec47b3: sbb rbx, rbx + _0xffffff8000ec47b6: and rbx, 0x100 + _0xffffff8000ec47bd: mov r12, r15 + _0xffffff8000ec47c0: and r12, rbx + _0xffffff8000ec47c3: add r12, r12 + _0xffffff8000ec47c6: add r12, rcx + _0xffffff8000ec47c9: movabs r13, 0xa50a2a224a251521 + _0xffffff8000ec47d3: mov rcx, r15 + _0xffffff8000ec47d6: and rcx, r13 + _0xffffff8000ec47d9: movabs rdx, 0x4a145444944a2a42 + _0xffffff8000ec47e3: add rdx, rcx + _0xffffff8000ec47e6: sub rdx, rbx + _0xffffff8000ec47e9: and rdx, r13 + _0xffffff8000ec47ec: add rdx, r12 + _0xffffff8000ec47ef: movabs rbx, 0x12548548a490a24a + _0xffffff8000ec47f9: and rbx, r15 + _0xffffff8000ec47fc: add rbx, rdx + _0xffffff8000ec47ff: mov r15, rbx + _0xffffff8000ec4802: xor r15, 0x63ea8c75 + _0xffffff8000ec4809: mov r12, r15 + _0xffffff8000ec480c: shl r12, 0x19 + _0xffffff8000ec4810: movabs r13, 0x40000000000000 + _0xffffff8000ec481a: and r13, r12 + _0xffffff8000ec481d: xor r13, r15 + _0xffffff8000ec4820: mov r15, r13 + _0xffffff8000ec4823: shr r15, 4 + _0xffffff8000ec4827: movabs r12, 0x20000000000 + _0xffffff8000ec4831: and r12, r15 + _0xffffff8000ec4834: xor r12, r13 + _0xffffff8000ec4837: movabs r15, 0x77ebdedf8c14739b + _0xffffff8000ec4841: xor r15, r12 + _0xffffff8000ec4844: movabs r12, 0x200000000000 + _0xffffff8000ec484e: and r12, r15 + _0xffffff8000ec4851: mov rax, r12 + _0xffffff8000ec4854: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec485e: mul rdx + _0xffffff8000ec4861: shr rdx, 1 + _0xffffff8000ec4864: lea r13, [rdx + rdx*2] + _0xffffff8000ec4868: sub r12, r13 + _0xffffff8000ec486b: mov rax, r12 + _0xffffff8000ec486e: imul rax, rax + _0xffffff8000ec4872: mov r12d, eax + _0xffffff8000ec4875: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec487f: mul rdx + _0xffffff8000ec4882: shr edx, 1 + _0xffffff8000ec4884: lea ecx, [rdx + rdx*2] + _0xffffff8000ec4887: sub r12d, ecx + _0xffffff8000ec488a: shl r12, 0x29 + _0xffffff8000ec488e: xor r12, r15 + _0xffffff8000ec4891: mov r15, r12 + _0xffffff8000ec4894: and r15, 0x20000000 + _0xffffff8000ec489b: mov rax, r15 + _0xffffff8000ec489e: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec48a8: mul rdx + _0xffffff8000ec48ab: shr rdx, 1 + _0xffffff8000ec48ae: lea r13, [rdx + rdx*2] + _0xffffff8000ec48b2: sub r15, r13 + _0xffffff8000ec48b5: mov rax, r15 + _0xffffff8000ec48b8: imul rax, rax + _0xffffff8000ec48bc: mov r15d, eax + _0xffffff8000ec48bf: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec48c9: mul rdx + _0xffffff8000ec48cc: shr edx, 1 + _0xffffff8000ec48ce: lea ecx, [rdx + rdx*2] + _0xffffff8000ec48d1: sub r15d, ecx + _0xffffff8000ec48d4: shl r15, 0x36 + _0xffffff8000ec48d8: xor r15, r12 + _0xffffff8000ec48db: movabs r12, 0x8814212010010012 + _0xffffff8000ec48e5: sub r12, r15 + _0xffffff8000ec48e8: mov r13, r12 + _0xffffff8000ec48eb: and r13, r15 + _0xffffff8000ec48ee: xor r12, r15 + _0xffffff8000ec48f1: lea r12, [r12 + r13*2] + _0xffffff8000ec48f5: mov r13, r12 + _0xffffff8000ec48f8: and r13, r15 + _0xffffff8000ec48fb: xor r15, r12 + _0xffffff8000ec48fe: lea r15, [r15 + r13*2] + _0xffffff8000ec4902: movabs r12, 0x4895212492524a4 + _0xffffff8000ec490c: mov r13, r15 + _0xffffff8000ec490f: and r13, r12 + _0xffffff8000ec4912: movabs r8, 0x112a424924a4948 + _0xffffff8000ec491c: add r8, r13 + _0xffffff8000ec491f: mov ecx, 1 + _0xffffff8000ec4924: sub ecx, ebx + _0xffffff8000ec4926: mov r10d, ecx + _0xffffff8000ec4929: and r10d, ebx + _0xffffff8000ec492c: xor ecx, ebx + _0xffffff8000ec492e: lea ecx, [rcx + r10*2] + _0xffffff8000ec4932: and ecx, 0x3f + _0xffffff8000ec4935: mov r13d, 0xf380b51a + _0xffffff8000ec493b: xor rbx, r13 + _0xffffff8000ec493e: mov r10, rbx + _0xffffff8000ec4941: and r10, 0xffffffff9fde2fea + _0xffffff8000ec4948: shl r10, cl + _0xffffff8000ec494b: mov r11d, 0xfbeb1e71 + _0xffffff8000ec4951: xor r10, r11 + _0xffffff8000ec4954: and rbx, 0x6021d015 + _0xffffff8000ec495b: shl rbx, cl + _0xffffff8000ec495e: xor rbx, r11 + _0xffffff8000ec4961: lea r11, [rbx + r10] + _0xffffff8000ec4965: and rbx, r10 + _0xffffff8000ec4968: add rbx, rbx + _0xffffff8000ec496b: sub r11, rbx + _0xffffff8000ec496e: shl r13, cl + _0xffffff8000ec4971: xor r13, r11 + _0xffffff8000ec4974: movabs rbx, 0x4481101249252484 + _0xffffff8000ec497e: and rbx, r13 + _0xffffff8000ec4981: sub r8, rbx + _0xffffff8000ec4984: and r8, r12 + _0xffffff8000ec4987: and rbx, r15 + _0xffffff8000ec498a: add rbx, rbx + _0xffffff8000ec498d: add rbx, r8 + _0xffffff8000ec4990: movabs r12, 0x25424a482489248 + _0xffffff8000ec499a: and r12, r13 + _0xffffff8000ec499d: mov rcx, r15 + _0xffffff8000ec49a0: and rcx, r12 + _0xffffff8000ec49a3: movabs r8, 0x125424a4a2489249 + _0xffffff8000ec49ad: mov r10, r15 + _0xffffff8000ec49b0: and r10, r8 + _0xffffff8000ec49b3: movabs r11, 0x4a8494944912492 + _0xffffff8000ec49bd: add r11, r12 + _0xffffff8000ec49c0: sub r11, r10 + _0xffffff8000ec49c3: and r11, r8 + _0xffffff8000ec49c6: add rcx, rcx + _0xffffff8000ec49c9: add rcx, r11 + _0xffffff8000ec49cc: movabs r12, 0x2922894914924912 + _0xffffff8000ec49d6: and r12, r15 + _0xffffff8000ec49d9: movabs r15, 0x2902890914904910 + _0xffffff8000ec49e3: and r15, r13 + _0xffffff8000ec49e6: add r15, r12 + _0xffffff8000ec49e9: add r15, rcx + _0xffffff8000ec49ec: add r15, rbx + _0xffffff8000ec49ef: mov ecx, 0xd8065de2 + _0xffffff8000ec49f4: mov r13, qword ptr [rbp - 0x3b8] + _0xffffff8000ec49fb: mov r8d, dword ptr [r13 + r15*4] + _0xffffff8000ec4a00: xor r8d, ecx + _0xffffff8000ec4a03: mov r10b, r9b + _0xffffff8000ec4a06: add r10b, r10b + _0xffffff8000ec4a09: xor r9b, 0x3f + _0xffffff8000ec4a0d: and r10b, 0x7e + _0xffffff8000ec4a11: add r10b, r9b + _0xffffff8000ec4a14: add r10b, 0xea + _0xffffff8000ec4a18: movzx ebx, r10b + _0xffffff8000ec4a1c: lea r15, [rbx + rbx] + _0xffffff8000ec4a20: mov r12, rbx + _0xffffff8000ec4a23: and r12, 0x14 + _0xffffff8000ec4a27: movabs r9, 0x2514948562745b3c + _0xffffff8000ec4a31: sub r9, r12 + _0xffffff8000ec4a34: movabs r12, 0x128a4a42a12a2914 + _0xffffff8000ec4a3e: and r12, r9 + _0xffffff8000ec4a41: mov r9, r15 + _0xffffff8000ec4a44: and r9, 0x28 + _0xffffff8000ec4a48: add r9, r12 + _0xffffff8000ec4a4b: mov r12, rbx + _0xffffff8000ec4a4e: and r12, 0xa1 + _0xffffff8000ec4a55: mov r10, r15 + _0xffffff8000ec4a58: and r10, 0x100 + _0xffffff8000ec4a5f: add r10, r12 + _0xffffff8000ec4a62: xor r10, 0x2810080 + _0xffffff8000ec4a69: add r10, r9 + _0xffffff8000ec4a6c: mov r12, rbx + _0xffffff8000ec4a6f: and r12, 0x4a + _0xffffff8000ec4a73: movabs r9, 0xa424950a8892494 + _0xffffff8000ec4a7d: add r9, r12 + _0xffffff8000ec4a80: movabs r12, 0x7fffffffeffbedbe + _0xffffff8000ec4a8a: add r12, r9 + _0xffffff8000ec4a8d: movabs r9, 0x452124a85444924a + _0xffffff8000ec4a97: and r9, r12 + _0xffffff8000ec4a9a: and r15, 0x84 + _0xffffff8000ec4aa1: add r15, r9 + _0xffffff8000ec4aa4: add r15, r10 + _0xffffff8000ec4aa7: cmp bl, 0x29 + _0xffffff8000ec4aaa: sbb rbx, rbx + _0xffffff8000ec4aad: and rbx, 0x100 + _0xffffff8000ec4ab4: mov r12, r15 + _0xffffff8000ec4ab7: and r12, rbx + _0xffffff8000ec4aba: xor rbx, r15 + _0xffffff8000ec4abd: lea rax, [rbx + r12*2] + _0xffffff8000ec4ac1: mov ebx, eax + _0xffffff8000ec4ac3: movabs r9, 0xcccccccccccccccd + _0xffffff8000ec4acd: mul r9 + _0xffffff8000ec4ad0: mov r15, rdx + _0xffffff8000ec4ad3: shr r15, 4 + _0xffffff8000ec4ad7: imul r10d, r15d, 0x14 + _0xffffff8000ec4adb: sub ebx, r10d + _0xffffff8000ec4ade: shl rbx, 0x20 + _0xffffff8000ec4ae2: and r15, 1 + _0xffffff8000ec4ae6: neg r15 + _0xffffff8000ec4ae9: movabs r12, 0x1400000000 + _0xffffff8000ec4af3: and r12, r15 + _0xffffff8000ec4af6: add r12, rbx + _0xffffff8000ec4af9: shr rdx, 5 + _0xffffff8000ec4afd: movabs rbx, 0xa00000000 + _0xffffff8000ec4b07: lea r15, [rdx + rbx] + _0xffffff8000ec4b0b: imul r15, r15 + _0xffffff8000ec4b0f: add r15, r12 + _0xffffff8000ec4b12: movabs r12, 0xcd5ae40100000000 + _0xffffff8000ec4b1c: add r12, r15 + _0xffffff8000ec4b1f: movabs r15, 0xfffffff600000000 + _0xffffff8000ec4b29: add rdx, r15 + _0xffffff8000ec4b2c: imul rdx, rdx + _0xffffff8000ec4b30: sub r12, rdx + _0xffffff8000ec4b33: mov r10, r12 + _0xffffff8000ec4b36: sar r10, 0x1e + _0xffffff8000ec4b3a: movabs r11, 0x4054042288021240 + _0xffffff8000ec4b44: and r11, r10 + _0xffffff8000ec4b47: mov r10, r12 + _0xffffff8000ec4b4a: sar r10, 0x1f + _0xffffff8000ec4b4e: movabs r13, 0xbfabfbdd77fde9be + _0xffffff8000ec4b58: and r13, r10 + _0xffffff8000ec4b5b: movabs r10, 0xa02a021144010b21 + _0xffffff8000ec4b65: xor r10, r13 + _0xffffff8000ec4b68: add r10, r11 + _0xffffff8000ec4b6b: sar r12, 0x20 + _0xffffff8000ec4b6f: mov r13d, 0xf265401b + _0xffffff8000ec4b75: xor r13, r12 + _0xffffff8000ec4b78: movabs r12, 0x5fd5fdee499bb4c4 + _0xffffff8000ec4b82: add r12, r13 + _0xffffff8000ec4b85: add r13, r13 + _0xffffff8000ec4b88: movabs r11, 0xbfabfbdc93376988 + _0xffffff8000ec4b92: and r11, r13 + _0xffffff8000ec4b95: sub r12, r11 + _0xffffff8000ec4b98: mov r13, r12 + _0xffffff8000ec4b9b: and r13, r10 + _0xffffff8000ec4b9e: xor r12, r10 + _0xffffff8000ec4ba1: lea r12, [r12 + r13*2] + _0xffffff8000ec4ba5: mov r13, qword ptr [rbp - 0x3c0] + _0xffffff8000ec4bac: xor ecx, dword ptr [r13 + r12*4] + _0xffffff8000ec4bb1: lea r10d, [r8 + rcx] + _0xffffff8000ec4bb5: and ecx, r8d + _0xffffff8000ec4bb8: add ecx, ecx + _0xffffff8000ec4bba: sub r10d, ecx + _0xffffff8000ec4bbd: xor r10d, 0xf925ef5f + _0xffffff8000ec4bc4: mov cl, byte ptr [rbp - 0x3e5] + _0xffffff8000ec4bca: add cl, cl + _0xffffff8000ec4bcc: xor byte ptr [rbp - 0x3e5], 0x7d + _0xffffff8000ec4bd3: and cl, 0xfa + _0xffffff8000ec4bd6: add cl, byte ptr [rbp - 0x3e5] + _0xffffff8000ec4bdc: add cl, 0xb7 + _0xffffff8000ec4bdf: movzx r12d, cl + _0xffffff8000ec4be3: mov rcx, 0xffffffffffffffcc + _0xffffff8000ec4bea: sub rcx, r12 + _0xffffff8000ec4bed: mov r8, rcx + _0xffffff8000ec4bf0: and r8, r12 + _0xffffff8000ec4bf3: xor rcx, r12 + _0xffffff8000ec4bf6: lea rcx, [rcx + r8*2] + _0xffffff8000ec4bfa: mov r8, rcx + _0xffffff8000ec4bfd: and r8, r12 + _0xffffff8000ec4c00: xor rcx, r12 + _0xffffff8000ec4c03: lea rcx, [rcx + r8*2] + _0xffffff8000ec4c07: cmp r12b, 0x34 + _0xffffff8000ec4c0b: sbb r12, r12 + _0xffffff8000ec4c0e: and r12, 0x100 + _0xffffff8000ec4c15: mov r8, rcx + _0xffffff8000ec4c18: and r8, r12 + _0xffffff8000ec4c1b: xor r12, rcx + _0xffffff8000ec4c1e: lea r12, [r12 + r8*2] + _0xffffff8000ec4c22: movabs rcx, 0x97b425ed097b425f + _0xffffff8000ec4c2c: mov rax, r12 + _0xffffff8000ec4c2f: mul rcx + _0xffffff8000ec4c32: mov rcx, rdx + _0xffffff8000ec4c35: shr rcx, 4 + _0xffffff8000ec4c39: lea r8, [rcx + rcx*8] + _0xffffff8000ec4c3d: lea r8, [r8 + r8*2] + _0xffffff8000ec4c41: mov r11, r12 + _0xffffff8000ec4c44: sub r11, r8 + _0xffffff8000ec4c47: and ecx, 1 + _0xffffff8000ec4c4a: neg ecx + _0xffffff8000ec4c4c: and rcx, 0x36 + _0xffffff8000ec4c50: lea rcx, [rcx + r11*2] + _0xffffff8000ec4c54: shr rdx, 5 + _0xffffff8000ec4c58: mov r8d, 0x1b + _0xffffff8000ec4c5e: sub r8, rdx + _0xffffff8000ec4c61: add rdx, 0x1b + _0xffffff8000ec4c65: imul rdx, rdx + _0xffffff8000ec4c69: add rdx, rcx + _0xffffff8000ec4c6c: imul r8, r8 + _0xffffff8000ec4c70: sub rdx, r8 + _0xffffff8000ec4c73: movabs rcx, 0xdeff1ef77dfefa3e + _0xffffff8000ec4c7d: sub rcx, rdx + _0xffffff8000ec4c80: mov r8, rcx + _0xffffff8000ec4c83: and r8, rdx + _0xffffff8000ec4c86: xor rcx, rdx + _0xffffff8000ec4c89: lea rcx, [rcx + r8*2] + _0xffffff8000ec4c8d: mov r8, rcx + _0xffffff8000ec4c90: sar r8, 1 + _0xffffff8000ec4c93: mov r11, rdx + _0xffffff8000ec4c96: sar r11, 1 + _0xffffff8000ec4c99: add r11, r8 + _0xffffff8000ec4c9c: mov r8d, edx + _0xffffff8000ec4c9f: neg r8d + _0xffffff8000ec4ca2: and r8d, ecx + _0xffffff8000ec4ca5: and r8, 1 + _0xffffff8000ec4ca9: add r8, r11 + _0xffffff8000ec4cac: movabs r11, 0xffffffff0abeb92b + _0xffffff8000ec4cb6: add r11, r8 + _0xffffff8000ec4cb9: xor rdx, rcx + _0xffffff8000ec4cbc: sar rdx, 1 + _0xffffff8000ec4cbf: sub r11, rdx + _0xffffff8000ec4cc2: mov ecx, 0x9d25450d + _0xffffff8000ec4cc7: xor rcx, r12 + _0xffffff8000ec4cca: movabs r12, 0x6f7f8f7b23da3812 + _0xffffff8000ec4cd4: add r12, rcx + _0xffffff8000ec4cd7: add rcx, rcx + _0xffffff8000ec4cda: movabs r8, 0xdeff1ef647b47024 + _0xffffff8000ec4ce4: and r8, rcx + _0xffffff8000ec4ce7: sub r12, r8 + _0xffffff8000ec4cea: movabs rcx, 0x10807084410082e1 + _0xffffff8000ec4cf4: and rcx, r12 + _0xffffff8000ec4cf7: movabs r8, 0x90807084410082e1 + _0xffffff8000ec4d01: xor r8, r12 + _0xffffff8000ec4d04: lea r12, [r8 + rcx*2] + _0xffffff8000ec4d08: mov ecx, 0xf54146d5 + _0xffffff8000ec4d0d: add rcx, r12 + _0xffffff8000ec4d10: mov r12, r11 + _0xffffff8000ec4d13: xor r12, rcx + _0xffffff8000ec4d16: mov r8, rcx + _0xffffff8000ec4d19: and r8, r11 + _0xffffff8000ec4d1c: movabs r13, 0x84a84a50a854922 + _0xffffff8000ec4d26: and r13, r11 + _0xffffff8000ec4d29: movabs rax, 0x12a15248a150a249 + _0xffffff8000ec4d33: and rax, r11 + _0xffffff8000ec4d36: movabs r11, 0x1aebd6edabd5eb6b + _0xffffff8000ec4d40: and r11, rcx + _0xffffff8000ec4d43: add r11, rax + _0xffffff8000ec4d46: add r11, r13 + _0xffffff8000ec4d49: movabs r13, 0x25142912542a1494 + _0xffffff8000ec4d53: and r13, r12 + _0xffffff8000ec4d56: movabs r12, 0x5142912542a1494 + _0xffffff8000ec4d60: and r12, r8 + _0xffffff8000ec4d63: add r12, r12 + _0xffffff8000ec4d66: add r12, r13 + _0xffffff8000ec4d69: add r12, r11 + _0xffffff8000ec4d6c: mov eax, 0xf925ef5f + _0xffffff8000ec4d71: mov r13, qword ptr [rbp - 0x3d8] + _0xffffff8000ec4d78: xor eax, dword ptr [r13 + r12*4] + _0xffffff8000ec4d7d: lea r8d, [r10 + rax] + _0xffffff8000ec4d81: and eax, r10d + _0xffffff8000ec4d84: add eax, eax + _0xffffff8000ec4d86: sub r8d, eax + _0xffffff8000ec4d89: mov al, byte ptr [rbp - 0x3e3] + _0xffffff8000ec4d8f: add al, al + _0xffffff8000ec4d91: xor byte ptr [rbp - 0x3e3], 0x78 + _0xffffff8000ec4d98: and al, 0xf0 + _0xffffff8000ec4d9a: add al, byte ptr [rbp - 0x3e3] + _0xffffff8000ec4da0: add al, 0xc0 + _0xffffff8000ec4da2: movzx r12d, al + _0xffffff8000ec4da6: mov rax, r12 + _0xffffff8000ec4da9: and rax, 0x57 + _0xffffff8000ec4dad: mov rcx, r12 + _0xffffff8000ec4db0: xor rcx, 0x5146b457 + _0xffffff8000ec4db7: lea rax, [rcx + rax*2] + _0xffffff8000ec4dbb: cmp r12b, 0x38 + _0xffffff8000ec4dbf: sbb r12, r12 + _0xffffff8000ec4dc2: and r12, 0x100 + _0xffffff8000ec4dc9: mov rcx, rax + _0xffffff8000ec4dcc: and rcx, r12 + _0xffffff8000ec4dcf: xor r12, rax + _0xffffff8000ec4dd2: lea r12, [r12 + rcx*2] + _0xffffff8000ec4dd6: mov rax, r12 + _0xffffff8000ec4dd9: shl rax, 0x21 + _0xffffff8000ec4ddd: movabs rcx, 0x5d7296e200000000 + _0xffffff8000ec4de7: and rcx, rax + _0xffffff8000ec4dea: shl r12, 0x20 + _0xffffff8000ec4dee: movabs rax, 0xaeb94b7100000000 + _0xffffff8000ec4df8: xor rax, r12 + _0xffffff8000ec4dfb: add rax, rcx + _0xffffff8000ec4dfe: sar rax, 0x20 + _0xffffff8000ec4e02: mov r12d, 0xf3a60961 + _0xffffff8000ec4e08: xor r12, rax + _0xffffff8000ec4e0b: lea rcx, [rax + r12] + _0xffffff8000ec4e0f: movabs r10, 0xf95c57fd285afed6 + _0xffffff8000ec4e19: add r10, rcx + _0xffffff8000ec4e1c: movabs rcx, 0x395c57fddbfcf7b7 + _0xffffff8000ec4e26: xor rcx, rax + _0xffffff8000ec4e29: sub r10, rcx + _0xffffff8000ec4e2c: add r12, r12 + _0xffffff8000ec4e2f: movabs rax, 0x32b8affa50b5fdac + _0xffffff8000ec4e39: and rax, r12 + _0xffffff8000ec4e3c: sub r10, rax + _0xffffff8000ec4e3f: mov r12, qword ptr [rbp - 0x3d0] + _0xffffff8000ec4e46: xor r8d, dword ptr [r12 + r10*4] + _0xffffff8000ec4e4a: mov al, byte ptr [rbp - 0x3e2] + _0xffffff8000ec4e50: add al, al + _0xffffff8000ec4e52: xor byte ptr [rbp - 0x3e2], 0x66 + _0xffffff8000ec4e59: and al, 0xcc + _0xffffff8000ec4e5b: add al, byte ptr [rbp - 0x3e2] + _0xffffff8000ec4e61: add al, 0xfe + _0xffffff8000ec4e63: movzx eax, al + _0xffffff8000ec4e66: lea rcx, [rax + rax] + _0xffffff8000ec4e6a: mov r10, rax + _0xffffff8000ec4e6d: and r10, 0x24 + _0xffffff8000ec4e71: movabs r11, 0xd9b3f1bf1b7e6f4c + _0xffffff8000ec4e7b: sub r11, r10 + _0xffffff8000ec4e7e: movabs r10, 0x48915095092a2524 + _0xffffff8000ec4e88: and r10, r11 + _0xffffff8000ec4e8b: mov r11, rcx + _0xffffff8000ec4e8e: and r11, 8 + _0xffffff8000ec4e92: add r11, r10 + _0xffffff8000ec4e95: mov r10, rax + _0xffffff8000ec4e98: and r10, 0x92 + _0xffffff8000ec4e9f: movabs r12, 0xe6de6f66fcf1b7b4 + _0xffffff8000ec4ea9: sub r12, r10 + _0xffffff8000ec4eac: movabs r10, 0xa24a252254509292 + _0xffffff8000ec4eb6: and r10, r12 + _0xffffff8000ec4eb9: mov r12, rcx + _0xffffff8000ec4ebc: and r12, 0x120 + _0xffffff8000ec4ec3: add r12, r10 + _0xffffff8000ec4ec6: add r12, r11 + _0xffffff8000ec4ec9: mov r10, rax + _0xffffff8000ec4ecc: and r10, 0x49 + _0xffffff8000ec4ed0: and rcx, 0x10 + _0xffffff8000ec4ed4: add rcx, r10 + _0xffffff8000ec4ed7: movabs r10, 0x15248a48a2854808 + _0xffffff8000ec4ee1: xor r10, rcx + _0xffffff8000ec4ee4: add r10, r12 + _0xffffff8000ec4ee7: cmp al, 0x64 + _0xffffff8000ec4ee9: sbb r12, r12 + _0xffffff8000ec4eec: and r12, 0x100 + _0xffffff8000ec4ef3: mov rax, r10 + _0xffffff8000ec4ef6: and rax, r12 + _0xffffff8000ec4ef9: xor r12, r10 + _0xffffff8000ec4efc: lea r12, [r12 + rax*2] + _0xffffff8000ec4f00: mov eax, 0xbe8c422f + _0xffffff8000ec4f05: xor rax, r12 + _0xffffff8000ec4f08: mov rcx, rax + _0xffffff8000ec4f0b: shr rcx, 4 + _0xffffff8000ec4f0f: and rcx, 0x80 + _0xffffff8000ec4f16: xor rcx, rax + _0xffffff8000ec4f19: mov rax, rcx + _0xffffff8000ec4f1c: shl rax, 0x15 + _0xffffff8000ec4f20: movabs r10, 0x200000000000000 + _0xffffff8000ec4f2a: and r10, rax + _0xffffff8000ec4f2d: xor r10, rcx + _0xffffff8000ec4f30: movabs rcx, 0x6cfefbcec35bb514 + _0xffffff8000ec4f3a: xor rcx, r10 + _0xffffff8000ec4f3d: movabs r10, 0x1000000000 + _0xffffff8000ec4f47: and r10, rcx + _0xffffff8000ec4f4a: lea rax, [rip - 0xd986] + _0xffffff8000ec4f51: mov rax, qword ptr [rax] + _0xffffff8000ec4f54: mov rax, r10 + _0xffffff8000ec4f57: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec4f61: mul rdx + _0xffffff8000ec4f64: shr rdx, 1 + _0xffffff8000ec4f67: lea rax, [rdx + rdx*2] + _0xffffff8000ec4f6b: sub r10, rax + _0xffffff8000ec4f6e: mov rax, r10 + _0xffffff8000ec4f71: imul rax, rax + _0xffffff8000ec4f75: mov r10d, eax + _0xffffff8000ec4f78: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec4f82: mul rdx + _0xffffff8000ec4f85: shr edx, 1 + _0xffffff8000ec4f87: lea eax, [rdx + rdx*2] + _0xffffff8000ec4f8a: sub r10d, eax + _0xffffff8000ec4f8d: shl r10, 0x39 + _0xffffff8000ec4f91: xor r10, rcx + _0xffffff8000ec4f94: mov rcx, r10 + _0xffffff8000ec4f97: and rcx, 0x800 + _0xffffff8000ec4f9e: mov rax, rcx + _0xffffff8000ec4fa1: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec4fab: mul rdx + _0xffffff8000ec4fae: shr rdx, 1 + _0xffffff8000ec4fb1: lea rax, [rdx + rdx*2] + _0xffffff8000ec4fb5: sub rcx, rax + _0xffffff8000ec4fb8: imul rcx, rcx + _0xffffff8000ec4fbc: mov rax, rcx + _0xffffff8000ec4fbf: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec4fc9: mul rdx + _0xffffff8000ec4fcc: shr rdx, 1 + _0xffffff8000ec4fcf: lea rax, [rdx + rdx*2] + _0xffffff8000ec4fd3: sub rcx, rax + _0xffffff8000ec4fd6: shl rcx, 7 + _0xffffff8000ec4fda: xor rcx, r10 + _0xffffff8000ec4fdd: movabs rax, 0x93010431822808c5 + _0xffffff8000ec4fe7: sub rax, rcx + _0xffffff8000ec4fea: movabs r10, 0x49242912a4914851 + _0xffffff8000ec4ff4: mov r11, rax + _0xffffff8000ec4ff7: and r11, r10 + _0xffffff8000ec4ffa: mov r13, rcx + _0xffffff8000ec4ffd: and r13, r10 + _0xffffff8000ec5000: movabs rdx, 0x12485225492290a2 + _0xffffff8000ec500a: add rdx, r13 + _0xffffff8000ec500d: sub rdx, r11 + _0xffffff8000ec5010: and rdx, r10 + _0xffffff8000ec5013: and r11, rcx + _0xffffff8000ec5016: add r11, r11 + _0xffffff8000ec5019: add r11, rdx + _0xffffff8000ec501c: movabs r13, 0x148a84a90a2a128a + _0xffffff8000ec5026: mov rdx, rax + _0xffffff8000ec5029: and rdx, r13 + _0xffffff8000ec502c: mov r10, rcx + _0xffffff8000ec502f: and r10, r13 + _0xffffff8000ec5032: movabs r9, 0x915095214542514 + _0xffffff8000ec503c: add r9, r10 + _0xffffff8000ec503f: sub r9, rdx + _0xffffff8000ec5042: and r9, r13 + _0xffffff8000ec5045: and rdx, rcx + _0xffffff8000ec5048: add rdx, rdx + _0xffffff8000ec504b: add rdx, r9 + _0xffffff8000ec504e: add rdx, r11 + _0xffffff8000ec5051: movabs r13, 0xa25152445144a524 + _0xffffff8000ec505b: mov r9, rcx + _0xffffff8000ec505e: and r9, r13 + _0xffffff8000ec5061: mov r10, rax + _0xffffff8000ec5064: and r10, r13 + _0xffffff8000ec5067: movabs r11, 0x44a2a488a2894a48 + _0xffffff8000ec5071: add r11, r10 + _0xffffff8000ec5074: sub r11, r9 + _0xffffff8000ec5077: and r11, r13 + _0xffffff8000ec507a: and r9, rax + _0xffffff8000ec507d: add r9, r9 + _0xffffff8000ec5080: add r9, r11 + _0xffffff8000ec5083: add r9, rdx + _0xffffff8000ec5086: mov r13, r9 + _0xffffff8000ec5089: and r13, rcx + _0xffffff8000ec508c: xor r9, rcx + _0xffffff8000ec508f: lea r13, [r9 + r13*2] + _0xffffff8000ec5093: mov rax, r12 + _0xffffff8000ec5096: and rax, 0x7f + _0xffffff8000ec509a: shr r12, 7 + _0xffffff8000ec509e: mov ecx, 0x20 + _0xffffff8000ec50a3: sub rcx, r12 + _0xffffff8000ec50a6: imul rcx, rcx + _0xffffff8000ec50aa: sub rax, rcx + _0xffffff8000ec50ad: add r12, 0x20 + _0xffffff8000ec50b1: imul r12, r12 + _0xffffff8000ec50b5: add r12, rax + _0xffffff8000ec50b8: add r12, r12 + _0xffffff8000ec50bb: movabs rax, 0xd9fdf79cfbafee76 + _0xffffff8000ec50c5: sub rax, r12 + _0xffffff8000ec50c8: mov rcx, rax + _0xffffff8000ec50cb: and rcx, r12 + _0xffffff8000ec50ce: xor rax, r12 + _0xffffff8000ec50d1: lea rax, [rax + rcx*2] + _0xffffff8000ec50d5: mov rcx, rax + _0xffffff8000ec50d8: xor rcx, r12 + _0xffffff8000ec50db: sar rax, 1 + _0xffffff8000ec50de: sar rcx, 1 + _0xffffff8000ec50e1: sub rax, rcx + _0xffffff8000ec50e4: sar r12, 1 + _0xffffff8000ec50e7: add r12, rax + _0xffffff8000ec50ea: mov rax, r13 + _0xffffff8000ec50ed: and rax, r12 + _0xffffff8000ec50f0: xor r12, r13 + _0xffffff8000ec50f3: lea r12, [r12 + rax*2] + _0xffffff8000ec50f7: mov al, byte ptr [rbp - 0x3c4] + _0xffffff8000ec50fd: add al, al + _0xffffff8000ec50ff: xor byte ptr [rbp - 0x3c4], 0x7f + _0xffffff8000ec5106: add byte ptr [rbp - 0x3c4], al + _0xffffff8000ec510c: add byte ptr [rbp - 0x3c4], 0xb9 + _0xffffff8000ec5113: movzx r13d, byte ptr [rbp - 0x3c4] + _0xffffff8000ec511b: mov rax, r13 + _0xffffff8000ec511e: xor rax, 0x3ebad7a5 + _0xffffff8000ec5124: cmp r13b, 0x38 + _0xffffff8000ec5128: sbb rcx, rcx + _0xffffff8000ec512b: and rcx, 0x100 + _0xffffff8000ec5132: add rcx, rax + _0xffffff8000ec5135: and r13, 0xa5 + _0xffffff8000ec513c: lea r13, [rcx + r13*2] + _0xffffff8000ec5140: mov rax, r13 + _0xffffff8000ec5143: movabs r9, 0xcccccccccccccccd + _0xffffff8000ec514d: mul r9 + _0xffffff8000ec5150: shr rdx, 5 + _0xffffff8000ec5154: add r15, rdx + _0xffffff8000ec5157: imul r15, r15 + _0xffffff8000ec515b: add rbx, rdx + _0xffffff8000ec515e: imul rbx, rbx + _0xffffff8000ec5162: sub rbx, r15 + _0xffffff8000ec5165: imul eax, edx, 0x28 + _0xffffff8000ec5168: sub r13d, eax + _0xffffff8000ec516b: shl r13, 0x20 + _0xffffff8000ec516f: add r13, rbx + _0xffffff8000ec5172: movabs rbx, 0x4145282300000000 + _0xffffff8000ec517c: and rbx, r13 + _0xffffff8000ec517f: movabs r15, 0xc145282300000000 + _0xffffff8000ec5189: xor r15, r13 + _0xffffff8000ec518c: lea rbx, [r15 + rbx*2] + _0xffffff8000ec5190: mov r15, rbx + _0xffffff8000ec5193: sar r15, 0x1f + _0xffffff8000ec5197: movabs r13, 0xd37dfee7da + _0xffffff8000ec51a1: and r13, r15 + _0xffffff8000ec51a4: mov r15, rbx + _0xffffff8000ec51a7: sar r15, 0x20 + _0xffffff8000ec51ab: movabs rax, 0x1f797fe9beff73ed + _0xffffff8000ec51b5: xor rax, r15 + _0xffffff8000ec51b8: add rax, r13 + _0xffffff8000ec51bb: sar rbx, 0x3f + _0xffffff8000ec51bf: mov ecx, ebx + _0xffffff8000ec51c1: xor ecx, 0x3ef2ff + _0xffffff8000ec51c7: neg ecx + _0xffffff8000ec51c9: lea ebx, [rbx + rcx + 0x1bef2ff] + _0xffffff8000ec51d0: shl rbx, 0x27 + _0xffffff8000ec51d4: add rbx, rax + _0xffffff8000ec51d7: mov r13, qword ptr [rbp - 0x3d8] + _0xffffff8000ec51de: lea rbx, [r13 + rbx*4] + _0xffffff8000ec51e3: movabs r15, 0x821a00590402304c + _0xffffff8000ec51ed: mov ecx, dword ptr [r15 + rbx] + _0xffffff8000ec51f1: mov r13, qword ptr [rbp - 0x3b8] + _0xffffff8000ec51f8: xor ecx, dword ptr [r13 + r12*4] + _0xffffff8000ec51fd: mov al, byte ptr [rbp - 0x3e4] + _0xffffff8000ec5203: add al, al + _0xffffff8000ec5205: xor byte ptr [rbp - 0x3e4], 0x14 + _0xffffff8000ec520c: and al, 0x28 + _0xffffff8000ec520e: add al, byte ptr [rbp - 0x3e4] + _0xffffff8000ec5214: add al, 0xf0 + _0xffffff8000ec5216: movzx ebx, al + _0xffffff8000ec5219: mov r15d, 0x12ad4ef6 + _0xffffff8000ec521f: sub r15, rbx + _0xffffff8000ec5222: mov r12, r15 + _0xffffff8000ec5225: and r12, 0x9254454 + _0xffffff8000ec522c: mov r13, r12 + _0xffffff8000ec522f: and r13, rbx + _0xffffff8000ec5232: mov rax, rbx + _0xffffff8000ec5235: and rax, 0x54 + _0xffffff8000ec5239: xor rax, r12 + _0xffffff8000ec523c: add r13, r13 + _0xffffff8000ec523f: add r13, rax + _0xffffff8000ec5242: mov r12, r15 + _0xffffff8000ec5245: and r12, 0x148a290a + _0xffffff8000ec524c: mov rax, rbx + _0xffffff8000ec524f: and rax, 0xab + _0xffffff8000ec5255: add rax, r12 + _0xffffff8000ec5258: and r15, 0x25092a1 + _0xffffff8000ec525f: add r15, rax + _0xffffff8000ec5262: add r15, r13 + _0xffffff8000ec5265: mov r12, r15 + _0xffffff8000ec5268: and r12, rbx + _0xffffff8000ec526b: xor r15, rbx + _0xffffff8000ec526e: lea r15, [r15 + r12*2] + _0xffffff8000ec5272: cmp bl, 4 + _0xffffff8000ec5275: sbb rbx, rbx + _0xffffff8000ec5278: and rbx, 0x100 + _0xffffff8000ec527f: mov r12, r15 + _0xffffff8000ec5282: and r12, rbx + _0xffffff8000ec5285: xor rbx, r15 + _0xffffff8000ec5288: lea rax, [rbx + r12*2 + 0x42532a58] + _0xffffff8000ec5290: mov ebx, eax + _0xffffff8000ec5292: movabs r15, 0x8888888888888889 + _0xffffff8000ec529c: mul r15 + _0xffffff8000ec529f: shr rdx, 4 + _0xffffff8000ec52a3: imul eax, edx, 0x1e + _0xffffff8000ec52a6: sub ebx, eax + _0xffffff8000ec52a8: shl rbx, 0x20 + _0xffffff8000ec52ac: movabs r15, 0x1e00000000 + _0xffffff8000ec52b6: imul r15, rdx + _0xffffff8000ec52ba: add r15, rbx + _0xffffff8000ec52bd: movabs rbx, 0x2fa5db5e00000000 + _0xffffff8000ec52c7: sub rbx, r15 + _0xffffff8000ec52ca: movabs r12, 0xbdacd5a800000000 + _0xffffff8000ec52d4: add r12, r15 + _0xffffff8000ec52d7: mov r15, r12 + _0xffffff8000ec52da: xor r15, rbx + _0xffffff8000ec52dd: add r15, r12 + _0xffffff8000ec52e0: and r12, rbx + _0xffffff8000ec52e3: lea rbx, [r15 + r12*2] + _0xffffff8000ec52e7: mov r15, rbx + _0xffffff8000ec52ea: sar r15, 0x20 + _0xffffff8000ec52ee: xor r15, 0x6b55010c + _0xffffff8000ec52f5: mov r12, r15 + _0xffffff8000ec52f8: shl r12, 0x23 + _0xffffff8000ec52fc: movabs r13, 0x800000000 + _0xffffff8000ec5306: and r12, r13 + _0xffffff8000ec5309: xor r12, r15 + _0xffffff8000ec530c: mov r15, r12 + _0xffffff8000ec530f: shl r15, 0x1d + _0xffffff8000ec5313: movabs rax, 0x100000000 + _0xffffff8000ec531d: and rax, r15 + _0xffffff8000ec5320: xor rax, r12 + _0xffffff8000ec5323: movabs r15, 0x47bfbff724aaec77 + _0xffffff8000ec532d: xor r15, rax + _0xffffff8000ec5330: mov r12, r15 + _0xffffff8000ec5333: and r12, 8 + _0xffffff8000ec5337: mov rax, r12 + _0xffffff8000ec533a: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec5344: mul rdx + _0xffffff8000ec5347: shr rdx, 1 + _0xffffff8000ec534a: lea rax, [rdx + rdx*2] + _0xffffff8000ec534e: sub r12, rax + _0xffffff8000ec5351: mov rax, r12 + _0xffffff8000ec5354: imul rax, rax + _0xffffff8000ec5358: mov r12d, eax + _0xffffff8000ec535b: movabs rdx, 0xaaaaaaaaaaaaaaab + _0xffffff8000ec5365: mul rdx + _0xffffff8000ec5368: shr rdx, 1 + _0xffffff8000ec536b: lea eax, [rdx + rdx*2] + _0xffffff8000ec536e: sub r12d, eax + _0xffffff8000ec5371: shl r12, 0x20 + _0xffffff8000ec5375: xor r12, r15 + _0xffffff8000ec5378: mov r15, r12 + _0xffffff8000ec537b: shl r15, 0x23 + _0xffffff8000ec537f: and r15, r13 + _0xffffff8000ec5382: xor r15, r12 + _0xffffff8000ec5385: movabs r12, 0x95222a2891254249 + _0xffffff8000ec538f: and r12, r15 + _0xffffff8000ec5392: sar rbx, 0x1f + _0xffffff8000ec5396: mov r13d, 0xf29e30a2 + _0xffffff8000ec539c: xor r13, rbx + _0xffffff8000ec539f: mov rbx, r13 + _0xffffff8000ec53a2: sar rbx, 0x22 + _0xffffff8000ec53a6: mov eax, ebx + _0xffffff8000ec53a8: xor eax, 0x63dfdfff + _0xffffff8000ec53ad: neg eax + _0xffffff8000ec53af: lea ebx, [rbx + rax + 0x63dfdfff] + _0xffffff8000ec53b6: shl rbx, 0x21 + _0xffffff8000ec53ba: movabs rax, 0x29fffdaf6 + _0xffffff8000ec53c4: and rax, r13 + _0xffffff8000ec53c7: add rax, rbx + _0xffffff8000ec53ca: mov ebx, 0x929e10a2 + _0xffffff8000ec53cf: xor rbx, rax + _0xffffff8000ec53d2: mov r13d, 0xa4688ab4 + _0xffffff8000ec53d8: add r13, rbx + _0xffffff8000ec53db: movabs rbx, 0xa922514249249294 + _0xffffff8000ec53e5: and rbx, r13 + _0xffffff8000ec53e8: lea rax, [r13 + r13] + _0xffffff8000ec53ed: movabs rdx, 0x5000800012090520 + _0xffffff8000ec53f7: and rdx, rax + _0xffffff8000ec53fa: add rdx, rbx + _0xffffff8000ec53fd: movabs rbx, 0xa800400009048290 + _0xffffff8000ec5407: xor rbx, rdx + _0xffffff8000ec540a: movabs rdx, 0x2000000000040080 + _0xffffff8000ec5414: and rdx, rax + _0xffffff8000ec5417: movabs rax, 0x12948495144a284a + _0xffffff8000ec5421: mov r9, r13 + _0xffffff8000ec5424: and r9, rax + _0xffffff8000ec5427: movabs r10, 0x3529092a289650d4 + _0xffffff8000ec5431: sub r10, r9 + _0xffffff8000ec5434: and r10, rax + _0xffffff8000ec5437: add r10, rdx + _0xffffff8000ec543a: movabs rax, 0x44492a28a2914520 + _0xffffff8000ec5444: and rax, r13 + _0xffffff8000ec5447: add rax, r10 + _0xffffff8000ec544a: add rax, rbx + _0xffffff8000ec544d: movabs rbx, 0x40000002910501 + _0xffffff8000ec5457: add rbx, rax + _0xffffff8000ec545a: movabs r13, 0x15222a2891254249 + _0xffffff8000ec5464: mov rax, rbx + _0xffffff8000ec5467: and rax, r13 + _0xffffff8000ec546a: movabs rdx, 0xa445451224a8492 + _0xffffff8000ec5474: add rdx, rax + _0xffffff8000ec5477: sub rdx, r12 + _0xffffff8000ec547a: and r12, rbx + _0xffffff8000ec547d: movabs rax, 0x488951424a889522 + _0xffffff8000ec5487: and rax, r15 + _0xffffff8000ec548a: movabs r9, 0x88951424a889522 + _0xffffff8000ec5494: mov r10, rbx + _0xffffff8000ec5497: and r10, r9 + _0xffffff8000ec549a: movabs r11, 0x112a28495112a44 + _0xffffff8000ec54a4: add r11, r10 + _0xffffff8000ec54a7: sub r11, rax + _0xffffff8000ec54aa: and r11, r9 + _0xffffff8000ec54ad: and rax, rbx + _0xffffff8000ec54b0: add rax, rax + _0xffffff8000ec54b3: add rax, r11 + _0xffffff8000ec54b6: and rdx, r13 + _0xffffff8000ec54b9: add r12, r12 + _0xffffff8000ec54bc: add r12, rdx + _0xffffff8000ec54bf: movabs r13, 0x2254849524522894 + _0xffffff8000ec54c9: and r15, r13 + _0xffffff8000ec54cc: and rbx, r13 + _0xffffff8000ec54cf: add rbx, r15 + _0xffffff8000ec54d2: add rbx, r12 + _0xffffff8000ec54d5: add rbx, rax + _0xffffff8000ec54d8: mov r9d, 0xc45528e4 + _0xffffff8000ec54de: mov r13, qword ptr [rbp - 0x3c0] + _0xffffff8000ec54e5: xor r9d, dword ptr [r13 + rbx*4] + _0xffffff8000ec54ea: mov al, byte ptr [rbp - 0x3e1] + _0xffffff8000ec54f0: add al, al + _0xffffff8000ec54f2: xor byte ptr [rbp - 0x3e1], 0x76 + _0xffffff8000ec54f9: and al, 0xec + _0xffffff8000ec54fb: add al, byte ptr [rbp - 0x3e1] + _0xffffff8000ec5501: add al, 0xdc + _0xffffff8000ec5503: movzx ebx, al + _0xffffff8000ec5506: mov r15, rbx + _0xffffff8000ec5509: and r15, 8 + _0xffffff8000ec550d: mov r12, rbx + _0xffffff8000ec5510: xor r12, 0x34e74508 + _0xffffff8000ec5517: lea r15, [r12 + r15*2] + _0xffffff8000ec551b: cmp bl, 0x52 + _0xffffff8000ec551e: sbb rbx, rbx + _0xffffff8000ec5521: and rbx, 0x100 + _0xffffff8000ec5528: mov r12, r15 + _0xffffff8000ec552b: and r12, rbx + _0xffffff8000ec552e: xor rbx, r15 + _0xffffff8000ec5531: lea rbx, [rbx + r12*2] + _0xffffff8000ec5535: movabs r15, 0x29e4129e4129e413 + _0xffffff8000ec553f: mov rax, rbx + _0xffffff8000ec5542: mul r15 + _0xffffff8000ec5545: mov r15, rbx + _0xffffff8000ec5548: sub r15, rdx + _0xffffff8000ec554b: shr r15, 1 + _0xffffff8000ec554e: add r15, rdx + _0xffffff8000ec5551: shr r15, 5 + _0xffffff8000ec5555: movabs r12, 0x3700000000 + _0xffffff8000ec555f: imul r12, r15 + _0xffffff8000ec5563: imul eax, r15d, 0x37 + _0xffffff8000ec5567: sub ebx, eax + _0xffffff8000ec5569: shl rbx, 0x20 + _0xffffff8000ec556d: add rbx, r12 + _0xffffff8000ec5570: movabs r15, 0x4b18baa600000000 + _0xffffff8000ec557a: and r15, rbx + _0xffffff8000ec557d: movabs r12, 0xcb18baa600000000 + _0xffffff8000ec5587: xor r12, rbx + _0xffffff8000ec558a: lea rbx, [r12 + r15*2] + _0xffffff8000ec558e: mov r15, rbx + _0xffffff8000ec5591: sar r15, 0x3f + _0xffffff8000ec5595: mov eax, r15d + _0xffffff8000ec5598: xor eax, 0x1fefeff + _0xffffff8000ec559d: neg eax + _0xffffff8000ec559f: lea r15d, [r15 + rax + 0x1fefeff] + _0xffffff8000ec55a7: shl r15, 0x27 + _0xffffff8000ec55ab: mov r12, rbx + _0xffffff8000ec55ae: sar r12, 0x1f + _0xffffff8000ec55b2: movabs r13, 0x9bfffb3b42 + _0xffffff8000ec55bc: and r13, r12 + _0xffffff8000ec55bf: add r13, r15 + _0xffffff8000ec55c2: movabs r15, 0x80801200022242 + _0xffffff8000ec55cc: and r15, r13 + _0xffffff8000ec55cf: movabs r12, 0x808080320002625f + _0xffffff8000ec55d9: xor r12, r13 + _0xffffff8000ec55dc: lea r15, [r12 + r15*2] + _0xffffff8000ec55e0: sar rbx, 0x20 + _0xffffff8000ec55e4: mov r12d, 0xffb0d3c6 + _0xffffff8000ec55ea: xor r12, rbx + _0xffffff8000ec55ed: movabs rbx, 0x7f7f7fcd004d4e67 + _0xffffff8000ec55f7: add rbx, r12 + _0xffffff8000ec55fa: add r12, r12 + _0xffffff8000ec55fd: movabs r13, 0xfefeff9a009a9cce + _0xffffff8000ec5607: and r13, r12 + _0xffffff8000ec560a: sub rbx, r13 + _0xffffff8000ec560d: mov r12, r15 + _0xffffff8000ec5610: and r12, rbx + _0xffffff8000ec5613: xor rbx, r15 + _0xffffff8000ec5616: lea rbx, [rbx + r12*2] + _0xffffff8000ec561a: mov eax, 0xbd8a01d6 + _0xffffff8000ec561f: mov r12, qword ptr [rbp - 0x3d0] + _0xffffff8000ec5626: xor eax, dword ptr [r12 + rbx*4] + _0xffffff8000ec562a: mov r12, qword ptr [rbp - 0x3e0] + _0xffffff8000ec5631: mov dl, byte ptr [rbp - 0x3f1] + _0xffffff8000ec5637: mov byte ptr [r12 + 4], dl + _0xffffff8000ec563c: xor ecx, 0xc45528e4 + _0xffffff8000ec5642: lea edx, [r9 + rcx] + _0xffffff8000ec5646: and ecx, r9d + _0xffffff8000ec5649: add ecx, ecx + _0xffffff8000ec564b: sub edx, ecx + _0xffffff8000ec564d: xor edx, 0xbd8a01d6 + _0xffffff8000ec5653: lea r9d, [rdx + rax] + _0xffffff8000ec5657: and edx, eax + _0xffffff8000ec5659: add edx, edx + _0xffffff8000ec565b: sub r9d, edx + _0xffffff8000ec565e: mov cl, r9b + _0xffffff8000ec5661: xor cl, 0x85 + _0xffffff8000ec5664: mov al, cl + _0xffffff8000ec5666: add al, al + _0xffffff8000ec5668: add cl, 0x81 + _0xffffff8000ec566b: and al, 0x22 + _0xffffff8000ec566d: sub cl, al + _0xffffff8000ec566f: mov dl, 0x95 + _0xffffff8000ec5671: mov al, r9b + _0xffffff8000ec5674: mul dl + _0xffffff8000ec5676: shr ax, 8 + _0xffffff8000ec567a: mov dl, al + _0xffffff8000ec567c: mov r10b, dl + _0xffffff8000ec567f: shr r10b, 5 + _0xffffff8000ec5683: mov r11b, 0x37 + _0xffffff8000ec5686: mov al, r10b + _0xffffff8000ec5689: mul r11b + _0xffffff8000ec568c: mov r11b, r9b + _0xffffff8000ec568f: sub r11b, al + _0xffffff8000ec5692: add r11b, r11b + _0xffffff8000ec5695: add r11b, 0x3c + _0xffffff8000ec5699: shr dl, 6 + _0xffffff8000ec569c: mov al, dl + _0xffffff8000ec569e: mul dl + _0xffffff8000ec56a0: sub r11b, al + _0xffffff8000ec56a3: and r10b, 1 + _0xffffff8000ec56a7: neg r10b + _0xffffff8000ec56aa: and r10b, 0x2e + _0xffffff8000ec56ae: add r10b, r11b + _0xffffff8000ec56b1: mov al, dl + _0xffffff8000ec56b3: add al, 0x6e + _0xffffff8000ec56b5: mul al + _0xffffff8000ec56b7: add al, r10b + _0xffffff8000ec56ba: and al, 0x28 + _0xffffff8000ec56bc: mov dl, cl + _0xffffff8000ec56be: xor dl, al + _0xffffff8000ec56c0: and al, cl + _0xffffff8000ec56c2: add al, al + _0xffffff8000ec56c4: add al, dl + _0xffffff8000ec56c6: movzx eax, al + _0xffffff8000ec56c9: cmp al, 4 + _0xffffff8000ec56cb: sbb rbx, rbx + _0xffffff8000ec56ce: and ebx, 0x100 + _0xffffff8000ec56d4: add ebx, eax + _0xffffff8000ec56d6: add ebx, 0x42702c45 + _0xffffff8000ec56dc: shl rbx, 0x20 + _0xffffff8000ec56e0: movabs r15, 0xbd8fd3b700000000 + _0xffffff8000ec56ea: add r15, rbx + _0xffffff8000ec56ed: mov rbx, r15 + _0xffffff8000ec56f0: sar rbx, 0x1f + _0xffffff8000ec56f4: movabs r13, 0x6dfbfe7f4fffbfde + _0xffffff8000ec56fe: and r13, rbx + _0xffffff8000ec5701: sar r15, 0x20 + _0xffffff8000ec5705: movabs rbx, 0x36fdff3fa7ffdfef + _0xffffff8000ec570f: xor rbx, r15 + _0xffffff8000ec5712: add rbx, r13 + _0xffffff8000ec5715: add rbx, qword ptr [r14 + 0x450] + _0xffffff8000ec571c: movabs r15, 0xc90200c058002011 + _0xffffff8000ec5726: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec572a: mov byte ptr [r12 + 8], al + _0xffffff8000ec572f: mov al, r8b + _0xffffff8000ec5732: add al, al + _0xffffff8000ec5734: mov cl, r8b + _0xffffff8000ec5737: xor cl, 5 + _0xffffff8000ec573a: and al, 0x7e + _0xffffff8000ec573c: add al, cl + _0xffffff8000ec573e: add al, 0x24 + _0xffffff8000ec5740: add cl, cl + _0xffffff8000ec5742: and cl, 0x74 + _0xffffff8000ec5745: sub al, cl + _0xffffff8000ec5747: movzx eax, al + _0xffffff8000ec574a: cmp al, 0x29 + _0xffffff8000ec574c: sbb rbx, rbx + _0xffffff8000ec574f: and ebx, 0x100 + _0xffffff8000ec5755: add ebx, eax + _0xffffff8000ec5757: add ebx, 0x52806728 + _0xffffff8000ec575d: shl rbx, 0x20 + _0xffffff8000ec5761: movabs r15, 0xad7f98af00000000 + _0xffffff8000ec576b: add r15, rbx + _0xffffff8000ec576e: mov rbx, r15 + _0xffffff8000ec5771: sar rbx, 0x1f + _0xffffff8000ec5775: movabs r13, 0xb3fff7e5f6ef33f8 + _0xffffff8000ec577f: and r13, rbx + _0xffffff8000ec5782: sar r15, 0x20 + _0xffffff8000ec5786: movabs rbx, 0x59fffbf2fb7799fc + _0xffffff8000ec5790: xor rbx, r15 + _0xffffff8000ec5793: add rbx, r13 + _0xffffff8000ec5796: add rbx, qword ptr [r14 + 0x470] + _0xffffff8000ec579d: movabs r15, 0xa600040d04886604 + _0xffffff8000ec57a7: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec57ab: mov byte ptr [r12 + 0xc], al + _0xffffff8000ec57b0: mov eax, 7 + _0xffffff8000ec57b5: sub eax, esi + _0xffffff8000ec57b7: mov ecx, eax + _0xffffff8000ec57b9: and ecx, esi + _0xffffff8000ec57bb: xor eax, esi + _0xffffff8000ec57bd: lea ecx, [rax + rcx*2] + _0xffffff8000ec57c0: mov eax, esi + _0xffffff8000ec57c2: shr eax, cl + _0xffffff8000ec57c4: and eax, 0x9a + _0xffffff8000ec57c9: neg eax + _0xffffff8000ec57cb: mov ecx, esi + _0xffffff8000ec57cd: shr ecx, 8 + _0xffffff8000ec57d0: add ecx, 0xcd + _0xffffff8000ec57d6: mov edx, ecx + _0xffffff8000ec57d8: and edx, eax + _0xffffff8000ec57da: xor ecx, eax + _0xffffff8000ec57dc: lea eax, [rcx + rdx*2] + _0xffffff8000ec57df: xor eax, 0x6ae2573e + _0xffffff8000ec57e4: lea ecx, [rax + rax] + _0xffffff8000ec57e7: and ecx, 0xe6 + _0xffffff8000ec57ed: neg ecx + _0xffffff8000ec57ef: lea eax, [rax + rcx + 0xf3] + _0xffffff8000ec57f6: mov cl, al + _0xffffff8000ec57f8: xor cl, 0xb6 + _0xffffff8000ec57fb: mov dl, cl + _0xffffff8000ec57fd: add dl, dl + _0xffffff8000ec57ff: add cl, 0xf5 + _0xffffff8000ec5802: and dl, 0xfa + _0xffffff8000ec5805: sub cl, dl + _0xffffff8000ec5807: mov dl, cl + _0xffffff8000ec5809: add dl, dl + _0xffffff8000ec580b: mov r10b, dl + _0xffffff8000ec580e: and r10b, 8 + _0xffffff8000ec5812: mov r11b, cl + _0xffffff8000ec5815: and r11b, 0x24 + _0xffffff8000ec5819: mov bl, 0x4c + _0xffffff8000ec581b: sub bl, r11b + _0xffffff8000ec581e: and bl, 0x24 + _0xffffff8000ec5821: or bl, r10b + _0xffffff8000ec5824: mov r10b, cl + _0xffffff8000ec5827: and r10b, 0x8a + _0xffffff8000ec582b: add r10b, bl + _0xffffff8000ec582e: and cl, 0x51 + _0xffffff8000ec5831: and dl, 2 + _0xffffff8000ec5834: or dl, cl + _0xffffff8000ec5836: xor dl, 1 + _0xffffff8000ec5839: add dl, r10b + _0xffffff8000ec583c: add al, al + _0xffffff8000ec583e: mov cl, al + _0xffffff8000ec5840: and cl, 0x12 + _0xffffff8000ec5843: mov r10b, dl + _0xffffff8000ec5846: and r10b, cl + _0xffffff8000ec5849: and al, 0x84 + _0xffffff8000ec584b: mov r11b, dl + _0xffffff8000ec584e: and r11b, al + _0xffffff8000ec5851: add r11b, r11b + _0xffffff8000ec5854: mov bl, dl + _0xffffff8000ec5856: and bl, 0xa4 + _0xffffff8000ec5859: or bl, 0x48 + _0xffffff8000ec585c: sub bl, al + _0xffffff8000ec585e: and bl, 0xa4 + _0xffffff8000ec5861: or bl, r11b + _0xffffff8000ec5864: mov al, dl + _0xffffff8000ec5866: and al, 9 + _0xffffff8000ec5868: add r10b, r10b + _0xffffff8000ec586b: or r10b, al + _0xffffff8000ec586e: and dl, 0x52 + _0xffffff8000ec5871: or dl, 0x24 + _0xffffff8000ec5874: sub dl, cl + _0xffffff8000ec5876: and dl, 0x52 + _0xffffff8000ec5879: or dl, r10b + _0xffffff8000ec587c: add dl, bl + _0xffffff8000ec587e: movzx eax, dl + _0xffffff8000ec5881: cmp al, 0x48 + _0xffffff8000ec5883: sbb rbx, rbx + _0xffffff8000ec5886: and ebx, 0x100 + _0xffffff8000ec588c: add ebx, eax + _0xffffff8000ec588e: add ebx, 0x559d3bb7 + _0xffffff8000ec5894: shl rbx, 0x20 + _0xffffff8000ec5898: movabs r15, 0xaa62c40100000000 + _0xffffff8000ec58a2: add r15, rbx + _0xffffff8000ec58a5: mov rbx, r15 + _0xffffff8000ec58a8: sar rbx, 0x1f + _0xffffff8000ec58ac: movabs r13, 0xf17bdffadffdffae + _0xffffff8000ec58b6: and r13, rbx + _0xffffff8000ec58b9: sar r15, 0x20 + _0xffffff8000ec58bd: movabs rbx, 0x78bdeffd6ffeffd7 + _0xffffff8000ec58c7: xor rbx, r15 + _0xffffff8000ec58ca: add rbx, r13 + _0xffffff8000ec58cd: add rbx, qword ptr [r14 + 0x438] + _0xffffff8000ec58d4: movabs r15, 0x8742100290010029 + _0xffffff8000ec58de: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec58e2: mov byte ptr [r12 + 1], al + _0xffffff8000ec58e7: mov eax, 8 + _0xffffff8000ec58ec: sub eax, r9d + _0xffffff8000ec58ef: mov ecx, eax + _0xffffff8000ec58f1: and ecx, r9d + _0xffffff8000ec58f4: xor eax, r9d + _0xffffff8000ec58f7: lea ecx, [rax + rcx*2] + _0xffffff8000ec58fa: mov eax, r9d + _0xffffff8000ec58fd: shr eax, cl + _0xffffff8000ec58ff: mov ecx, eax + _0xffffff8000ec5901: and ecx, 0x10 + _0xffffff8000ec5904: xor eax, 0x90 + _0xffffff8000ec5909: lea eax, [rax + rcx*2] + _0xffffff8000ec590c: mov ecx, r9d + _0xffffff8000ec590f: shr ecx, 7 + _0xffffff8000ec5912: mov edx, ecx + _0xffffff8000ec5914: or edx, 0xfe7009fa + _0xffffff8000ec591a: xor edx, 0x18ff605 + _0xffffff8000ec5920: mov r10d, edx + _0xffffff8000ec5923: and r10d, 0x67c4e541 + _0xffffff8000ec592a: sub edx, r10d + _0xffffff8000ec592d: xor edx, r10d + _0xffffff8000ec5930: or ecx, 0xff8ff604 + _0xffffff8000ec5936: xor ecx, 0x7009fa + _0xffffff8000ec593c: and ecx, edx + _0xffffff8000ec593e: sar ecx, 1 + _0xffffff8000ec5940: mov edx, ecx + _0xffffff8000ec5942: xor edx, 0xcc + _0xffffff8000ec5948: xor ecx, 0xdc + _0xffffff8000ec594e: add ecx, 0x10 + _0xffffff8000ec5951: sub edx, ecx + _0xffffff8000ec5953: add edx, eax + _0xffffff8000ec5955: xor edx, 0x90 + _0xffffff8000ec595b: mov cl, dl + _0xffffff8000ec595d: add cl, cl + _0xffffff8000ec595f: mov r10b, cl + _0xffffff8000ec5962: or r10b, 0x75 + _0xffffff8000ec5966: xor r10b, 0x8a + _0xffffff8000ec596a: mov r11b, cl + _0xffffff8000ec596d: or r11b, 0x8b + _0xffffff8000ec5971: xor r11b, 0x74 + _0xffffff8000ec5975: mov al, r10b + _0xffffff8000ec5978: mul r11b + _0xffffff8000ec597b: mov bl, r10b + _0xffffff8000ec597e: xor bl, r11b + _0xffffff8000ec5981: sar r10b, 1 + _0xffffff8000ec5984: sar r11b, 1 + _0xffffff8000ec5987: add r11b, r10b + _0xffffff8000ec598a: and al, 1 + _0xffffff8000ec598c: add al, r11b + _0xffffff8000ec598f: sar bl, 1 + _0xffffff8000ec5991: sub al, bl + _0xffffff8000ec5993: and al, 0xec + _0xffffff8000ec5995: xor al, 0xec + _0xffffff8000ec5997: xor dl, 0xaa + _0xffffff8000ec599a: and cl, 0xb8 + _0xffffff8000ec599d: xor cl, 0xa8 + _0xffffff8000ec59a0: add cl, dl + _0xffffff8000ec59a2: mov dl, al + _0xffffff8000ec59a4: xor dl, cl + _0xffffff8000ec59a6: and cl, al + _0xffffff8000ec59a8: add cl, cl + _0xffffff8000ec59aa: add cl, dl + _0xffffff8000ec59ac: movzx ebx, cl + _0xffffff8000ec59af: cmp bl, 0x52 + _0xffffff8000ec59b2: sbb r15, r15 + _0xffffff8000ec59b5: and r15, 0x100 + _0xffffff8000ec59bc: add r15, rbx + _0xffffff8000ec59bf: add r15, -0x52 + _0xffffff8000ec59c3: movabs rbx, 0x7bf7ffffddf7dfb5 + _0xffffff8000ec59cd: mov r13, r15 + _0xffffff8000ec59d0: and r13, rbx + _0xffffff8000ec59d3: xor r15, rbx + _0xffffff8000ec59d6: lea rbx, [r15 + r13*2] + _0xffffff8000ec59da: add rbx, qword ptr [r14 + 0x458] + _0xffffff8000ec59e1: movabs r15, 0x840800002208204b + _0xffffff8000ec59eb: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec59ef: mov byte ptr [r12 + 5], al + _0xffffff8000ec59f4: mov eax, r8d + _0xffffff8000ec59f7: shr eax, 8 + _0xffffff8000ec59fa: mov ecx, 0x8d + _0xffffff8000ec59ff: sub ecx, eax + _0xffffff8000ec5a01: mov edx, eax + _0xffffff8000ec5a03: and edx, 0x922511 + _0xffffff8000ec5a09: mov r10d, ecx + _0xffffff8000ec5a0c: and r10d, edx + _0xffffff8000ec5a0f: mov r11d, eax + _0xffffff8000ec5a12: and r11d, 0x4492a4 + _0xffffff8000ec5a19: mov ebx, ecx + _0xffffff8000ec5a1b: and ebx, r11d + _0xffffff8000ec5a1e: mov r15d, ecx + _0xffffff8000ec5a21: and r15d, 0x4a4492a4 + _0xffffff8000ec5a28: xor r15d, r11d + _0xffffff8000ec5a2b: add ebx, ebx + _0xffffff8000ec5a2d: add ebx, r15d + _0xffffff8000ec5a30: mov r11d, r8d + _0xffffff8000ec5a33: shr r11d, 7 + _0xffffff8000ec5a37: and r11d, 0x1a + _0xffffff8000ec5a3b: mov r15d, eax + _0xffffff8000ec5a3e: sub r15d, r11d + _0xffffff8000ec5a41: and eax, 0x29484a + _0xffffff8000ec5a46: add eax, r15d + _0xffffff8000ec5a49: mov r11d, ecx + _0xffffff8000ec5a4c: and r11d, 0x9129484a + _0xffffff8000ec5a53: add r11d, eax + _0xffffff8000ec5a56: add r11d, ebx + _0xffffff8000ec5a59: add r10d, r10d + _0xffffff8000ec5a5c: and ecx, 0x24922511 + _0xffffff8000ec5a62: add ecx, 0x9244a22 + _0xffffff8000ec5a68: sub ecx, edx + _0xffffff8000ec5a6a: and ecx, 0x24922511 + _0xffffff8000ec5a70: add ecx, r10d + _0xffffff8000ec5a73: add ecx, r11d + _0xffffff8000ec5a76: xor ecx, 0xad281936 + _0xffffff8000ec5a7c: lea eax, [rcx + rcx] + _0xffffff8000ec5a7f: and eax, 0x76 + _0xffffff8000ec5a82: neg eax + _0xffffff8000ec5a84: lea eax, [rcx + rax + 0xbb] + _0xffffff8000ec5a8b: mov cl, al + _0xffffff8000ec5a8d: add cl, cl + _0xffffff8000ec5a8f: mov dl, cl + _0xffffff8000ec5a91: and dl, 0x82 + _0xffffff8000ec5a94: and cl, 0x7c + _0xffffff8000ec5a97: or cl, dl + _0xffffff8000ec5a99: sar cl, 1 + _0xffffff8000ec5a9b: mov dl, cl + _0xffffff8000ec5a9d: xor dl, 0xf8 + _0xffffff8000ec5aa0: add cl, 0xb8 + _0xffffff8000ec5aa3: sub cl, dl + _0xffffff8000ec5aa5: xor al, 0x4b + _0xffffff8000ec5aa7: mov dl, al + _0xffffff8000ec5aa9: and dl, 0x54 + _0xffffff8000ec5aac: mov r10b, 0xb8 + _0xffffff8000ec5aaf: sub r10b, dl + _0xffffff8000ec5ab2: and r10b, 0x54 + _0xffffff8000ec5ab6: mov dl, al + _0xffffff8000ec5ab8: and dl, 0x21 + _0xffffff8000ec5abb: add dl, 0x21 + _0xffffff8000ec5abe: and dl, 0x21 + _0xffffff8000ec5ac1: or dl, r10b + _0xffffff8000ec5ac4: and al, 0x8a + _0xffffff8000ec5ac6: or al, 0x14 + _0xffffff8000ec5ac8: add al, 0xfe + _0xffffff8000ec5aca: and al, 0x8a + _0xffffff8000ec5acc: or al, dl + _0xffffff8000ec5ace: add al, cl + _0xffffff8000ec5ad0: movzx eax, al + _0xffffff8000ec5ad3: cmp al, 0x38 + _0xffffff8000ec5ad5: sbb rbx, rbx + _0xffffff8000ec5ad8: and ebx, 0x100 + _0xffffff8000ec5ade: add ebx, eax + _0xffffff8000ec5ae0: add ebx, 0x6f37f481 + _0xffffff8000ec5ae6: shl rbx, 0x20 + _0xffffff8000ec5aea: movabs r15, 0x90c80b4700000000 + _0xffffff8000ec5af4: add r15, rbx + _0xffffff8000ec5af7: mov rbx, r15 + _0xffffff8000ec5afa: sar rbx, 0x1f + _0xffffff8000ec5afe: movabs r13, 0xf5ffdf7eafedb0be + _0xffffff8000ec5b08: and r13, rbx + _0xffffff8000ec5b0b: sar r15, 0x20 + _0xffffff8000ec5b0f: movabs rbx, 0x7affefbf57f6d85f + _0xffffff8000ec5b19: xor rbx, r15 + _0xffffff8000ec5b1c: add rbx, r13 + _0xffffff8000ec5b1f: add rbx, qword ptr [r14 + 0x478] + _0xffffff8000ec5b26: movabs r15, 0x85001040a80927a1 + _0xffffff8000ec5b30: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec5b34: mov byte ptr [r12 + 9], al + _0xffffff8000ec5b39: mov eax, dword ptr [rbp - 0x3b0] + _0xffffff8000ec5b3f: shr eax, 7 + _0xffffff8000ec5b42: and eax, 0x8e + _0xffffff8000ec5b47: mov ecx, dword ptr [rbp - 0x3b0] + _0xffffff8000ec5b4d: shr ecx, 8 + _0xffffff8000ec5b50: add ecx, 0x47 + _0xffffff8000ec5b53: sub ecx, eax + _0xffffff8000ec5b55: xor ecx, 0x47 + _0xffffff8000ec5b58: mov al, cl + _0xffffff8000ec5b5a: add al, al + _0xffffff8000ec5b5c: xor cl, 0x2f + _0xffffff8000ec5b5f: and al, 0x5e + _0xffffff8000ec5b61: add al, cl + _0xffffff8000ec5b63: add al, 0xdb + _0xffffff8000ec5b65: movzx eax, al + _0xffffff8000ec5b68: cmp al, 0xa + _0xffffff8000ec5b6a: sbb rbx, rbx + _0xffffff8000ec5b6d: and ebx, 0x100 + _0xffffff8000ec5b73: add ebx, eax + _0xffffff8000ec5b75: add ebx, 0x1c12ae02 + _0xffffff8000ec5b7b: shl rbx, 0x20 + _0xffffff8000ec5b7f: movabs r15, 0xe3ed51f400000000 + _0xffffff8000ec5b89: add r15, rbx + _0xffffff8000ec5b8c: mov rbx, r15 + _0xffffff8000ec5b8f: sar rbx, 0x1f + _0xffffff8000ec5b93: movabs r13, 0xceddff3b37ffffda + _0xffffff8000ec5b9d: and r13, rbx + _0xffffff8000ec5ba0: sar r15, 0x20 + _0xffffff8000ec5ba4: movabs rbx, 0x676eff9d9bffffed + _0xffffff8000ec5bae: xor rbx, r15 + _0xffffff8000ec5bb1: add rbx, r13 + _0xffffff8000ec5bb4: add rbx, qword ptr [r14 + 0x418] + _0xffffff8000ec5bbb: movabs r15, 0x9891006264000013 + _0xffffff8000ec5bc5: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec5bc9: mov byte ptr [r12 + 0xd], al + _0xffffff8000ec5bce: mov eax, r9d + _0xffffff8000ec5bd1: shr eax, 0x10 + _0xffffff8000ec5bd4: mov ecx, 0x60 + _0xffffff8000ec5bd9: sub ecx, eax + _0xffffff8000ec5bdb: mov edx, ecx + _0xffffff8000ec5bdd: and edx, eax + _0xffffff8000ec5bdf: xor ecx, eax + _0xffffff8000ec5be1: lea ecx, [rcx + rdx*2] + _0xffffff8000ec5be4: mov edx, ecx + _0xffffff8000ec5be6: and edx, eax + _0xffffff8000ec5be8: xor ecx, eax + _0xffffff8000ec5bea: mov r10d, 0xffffffa0 + _0xffffff8000ec5bf0: sub r10d, eax + _0xffffff8000ec5bf3: xor eax, 0x60 + _0xffffff8000ec5bf6: add eax, r10d + _0xffffff8000ec5bf9: add eax, ecx + _0xffffff8000ec5bfb: lea eax, [rax + rdx*2] + _0xffffff8000ec5bfe: xor eax, 0xd6a0290a + _0xffffff8000ec5c03: lea ecx, [rax + rax] + _0xffffff8000ec5c06: and ecx, 0xd4 + _0xffffff8000ec5c0c: neg ecx + _0xffffff8000ec5c0e: lea ecx, [rax + rcx + 0x6a] + _0xffffff8000ec5c12: mov dl, 0x29 + _0xffffff8000ec5c14: mov al, cl + _0xffffff8000ec5c16: mul dl + _0xffffff8000ec5c18: shr ax, 8 + _0xffffff8000ec5c1c: mov dl, al + _0xffffff8000ec5c1e: shr dl, 3 + _0xffffff8000ec5c21: mov r10b, 0x32 + _0xffffff8000ec5c24: mov al, dl + _0xffffff8000ec5c26: mul r10b + _0xffffff8000ec5c29: mov r10b, cl + _0xffffff8000ec5c2c: sub r10b, al + _0xffffff8000ec5c2f: add r10b, r10b + _0xffffff8000ec5c32: mov r11b, 0x64 + _0xffffff8000ec5c35: mov al, dl + _0xffffff8000ec5c37: mul r11b + _0xffffff8000ec5c3a: add al, r10b + _0xffffff8000ec5c3d: mov dl, 0xb9 + _0xffffff8000ec5c3f: sub dl, al + _0xffffff8000ec5c41: mov r10b, dl + _0xffffff8000ec5c44: xor r10b, al + _0xffffff8000ec5c47: and dl, al + _0xffffff8000ec5c49: add dl, dl + _0xffffff8000ec5c4b: add dl, r10b + _0xffffff8000ec5c4e: mov r10b, dl + _0xffffff8000ec5c51: xor r10b, al + _0xffffff8000ec5c54: and dl, al + _0xffffff8000ec5c56: add dl, dl + _0xffffff8000ec5c58: add dl, r10b + _0xffffff8000ec5c5b: xor cl, 0x48 + _0xffffff8000ec5c5e: mov al, cl + _0xffffff8000ec5c60: add al, al + _0xffffff8000ec5c62: add cl, 7 + _0xffffff8000ec5c65: and al, 0xe + _0xffffff8000ec5c67: sub cl, al + _0xffffff8000ec5c69: xor cl, 0x30 + _0xffffff8000ec5c6c: mov al, cl + _0xffffff8000ec5c6e: xor al, dl + _0xffffff8000ec5c70: mov r10b, al + _0xffffff8000ec5c73: and r10b, 0x89 + _0xffffff8000ec5c77: mov r11b, dl + _0xffffff8000ec5c7a: and r11b, cl + _0xffffff8000ec5c7d: mov bl, r11b + _0xffffff8000ec5c80: and bl, 9 + _0xffffff8000ec5c83: add bl, bl + _0xffffff8000ec5c85: or bl, r10b + _0xffffff8000ec5c88: and al, 0x24 + _0xffffff8000ec5c8a: and r11b, 0x24 + _0xffffff8000ec5c8e: add r11b, r11b + _0xffffff8000ec5c91: or r11b, al + _0xffffff8000ec5c94: and dl, 0x52 + _0xffffff8000ec5c97: and cl, 0x52 + _0xffffff8000ec5c9a: add cl, dl + _0xffffff8000ec5c9c: add cl, r11b + _0xffffff8000ec5c9f: add cl, bl + _0xffffff8000ec5ca1: movzx eax, cl + _0xffffff8000ec5ca4: cmp al, 0x38 + _0xffffff8000ec5ca6: sbb rbx, rbx + _0xffffff8000ec5ca9: and ebx, 0x100 + _0xffffff8000ec5caf: add ebx, eax + _0xffffff8000ec5cb1: add ebx, 0x4471d01b + _0xffffff8000ec5cb7: shl rbx, 0x20 + _0xffffff8000ec5cbb: movabs r15, 0xbb8e2fad00000000 + _0xffffff8000ec5cc5: add r15, rbx + _0xffffff8000ec5cc8: mov rbx, r15 + _0xffffff8000ec5ccb: sar rbx, 0x1f + _0xffffff8000ec5ccf: movabs r13, 0xfdafedd7dfdfbfd6 + _0xffffff8000ec5cd9: and r13, rbx + _0xffffff8000ec5cdc: sar r15, 0x20 + _0xffffff8000ec5ce0: movabs rbx, 0x7ed7f6ebefefdfeb + _0xffffff8000ec5cea: xor rbx, r15 + _0xffffff8000ec5ced: add rbx, r13 + _0xffffff8000ec5cf0: add rbx, qword ptr [r14 + 0x460] + _0xffffff8000ec5cf7: movabs r15, 0x8128091410102015 + _0xffffff8000ec5d01: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec5d05: mov byte ptr [r12 + 2], al + _0xffffff8000ec5d0a: mov eax, dword ptr [rbp - 0x3b0] + _0xffffff8000ec5d10: shr eax, 0xf + _0xffffff8000ec5d13: and eax, 0x78 + _0xffffff8000ec5d16: mov ecx, dword ptr [rbp - 0x3b0] + _0xffffff8000ec5d1c: shr ecx, 0x10 + _0xffffff8000ec5d1f: add ecx, 0x3c + _0xffffff8000ec5d22: sub ecx, eax + _0xffffff8000ec5d24: xor ecx, 0x3c + _0xffffff8000ec5d27: mov al, cl + _0xffffff8000ec5d29: add al, al + _0xffffff8000ec5d2b: xor cl, 0x6c + _0xffffff8000ec5d2e: and al, 0xd8 + _0xffffff8000ec5d30: add al, cl + _0xffffff8000ec5d32: add al, 0xe0 + _0xffffff8000ec5d34: movzx eax, al + _0xffffff8000ec5d37: cmp al, 0x4c + _0xffffff8000ec5d39: sbb rbx, rbx + _0xffffff8000ec5d3c: and ebx, 0x100 + _0xffffff8000ec5d42: add ebx, eax + _0xffffff8000ec5d44: add ebx, 0x30096228 + _0xffffff8000ec5d4a: shl rbx, 0x20 + _0xffffff8000ec5d4e: movabs r15, 0xcff69d8c00000000 + _0xffffff8000ec5d58: add r15, rbx + _0xffffff8000ec5d5b: mov rbx, r15 + _0xffffff8000ec5d5e: sar rbx, 0x1f + _0xffffff8000ec5d62: movabs r13, 0xffbbff7eef5fb8aa + _0xffffff8000ec5d6c: and r13, rbx + _0xffffff8000ec5d6f: sar r15, 0x20 + _0xffffff8000ec5d73: movabs rbx, 0x7fddffbf77afdc55 + _0xffffff8000ec5d7d: xor rbx, r15 + _0xffffff8000ec5d80: add rbx, r13 + _0xffffff8000ec5d83: add rbx, qword ptr [r14 + 0x420] + _0xffffff8000ec5d8a: movabs r15, 0x80220040885023ab + _0xffffff8000ec5d94: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec5d98: mov byte ptr [r12 + 0xa], al + _0xffffff8000ec5d9d: mov eax, r8d + _0xffffff8000ec5da0: shr eax, 0xf + _0xffffff8000ec5da3: mov ecx, eax + _0xffffff8000ec5da5: and ecx, 0x704d + _0xffffff8000ec5dab: xor ecx, 0xd50e365e + _0xffffff8000ec5db1: mov edx, ecx + _0xffffff8000ec5db3: and edx, 0x404444a + _0xffffff8000ec5db9: and eax, 0x18fb2 + _0xffffff8000ec5dbe: xor eax, 0xd50e365e + _0xffffff8000ec5dc3: mov r10d, eax + _0xffffff8000ec5dc6: and r10d, 0x405044a + _0xffffff8000ec5dcd: add r10d, 0x88a8894 + _0xffffff8000ec5dd4: sub r10d, edx + _0xffffff8000ec5dd7: and r10d, 0x2445444a + _0xffffff8000ec5dde: mov edx, eax + _0xffffff8000ec5de0: and edx, 0x5000a914 + _0xffffff8000ec5de6: mov r11d, ecx + _0xffffff8000ec5de9: and r11d, 0x50002014 + _0xffffff8000ec5df0: add r11d, 0x25215228 + _0xffffff8000ec5df7: sub r11d, edx + _0xffffff8000ec5dfa: and r11d, 0x5290a914 + _0xffffff8000ec5e01: add r11d, r10d + _0xffffff8000ec5e04: and eax, 0x810a12a0 + _0xffffff8000ec5e09: and ecx, 0x810a1201 + _0xffffff8000ec5e0f: add ecx, 0x12542542 + _0xffffff8000ec5e15: sub ecx, eax + _0xffffff8000ec5e17: and ecx, 0x892a12a0 + _0xffffff8000ec5e1d: add ecx, r11d + _0xffffff8000ec5e20: sar ecx, 1 + _0xffffff8000ec5e22: mov eax, ecx + _0xffffff8000ec5e24: xor eax, 0x33 + _0xffffff8000ec5e27: mov edx, 0x33 + _0xffffff8000ec5e2c: sub edx, eax + _0xffffff8000ec5e2e: add edx, ecx + _0xffffff8000ec5e30: neg edx + _0xffffff8000ec5e32: mov eax, r8d + _0xffffff8000ec5e35: shr eax, 0x10 + _0xffffff8000ec5e38: mov ecx, eax + _0xffffff8000ec5e3a: and ecx, 0x14a2 + _0xffffff8000ec5e40: mov r10d, eax + _0xffffff8000ec5e43: and r10d, 0x4240 + _0xffffff8000ec5e4a: add r10d, ecx + _0xffffff8000ec5e4d: mov ecx, eax + _0xffffff8000ec5e4f: and ecx, 9 + _0xffffff8000ec5e52: add ecx, r10d + _0xffffff8000ec5e55: mov r10d, eax + _0xffffff8000ec5e58: and r10d, 0x2804 + _0xffffff8000ec5e5f: and eax, 0x8110 + _0xffffff8000ec5e64: add eax, 0x22a15228 + _0xffffff8000ec5e69: sub eax, r10d + _0xffffff8000ec5e6c: and eax, 0x9150a914 + _0xffffff8000ec5e71: add eax, ecx + _0xffffff8000ec5e73: add eax, 0xb3 + _0xffffff8000ec5e78: mov ecx, eax + _0xffffff8000ec5e7a: and ecx, edx + _0xffffff8000ec5e7c: xor eax, edx + _0xffffff8000ec5e7e: lea eax, [rax + rcx*2] + _0xffffff8000ec5e81: xor eax, 0xc0e39c7a + _0xffffff8000ec5e86: lea ecx, [rax + rax] + _0xffffff8000ec5e89: and ecx, 0x92 + _0xffffff8000ec5e8f: neg ecx + _0xffffff8000ec5e91: lea eax, [rax + rcx + 0xc9] + _0xffffff8000ec5e98: mov cl, al + _0xffffff8000ec5e9a: xor cl, 4 + _0xffffff8000ec5e9d: mov dl, cl + _0xffffff8000ec5e9f: add dl, dl + _0xffffff8000ec5ea1: add cl, 0x79 + _0xffffff8000ec5ea4: and dl, 0xf2 + _0xffffff8000ec5ea7: sub cl, dl + _0xffffff8000ec5ea9: mov dl, cl + _0xffffff8000ec5eab: add dl, dl + _0xffffff8000ec5ead: xor cl, 0xb7 + _0xffffff8000ec5eb0: and dl, 0x6e + _0xffffff8000ec5eb3: add dl, cl + _0xffffff8000ec5eb5: add al, al + _0xffffff8000ec5eb7: mov cl, al + _0xffffff8000ec5eb9: and cl, 0x72 + _0xffffff8000ec5ebc: and al, 0x88 + _0xffffff8000ec5ebe: or al, cl + _0xffffff8000ec5ec0: mov cl, dl + _0xffffff8000ec5ec2: xor cl, al + _0xffffff8000ec5ec4: and al, dl + _0xffffff8000ec5ec6: add al, al + _0xffffff8000ec5ec8: add al, cl + _0xffffff8000ec5eca: movzx ebx, al + _0xffffff8000ec5ecd: cmp bl, 0x34 + _0xffffff8000ec5ed0: sbb r15, r15 + _0xffffff8000ec5ed3: and r15, 0x100 + _0xffffff8000ec5eda: add r15, rbx + _0xffffff8000ec5edd: add r15, -0x34 + _0xffffff8000ec5ee1: movabs rbx, 0x767ffffc9fffef37 + _0xffffff8000ec5eeb: mov r13, r15 + _0xffffff8000ec5eee: and r13, rbx + _0xffffff8000ec5ef1: xor r15, rbx + _0xffffff8000ec5ef4: lea rbx, [r15 + r13*2] + _0xffffff8000ec5ef8: add rbx, qword ptr [r14 + 0x480] + _0xffffff8000ec5eff: movabs r15, 0x89800003600010c9 + _0xffffff8000ec5f09: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec5f0d: mov byte ptr [r12 + 6], al + _0xffffff8000ec5f12: mov eax, esi + _0xffffff8000ec5f14: xor eax, 0x151f8000 + _0xffffff8000ec5f19: shr eax, 0xf + _0xffffff8000ec5f1c: mov ecx, eax + _0xffffff8000ec5f1e: and ecx, 0x1e1dd + _0xffffff8000ec5f24: xor ecx, 0x5193267e + _0xffffff8000ec5f2a: and eax, 0x1e22 + _0xffffff8000ec5f2f: xor eax, 0x5193267e + _0xffffff8000ec5f34: lea edx, [rax + rcx] + _0xffffff8000ec5f37: and eax, ecx + _0xffffff8000ec5f39: add eax, eax + _0xffffff8000ec5f3b: sub edx, eax + _0xffffff8000ec5f3d: xor edx, 0x2a3f + _0xffffff8000ec5f43: mov eax, edx + _0xffffff8000ec5f45: sar eax, 6 + _0xffffff8000ec5f48: mov ecx, eax + _0xffffff8000ec5f4a: xor ecx, 2 + _0xffffff8000ec5f4d: neg ecx + _0xffffff8000ec5f4f: lea eax, [rax + rcx + 2] + _0xffffff8000ec5f53: shl eax, 5 + _0xffffff8000ec5f56: and edx, 8 + _0xffffff8000ec5f59: add edx, eax + _0xffffff8000ec5f5b: mov eax, esi + _0xffffff8000ec5f5d: shr eax, 0xf + _0xffffff8000ec5f60: and eax, 0x88 + _0xffffff8000ec5f65: mov ecx, esi + _0xffffff8000ec5f67: shr ecx, 0x10 + _0xffffff8000ec5f6a: xor ecx, 0xc4 + _0xffffff8000ec5f70: add ecx, eax + _0xffffff8000ec5f72: sub ecx, edx + _0xffffff8000ec5f74: xor ecx, 0xc4 + _0xffffff8000ec5f7a: mov al, 0x6f + _0xffffff8000ec5f7c: sub al, cl + _0xffffff8000ec5f7e: mov dl, al + _0xffffff8000ec5f80: xor dl, cl + _0xffffff8000ec5f82: and al, cl + _0xffffff8000ec5f84: add al, al + _0xffffff8000ec5f86: add al, dl + _0xffffff8000ec5f88: xor al, 0x1e + _0xffffff8000ec5f8a: mov dl, cl + _0xffffff8000ec5f8c: xor dl, 0x1e + _0xffffff8000ec5f8f: mov r10b, dl + _0xffffff8000ec5f92: add r10b, al + _0xffffff8000ec5f95: and dl, al + _0xffffff8000ec5f97: add dl, dl + _0xffffff8000ec5f99: add r10b, 0xbf + _0xffffff8000ec5f9d: sub r10b, dl + _0xffffff8000ec5fa0: mov dl, r10b + _0xffffff8000ec5fa3: and dl, 0x4a + _0xffffff8000ec5fa6: or dl, 0x14 + _0xffffff8000ec5fa9: mov r11b, cl + _0xffffff8000ec5fac: or r11b, 0x93 + _0xffffff8000ec5fb0: xor r11b, 0x6c + _0xffffff8000ec5fb4: or cl, 0x6c + _0xffffff8000ec5fb7: xor cl, 0x93 + _0xffffff8000ec5fba: mov al, r11b + _0xffffff8000ec5fbd: mul cl + _0xffffff8000ec5fbf: mov bl, r11b + _0xffffff8000ec5fc2: xor bl, cl + _0xffffff8000ec5fc4: shr r11b, 1 + _0xffffff8000ec5fc7: shr cl, 1 + _0xffffff8000ec5fc9: add cl, r11b + _0xffffff8000ec5fcc: and al, 1 + _0xffffff8000ec5fce: add al, cl + _0xffffff8000ec5fd0: shr bl, 1 + _0xffffff8000ec5fd2: sub al, bl + _0xffffff8000ec5fd4: add al, al + _0xffffff8000ec5fd6: xor al, 0xfe + _0xffffff8000ec5fd8: mov cl, al + _0xffffff8000ec5fda: and cl, 0x4a + _0xffffff8000ec5fdd: sub dl, cl + _0xffffff8000ec5fdf: and cl, r10b + _0xffffff8000ec5fe2: add cl, cl + _0xffffff8000ec5fe4: and dl, 0x4a + _0xffffff8000ec5fe7: or dl, cl + _0xffffff8000ec5fe9: mov cl, r10b + _0xffffff8000ec5fec: and cl, 0x14 + _0xffffff8000ec5fef: mov r11b, al + _0xffffff8000ec5ff2: and r11b, 0x14 + _0xffffff8000ec5ff6: add r11b, cl + _0xffffff8000ec5ff9: and r10b, 0xa1 + _0xffffff8000ec5ffd: add r10b, r11b + _0xffffff8000ec6000: and al, 0x80 + _0xffffff8000ec6002: add al, r10b + _0xffffff8000ec6005: add al, dl + _0xffffff8000ec6007: movzx eax, al + _0xffffff8000ec600a: cmp al, 0x2e + _0xffffff8000ec600c: sbb rbx, rbx + _0xffffff8000ec600f: and ebx, 0x100 + _0xffffff8000ec6015: add ebx, eax + _0xffffff8000ec6017: add ebx, 0x7b89d927 + _0xffffff8000ec601d: shl rbx, 0x20 + _0xffffff8000ec6021: movabs r15, 0x847626ab00000000 + _0xffffff8000ec602b: add r15, rbx + _0xffffff8000ec602e: mov rbx, r15 + _0xffffff8000ec6031: sar rbx, 0x1f + _0xffffff8000ec6035: movabs r13, 0xef77ef7a2db54f7c + _0xffffff8000ec603f: and r13, rbx + _0xffffff8000ec6042: sar r15, 0x20 + _0xffffff8000ec6046: movabs rbx, 0x77bbf7bd16daa7be + _0xffffff8000ec6050: xor rbx, r15 + _0xffffff8000ec6053: add rbx, r13 + _0xffffff8000ec6056: add rbx, qword ptr [r14 + 0x440] + _0xffffff8000ec605d: movabs r15, 0x88440842e9255842 + _0xffffff8000ec6067: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec606b: mov byte ptr [r12 + 0xe], al + _0xffffff8000ec6070: mov ecx, 0x17 + _0xffffff8000ec6075: sub ecx, r9d + _0xffffff8000ec6078: mov eax, ecx + _0xffffff8000ec607a: and eax, 0x8928a922 + _0xffffff8000ec607f: mov edx, r9d + _0xffffff8000ec6082: and edx, 2 + _0xffffff8000ec6085: sub edx, eax + _0xffffff8000ec6087: and eax, r9d + _0xffffff8000ec608a: mov r10d, r9d + _0xffffff8000ec608d: and r10d, 0x22921494 + _0xffffff8000ec6094: mov r11d, ecx + _0xffffff8000ec6097: and r11d, r10d + _0xffffff8000ec609a: add r11d, r11d + _0xffffff8000ec609d: mov ebx, ecx + _0xffffff8000ec609f: and ebx, 0x14 + _0xffffff8000ec60a2: add ebx, 8 + _0xffffff8000ec60a5: sub ebx, r10d + _0xffffff8000ec60a8: and ebx, 0x14 + _0xffffff8000ec60ab: add ebx, r11d + _0xffffff8000ec60ae: add eax, eax + _0xffffff8000ec60b0: and edx, 2 + _0xffffff8000ec60b3: add edx, eax + _0xffffff8000ec60b5: mov eax, r9d + _0xffffff8000ec60b8: and eax, 9 + _0xffffff8000ec60bb: and ecx, 9 + _0xffffff8000ec60be: add ecx, eax + _0xffffff8000ec60c0: add ecx, edx + _0xffffff8000ec60c2: add ecx, ebx + _0xffffff8000ec60c4: and ecx, 0x1f + _0xffffff8000ec60c7: mov eax, r9d + _0xffffff8000ec60ca: and eax, 0x9e8ecb23 + _0xffffff8000ec60cf: shr eax, cl + _0xffffff8000ec60d1: mov edx, eax + _0xffffff8000ec60d3: and edx, 0x549214a4 + _0xffffff8000ec60d9: mov r10d, r9d + _0xffffff8000ec60dc: and r10d, 0x617134dc + _0xffffff8000ec60e3: shr r10d, cl + _0xffffff8000ec60e6: mov ecx, r10d + _0xffffff8000ec60e9: and ecx, 0x24 + _0xffffff8000ec60ec: add ecx, 8 + _0xffffff8000ec60ef: sub ecx, edx + _0xffffff8000ec60f1: and edx, r10d + _0xffffff8000ec60f4: mov r11d, eax + _0xffffff8000ec60f7: and r11d, 0xa248a911 + _0xffffff8000ec60fe: mov ebx, r10d + _0xffffff8000ec6101: and ebx, 0x11 + _0xffffff8000ec6104: add ebx, 2 + _0xffffff8000ec6107: sub ebx, r11d + _0xffffff8000ec610a: and r11d, r10d + _0xffffff8000ec610d: add r11d, r11d + _0xffffff8000ec6110: and ebx, 0x11 + _0xffffff8000ec6113: add ebx, r11d + _0xffffff8000ec6116: add edx, edx + _0xffffff8000ec6118: and ecx, 0x24 + _0xffffff8000ec611b: add ecx, edx + _0xffffff8000ec611d: and eax, 0x4a + _0xffffff8000ec6120: and r10d, 0x4a + _0xffffff8000ec6124: add r10d, eax + _0xffffff8000ec6127: add r10d, ecx + _0xffffff8000ec612a: add r10d, ebx + _0xffffff8000ec612d: and r10d, 0x50 + _0xffffff8000ec6131: neg r10d + _0xffffff8000ec6134: shr r9d, 0x18 + _0xffffff8000ec6138: mov eax, r9d + _0xffffff8000ec613b: and eax, 0x80 + _0xffffff8000ec6140: mov ecx, r9d + _0xffffff8000ec6143: and ecx, 0x14 + _0xffffff8000ec6146: add ecx, eax + _0xffffff8000ec6148: mov eax, r9d + _0xffffff8000ec614b: and eax, 0x20 + _0xffffff8000ec614e: mov edx, r9d + _0xffffff8000ec6151: and edx, 1 + _0xffffff8000ec6154: add edx, 0x4514a42 + _0xffffff8000ec615a: sub edx, eax + _0xffffff8000ec615c: and edx, 0x2228a521 + _0xffffff8000ec6162: add edx, ecx + _0xffffff8000ec6164: mov eax, r9d + _0xffffff8000ec6167: and eax, 0xa + _0xffffff8000ec616a: and r9d, 0x40 + _0xffffff8000ec616e: add r9d, 0x29249094 + _0xffffff8000ec6175: sub r9d, eax + _0xffffff8000ec6178: and r9d, 0x5492484a + _0xffffff8000ec617f: add r9d, edx + _0xffffff8000ec6182: mov eax, r9d + _0xffffff8000ec6185: and eax, 0xa8 + _0xffffff8000ec618a: xor r9d, 0xa8 + _0xffffff8000ec6191: lea eax, [r9 + rax*2] + _0xffffff8000ec6195: mov ecx, eax + _0xffffff8000ec6197: and ecx, r10d + _0xffffff8000ec619a: xor r10d, eax + _0xffffff8000ec619d: lea edx, [r10 + rcx*2] + _0xffffff8000ec61a1: xor edx, 0xa8 + _0xffffff8000ec61a7: mov cl, 1 + _0xffffff8000ec61a9: sub cl, dl + _0xffffff8000ec61ab: mov al, cl + _0xffffff8000ec61ad: xor al, dl + _0xffffff8000ec61af: and cl, dl + _0xffffff8000ec61b1: add cl, cl + _0xffffff8000ec61b3: add cl, al + _0xffffff8000ec61b5: mov r9b, dl + _0xffffff8000ec61b8: add r9b, 0x11 + _0xffffff8000ec61bc: mov r10b, r9b + _0xffffff8000ec61bf: and r10b, 0xfd + _0xffffff8000ec61c3: shl r10b, cl + _0xffffff8000ec61c6: xor r10b, 3 + _0xffffff8000ec61ca: and r9b, 2 + _0xffffff8000ec61ce: shl r9b, cl + _0xffffff8000ec61d1: not r9b + _0xffffff8000ec61d4: mov al, r10b + _0xffffff8000ec61d7: mul r9b + _0xffffff8000ec61da: mov r11b, r10b + _0xffffff8000ec61dd: xor r11b, r9b + _0xffffff8000ec61e0: mov bl, r9b + _0xffffff8000ec61e3: sar bl, 1 + _0xffffff8000ec61e5: sar r10b, 1 + _0xffffff8000ec61e8: add r10b, bl + _0xffffff8000ec61eb: and al, 1 + _0xffffff8000ec61ed: add al, r10b + _0xffffff8000ec61f0: sar r11b, 1 + _0xffffff8000ec61f3: sub al, r11b + _0xffffff8000ec61f6: or r9b, 3 + _0xffffff8000ec61fa: xor r9b, al + _0xffffff8000ec61fd: xor r9b, 0xfc + _0xffffff8000ec6201: mov al, 0x11 + _0xffffff8000ec6203: shl al, cl + _0xffffff8000ec6205: sub r9b, al + _0xffffff8000ec6208: and r9b, 0xcc + _0xffffff8000ec620c: mov al, dl + _0xffffff8000ec620e: add al, al + _0xffffff8000ec6210: xor dl, 0x98 + _0xffffff8000ec6213: and al, 0xfc + _0xffffff8000ec6215: xor al, 0xcc + _0xffffff8000ec6217: add al, dl + _0xffffff8000ec6219: mov cl, r9b + _0xffffff8000ec621c: xor cl, al + _0xffffff8000ec621e: and al, r9b + _0xffffff8000ec6221: add al, al + _0xffffff8000ec6223: add al, cl + _0xffffff8000ec6225: movzx ebx, al + _0xffffff8000ec6228: cmp bl, 0x64 + _0xffffff8000ec622b: sbb r15, r15 + _0xffffff8000ec622e: and r15, 0x100 + _0xffffff8000ec6235: add r15, rbx + _0xffffff8000ec6238: add r15, -0x64 + _0xffffff8000ec623c: movabs rbx, 0x3daeaf6d3bfd3eff + _0xffffff8000ec6246: mov r13, r15 + _0xffffff8000ec6249: and r13, rbx + _0xffffff8000ec624c: xor r15, rbx + _0xffffff8000ec624f: lea rbx, [r15 + r13*2] + _0xffffff8000ec6253: add rbx, qword ptr [r14 + 0x468] + _0xffffff8000ec625a: movabs r15, 0xc2515092c402c101 + _0xffffff8000ec6264: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec6268: mov byte ptr [r12 + 0xf], al + _0xffffff8000ec626d: shr edi, 0x18 + _0xffffff8000ec6270: and edi, 0x5d + _0xffffff8000ec6273: shr esi, 0x18 + _0xffffff8000ec6276: mov eax, esi + _0xffffff8000ec6278: and eax, 0xa2 + _0xffffff8000ec627d: add eax, edi + _0xffffff8000ec627f: mov ecx, eax + _0xffffff8000ec6281: and ecx, 0x62 + _0xffffff8000ec6284: xor eax, 0x62 + _0xffffff8000ec6287: lea eax, [rax + rcx*2] + _0xffffff8000ec628a: mov ecx, esi + _0xffffff8000ec628c: xor ecx, 0x62 + _0xffffff8000ec628f: mov edx, 0x62 + _0xffffff8000ec6294: sub edx, ecx + _0xffffff8000ec6296: add edx, esi + _0xffffff8000ec6298: neg edx + _0xffffff8000ec629a: mov ecx, eax + _0xffffff8000ec629c: and ecx, edx + _0xffffff8000ec629e: xor edx, eax + _0xffffff8000ec62a0: lea eax, [rdx + rcx*2] + _0xffffff8000ec62a3: xor eax, 0x5392f815 + _0xffffff8000ec62a8: lea ecx, [rax + rax] + _0xffffff8000ec62ab: and ecx, 0xee + _0xffffff8000ec62b1: neg ecx + _0xffffff8000ec62b3: lea ecx, [rax + rcx + 0x77] + _0xffffff8000ec62b7: mov al, cl + _0xffffff8000ec62b9: and al, 3 + _0xffffff8000ec62bb: mov dl, 0xaa + _0xffffff8000ec62bd: mul dl + _0xffffff8000ec62bf: mov dl, al + _0xffffff8000ec62c1: mov sil, cl + _0xffffff8000ec62c4: sar sil, 2 + _0xffffff8000ec62c8: mov al, sil + _0xffffff8000ec62cb: add al, 0x56 + _0xffffff8000ec62cd: mul al + _0xffffff8000ec62cf: sub dl, al + _0xffffff8000ec62d1: mov al, sil + _0xffffff8000ec62d4: add al, 0xaa + _0xffffff8000ec62d6: mul al + _0xffffff8000ec62d8: add al, dl + _0xffffff8000ec62da: mov dl, 0xfd + _0xffffff8000ec62dc: mul dl + _0xffffff8000ec62de: mov dl, 0xe0 + _0xffffff8000ec62e0: sub dl, al + _0xffffff8000ec62e2: mov sil, dl + _0xffffff8000ec62e5: xor sil, al + _0xffffff8000ec62e8: and dl, al + _0xffffff8000ec62ea: add dl, dl + _0xffffff8000ec62ec: add dl, sil + _0xffffff8000ec62ef: and dl, al + _0xffffff8000ec62f1: xor cl, 0x5e + _0xffffff8000ec62f4: mov al, cl + _0xffffff8000ec62f6: add al, al + _0xffffff8000ec62f8: add cl, 0x2e + _0xffffff8000ec62fb: and al, 0x5c + _0xffffff8000ec62fd: sub cl, al + _0xffffff8000ec62ff: mov al, cl + _0xffffff8000ec6301: add al, al + _0xffffff8000ec6303: xor cl, 0xc0 + _0xffffff8000ec6306: and al, 0x80 + _0xffffff8000ec6308: add al, cl + _0xffffff8000ec630a: mov cl, dl + _0xffffff8000ec630c: xor cl, al + _0xffffff8000ec630e: mov sil, al + _0xffffff8000ec6311: and sil, dl + _0xffffff8000ec6314: mov dil, al + _0xffffff8000ec6317: and dil, 0xa1 + _0xffffff8000ec631b: mov r9b, dl + _0xffffff8000ec631e: and r9b, dil + _0xffffff8000ec6321: add r9b, r9b + _0xffffff8000ec6324: mov r10b, dl + _0xffffff8000ec6327: and r10b, 0xa1 + _0xffffff8000ec632b: or r10b, 0x42 + _0xffffff8000ec632f: sub r10b, dil + _0xffffff8000ec6332: and r10b, 0xa1 + _0xffffff8000ec6336: or r10b, r9b + _0xffffff8000ec6339: and cl, 0x54 + _0xffffff8000ec633c: and sil, 0x54 + _0xffffff8000ec6340: add sil, sil + _0xffffff8000ec6343: or sil, cl + _0xffffff8000ec6346: and dl, 0xa + _0xffffff8000ec6349: and al, 0xa + _0xffffff8000ec634b: add al, dl + _0xffffff8000ec634d: add al, sil + _0xffffff8000ec6350: add al, r10b + _0xffffff8000ec6353: movzx eax, al + _0xffffff8000ec6356: cmp al, 0x30 + _0xffffff8000ec6358: sbb rbx, rbx + _0xffffff8000ec635b: and ebx, 0x100 + _0xffffff8000ec6361: add ebx, eax + _0xffffff8000ec6363: add ebx, 0x15a5bf9b + _0xffffff8000ec6369: shl rbx, 0x20 + _0xffffff8000ec636d: movabs r15, 0xea5a403500000000 + _0xffffff8000ec6377: add r15, rbx + _0xffffff8000ec637a: mov rbx, r15 + _0xffffff8000ec637d: sar rbx, 0x1f + _0xffffff8000ec6381: movabs r13, 0xf97ecfd70d8bef8a + _0xffffff8000ec638b: and r13, rbx + _0xffffff8000ec638e: sar r15, 0x20 + _0xffffff8000ec6392: movabs rbx, 0x7cbf67eb86c5f7c5 + _0xffffff8000ec639c: xor rbx, r15 + _0xffffff8000ec639f: add rbx, r13 + _0xffffff8000ec63a2: add rbx, qword ptr [r14 + 0x448] + _0xffffff8000ec63a9: movabs r15, 0x83409814793a083b + _0xffffff8000ec63b3: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec63b7: mov byte ptr [r12 + 0xb], al + _0xffffff8000ec63bc: mov eax, dword ptr [rbp - 0x3b0] + _0xffffff8000ec63c2: shr eax, 0x17 + _0xffffff8000ec63c5: and eax, 0x2c + _0xffffff8000ec63c8: shr dword ptr [rbp - 0x3b0], 0x18 + _0xffffff8000ec63cf: add dword ptr [rbp - 0x3b0], 0x16 + _0xffffff8000ec63d6: sub dword ptr [rbp - 0x3b0], eax + _0xffffff8000ec63dc: xor dword ptr [rbp - 0x3b0], 0x16 + _0xffffff8000ec63e3: mov eax, dword ptr [rbp - 0x3b0] + _0xffffff8000ec63e9: mov cl, al + _0xffffff8000ec63eb: add cl, cl + _0xffffff8000ec63ed: xor al, 0x55 + _0xffffff8000ec63ef: and cl, 0xaa + _0xffffff8000ec63f2: add cl, al + _0xffffff8000ec63f4: add cl, 0xbf + _0xffffff8000ec63f7: movzx eax, cl + _0xffffff8000ec63fa: cmp al, 0x14 + _0xffffff8000ec63fc: sbb rbx, rbx + _0xffffff8000ec63ff: and ebx, 0x100 + _0xffffff8000ec6405: add ebx, eax + _0xffffff8000ec6407: add ebx, 0x17eb574e + _0xffffff8000ec640d: shl rbx, 0x20 + _0xffffff8000ec6411: movabs r15, 0xe814a89e00000000 + _0xffffff8000ec641b: add r15, rbx + _0xffffff8000ec641e: mov rbx, r15 + _0xffffff8000ec6421: sar rbx, 0x1f + _0xffffff8000ec6425: movabs r13, 0xff9dfffbfa63d6ae + _0xffffff8000ec642f: and r13, rbx + _0xffffff8000ec6432: sar r15, 0x20 + _0xffffff8000ec6436: movabs rbx, 0x7fcefffdfd31eb57 + _0xffffff8000ec6440: xor rbx, r15 + _0xffffff8000ec6443: add rbx, r13 + _0xffffff8000ec6446: add rbx, qword ptr [r14 + 0x428] + _0xffffff8000ec644d: movabs r15, 0x8031000202ce14a9 + _0xffffff8000ec6457: mov al, byte ptr [r15 + rbx] + _0xffffff8000ec645b: mov byte ptr [r12 + 7], al + _0xffffff8000ec6460: mov eax, r8d + _0xffffff8000ec6463: shr eax, 0x17 + _0xffffff8000ec6466: mov ecx, eax + _0xffffff8000ec6468: and ecx, 0xa + _0xffffff8000ec646b: shr r8d, 0x18 + _0xffffff8000ec646f: xor r8d, 5 + _0xffffff8000ec6473: add r8d, ecx + _0xffffff8000ec6476: mov ecx, eax + _0xffffff8000ec6478: or ecx, 8 + _0xffffff8000ec647b: xor ecx, 2 + _0xffffff8000ec647e: or eax, 2 + _0xffffff8000ec6481: xor eax, 8 + _0xffffff8000ec6484: and eax, ecx + _0xffffff8000ec6486: and eax, 0xa + _0xffffff8000ec6489: xor eax, 0xa + _0xffffff8000ec648c: neg eax + _0xffffff8000ec648e: mov ecx, r8d + _0xffffff8000ec6491: and ecx, eax + _0xffffff8000ec6493: xor eax, r8d + _0xffffff8000ec6496: lea eax, [rax + rcx*2] + _0xffffff8000ec6499: xor eax, 0xaa6c697d + _0xffffff8000ec649e: lea ecx, [rax*4] + _0xffffff8000ec64a5: and ecx, 0x40 + _0xffffff8000ec64a8: xor ecx, eax + _0xffffff8000ec64aa: mov esi, ecx + _0xffffff8000ec64ac: shr esi, 9 + _0xffffff8000ec64af: and esi, 0x10000 + _0xffffff8000ec64b5: xor esi, ecx + _0xffffff8000ec64b7: xor esi, 0xaa6d6938 + _0xffffff8000ec64bd: mov ecx, esi + _0xffffff8000ec64bf: and ecx, 0x2000000 + _0xffffff8000ec64c5: mov edi, 0xaaaaaaab + _0xffffff8000ec64ca: mov eax, ecx + _0xffffff8000ec64cc: mul edi + _0xffffff8000ec64ce: shr edx, 1 + _0xffffff8000ec64d0: lea eax, [rdx + rdx*2] + _0xffffff8000ec64d3: sub ecx, eax + _0xffffff8000ec64d5: imul ecx, ecx + _0xffffff8000ec64d8: mov eax, ecx + _0xffffff8000ec64da: mul edi + _0xffffff8000ec64dc: shr edx, 1 + _0xffffff8000ec64de: lea eax, [rdx + rdx*2] + _0xffffff8000ec64e1: sub ecx, eax + _0xffffff8000ec64e3: shl ecx, 0x10 + _0xffffff8000ec64e6: xor ecx, esi + _0xffffff8000ec64e8: mov esi, ecx + _0xffffff8000ec64ea: and esi, 0x10 + _0xffffff8000ec64ed: mov eax, esi + _0xffffff8000ec64ef: mul edi + _0xffffff8000ec64f1: shr edx, 1 + _0xffffff8000ec64f3: lea eax, [rdx + rdx*2] + _0xffffff8000ec64f6: sub esi, eax + _0xffffff8000ec64f8: imul esi, esi + _0xffffff8000ec64fb: mov eax, esi + _0xffffff8000ec64fd: mul edi + _0xffffff8000ec64ff: shr edx, 1 + _0xffffff8000ec6501: lea eax, [rdx + rdx*2] + _0xffffff8000ec6504: sub esi, eax + _0xffffff8000ec6506: shl esi, 6 + _0xffffff8000ec6509: xor esi, ecx + _0xffffff8000ec650b: mov al, sil + _0xffffff8000ec650e: add al, al + _0xffffff8000ec6510: mov cl, 0x76 + _0xffffff8000ec6512: sub cl, al + _0xffffff8000ec6514: mov dl, cl + _0xffffff8000ec6516: xor dl, al + _0xffffff8000ec6518: and cl, al + _0xffffff8000ec651a: add cl, cl + _0xffffff8000ec651c: add cl, dl + _0xffffff8000ec651e: and cl, al + _0xffffff8000ec6520: xor sil, 0x52 + _0xffffff8000ec6524: mov al, sil + _0xffffff8000ec6527: add al, al + _0xffffff8000ec6529: add sil, 0x56 + _0xffffff8000ec652d: and al, 0xac + _0xffffff8000ec652f: sub sil, al + _0xffffff8000ec6532: mov al, sil + _0xffffff8000ec6535: add al, al + _0xffffff8000ec6537: xor sil, 0xf2 + _0xffffff8000ec653b: and al, 0x9a + _0xffffff8000ec653d: xor al, 0x1a + _0xffffff8000ec653f: add al, sil + _0xffffff8000ec6542: mov dl, al + _0xffffff8000ec6544: xor dl, cl + _0xffffff8000ec6546: mov sil, cl + _0xffffff8000ec6549: and sil, al + _0xffffff8000ec654c: mov dil, cl + _0xffffff8000ec654f: and dil, 0x94 + _0xffffff8000ec6553: mov r8b, al + _0xffffff8000ec6556: and r8b, dil + _0xffffff8000ec6559: and cl, 0x48 + _0xffffff8000ec655c: mov r9b, al + _0xffffff8000ec655f: and r9b, cl + _0xffffff8000ec6562: mov r10b, al + _0xffffff8000ec6565: and r10b, 0x49 + _0xffffff8000ec6569: xor r10b, cl + _0xffffff8000ec656c: add r9b, r9b + _0xffffff8000ec656f: or r9b, r10b + _0xffffff8000ec6572: and dl, 0x22 + _0xffffff8000ec6575: and sil, 0x22 + _0xffffff8000ec6579: add sil, sil + _0xffffff8000ec657c: or sil, dl + _0xffffff8000ec657f: add sil, r9b + _0xffffff8000ec6582: add r8b, r8b + _0xffffff8000ec6585: and al, 0x94 + _0xffffff8000ec6587: or al, 0x28 + _0xffffff8000ec6589: sub al, dil + _0xffffff8000ec658c: and al, 0x94 + _0xffffff8000ec658e: or al, r8b + _0xffffff8000ec6591: add al, sil + _0xffffff8000ec6594: movzx eax, al + _0xffffff8000ec6597: cmp al, 8 + _0xffffff8000ec6599: sbb rbx, rbx + _0xffffff8000ec659c: and ebx, 0x100 + _0xffffff8000ec65a2: add ebx, eax + _0xffffff8000ec65a4: add ebx, 0x4f7b91ea + _0xffffff8000ec65aa: shl rbx, 0x20 + _0xffffff8000ec65ae: movabs r15, 0xb0846e0e00000000 + _0xffffff8000ec65b8: add r15, rbx + _0xffffff8000ec65bb: mov rbx, r15 + _0xffffff8000ec65be: sar rbx, 0x1f + _0xffffff8000ec65c2: movabs r13, 0x5ebdfb9c6cfa45f6 + _0xffffff8000ec65cc: and r13, rbx + _0xffffff8000ec65cf: sar r15, 0x20 + _0xffffff8000ec65d3: movabs rbx, 0x2f5efdce367d22fb + _0xffffff8000ec65dd: xor rbx, r15 + _0xffffff8000ec65e0: add rbx, r13 + _0xffffff8000ec65e3: add rbx, qword ptr [r14 + 0x488] + _0xffffff8000ec65ea: movabs r14, 0xd0a10231c982dd05 + _0xffffff8000ec65f4: mov al, byte ptr [r14 + rbx] + _0xffffff8000ec65f8: mov byte ptr [r12 + 3], al + _0xffffff8000ec65fd: mov rbx, qword ptr [rbp - 0x3f0] + _0xffffff8000ec6604: mov dword ptr [rbx + 4], 0 + _0xffffff8000ec660b: jmp _0xffffff8000ebfbfa + _0xffffff8000ec6610: lea rcx, [rip + jumptbl_0xffffff8000ec6610] + _0xffffff8000ec6617: movsxd rax, dword ptr [rcx + rax*4] + _0xffffff8000ec661b: add rax, rcx + _0xffffff8000ec661e: jmp rax + _0xffffff8000ec6620: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6627: mov rcx, qword ptr [rax] + _0xffffff8000ec662a: mov rdx, qword ptr [rax + 8] + _0xffffff8000ec662e: mov rdx, qword ptr [rdx + 8] + _0xffffff8000ec6632: mov rdx, qword ptr [rdx + 8] + _0xffffff8000ec6636: mov rsi, qword ptr [rdx] + _0xffffff8000ec6639: mov qword ptr [rcx + 8], rdx + _0xffffff8000ec663d: mov qword ptr [rsi + 8], rax + _0xffffff8000ec6641: mov qword ptr [rax], rsi + _0xffffff8000ec6644: mov qword ptr [rdx], rcx + _0xffffff8000ec6647: mov qword ptr [rbp - 0x340], rdx + _0xffffff8000ec664e: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ec6654: lea ecx, [rax - 0x2533d2d] + _0xffffff8000ec665a: lea edx, [rax + 0x20553082] + _0xffffff8000ec6660: lea eax, [rax + 0x20553079] + _0xffffff8000ec6666: mov rsi, qword ptr [rbp - 0x388] + _0xffffff8000ec666d: mov rdi, qword ptr [rsi] + _0xffffff8000ec6670: cmp rsi, qword ptr [rdi + 8] + _0xffffff8000ec6674: mov rsi, r14 + _0xffffff8000ec6677: cmove rsi, rbx + _0xffffff8000ec667b: mov esi, dword ptr [rsi] + _0xffffff8000ec667d: cmove eax, edx + _0xffffff8000ec6680: mov rdi, qword ptr [rbp - 0x3a0] + _0xffffff8000ec6687: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ec668e: mov dword ptr [r8], eax + _0xffffff8000ec6691: cmovne ecx, edx + _0xffffff8000ec6694: mov dword ptr [rdi], ecx + _0xffffff8000ec6696: mov dword ptr [rbp - 0x3a4], esi + _0xffffff8000ec669c: jmp _0xffffff8000ebea0e + _0xffffff8000ec66a1: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ec66a7: lea ecx, [rax - 0xf] + _0xffffff8000ec66aa: lea edx, [rax + 0x20553076] + _0xffffff8000ec66b0: lea esi, [rax + 0x20553078] + _0xffffff8000ec66b6: lea eax, [rax + 0x20553079] + _0xffffff8000ec66bc: cmp dword ptr [rbp - 0x2c], 2 + _0xffffff8000ec66c0: mov rdi, r14 + _0xffffff8000ec66c3: cmovl rdi, rbx + _0xffffff8000ec66c7: mov edi, dword ptr [rdi] + _0xffffff8000ec66c9: cmovl eax, esi + _0xffffff8000ec66cc: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ec66d3: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ec66da: mov dword ptr [r8], eax + _0xffffff8000ec66dd: cmovge ecx, edx + _0xffffff8000ec66e0: jmp _0xffffff8000ec6b5e + _0xffffff8000ec66e5: mov rdi, qword ptr [rbp - 0x3b0] + _0xffffff8000ec66ec: mov rax, qword ptr [rdi + 8] + _0xffffff8000ec66f0: lea rcx, [rbp - 0x108] + _0xffffff8000ec66f7: mov qword ptr [rbp - 0xb8], rcx + _0xffffff8000ec66fe: mov qword ptr [rbp - 0xb0], rcx + _0xffffff8000ec6705: mov qword ptr [rbp - 0xa8], rcx + _0xffffff8000ec670c: mov qword ptr [rbp - 0xa0], rcx + _0xffffff8000ec6713: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6717: mov qword ptr [rbp - 0x98], rcx + _0xffffff8000ec671e: mov rcx, qword ptr [rax + 0x10] + _0xffffff8000ec6722: mov qword ptr [rbp - 0x248], rcx + _0xffffff8000ec6729: mov eax, dword ptr [rax + 0x18] + _0xffffff8000ec672c: mov dword ptr [rbp - 0x90], eax + _0xffffff8000ec6732: mov rcx, qword ptr [rbp - 0x98] + _0xffffff8000ec6739: mov ecx, dword ptr [rcx + 0x10] + _0xffffff8000ec673c: mov dword ptr [rbp - 0x8c], ecx + _0xffffff8000ec6742: shl eax, 3 + _0xffffff8000ec6745: xor eax, 0x3bfcfffe + _0xffffff8000ec674a: add eax, ecx + _0xffffff8000ec674c: mov ecx, dword ptr [rbp - 0x90] + _0xffffff8000ec6752: shl ecx, 4 + _0xffffff8000ec6755: and ecx, 0x77f9fff0 + _0xffffff8000ec675b: lea eax, [rcx + rax - 0x3bfcfffe] + _0xffffff8000ec6762: mov rcx, qword ptr [rbp - 0x98] + _0xffffff8000ec6769: mov dword ptr [rcx + 0x10], eax + _0xffffff8000ec676c: cmp eax, dword ptr [rbp - 0x8c] + _0xffffff8000ec6772: sbb al, al + _0xffffff8000ec6774: and al, 1 + _0xffffff8000ec6776: mov byte ptr [rbp - 0x1b1], al + _0xffffff8000ec677c: mov qword ptr [rbp - 0x380], r15 + _0xffffff8000ec6783: mov qword ptr [rbp - 0x308], r15 + _0xffffff8000ec678a: mov qword ptr [rbp - 0x300], r15 + _0xffffff8000ec6791: mov rax, qword ptr [rbp - 0x380] + _0xffffff8000ec6798: mov qword ptr [rbp - 0x2f8], rax + _0xffffff8000ec679f: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec67a3: mov qword ptr [rbp - 0x2f0], rcx + _0xffffff8000ec67aa: mov qword ptr [rcx], r12 + _0xffffff8000ec67ad: mov qword ptr [rax + 8], r12 + _0xffffff8000ec67b1: lea rax, [rbp - 0x2e8] + _0xffffff8000ec67b8: mov qword ptr [rbp - 0x88], rax + _0xffffff8000ec67bf: mov qword ptr [rbp - 0x378], rax + _0xffffff8000ec67c6: mov rax, qword ptr [rbp - 0x88] + _0xffffff8000ec67cd: mov qword ptr [rbp - 0x1a8], rax + _0xffffff8000ec67d4: lea rcx, [rax + 8] + _0xffffff8000ec67d8: mov qword ptr [rbp - 0x1a0], rcx + _0xffffff8000ec67df: mov qword ptr [rax], rax + _0xffffff8000ec67e2: mov rax, qword ptr [rbp - 0x1a0] + _0xffffff8000ec67e9: mov rcx, qword ptr [rbp - 0x88] + _0xffffff8000ec67f0: mov qword ptr [rax], rcx + _0xffffff8000ec67f3: mov rax, qword ptr [rbp - 0x380] + _0xffffff8000ec67fa: mov qword ptr [rbp - 0x2d8], rax + _0xffffff8000ec6801: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6805: mov qword ptr [rbp - 0x2d0], rcx + _0xffffff8000ec680c: lea rdx, [rbp - 0x2d8] + _0xffffff8000ec6813: mov qword ptr [rcx], rdx + _0xffffff8000ec6816: mov qword ptr [rax + 8], rdx + _0xffffff8000ec681a: lea rax, [rbp - 0x2c8] + _0xffffff8000ec6821: mov qword ptr [rbp - 0x388], rax + _0xffffff8000ec6828: mov qword ptr [rbp - 0x2c8], rax + _0xffffff8000ec682f: mov qword ptr [rbp - 0x2c0], rax + _0xffffff8000ec6836: lea rax, [rbp - 0x2b8] + _0xffffff8000ec683d: mov qword ptr [rbp - 0x80], rax + _0xffffff8000ec6841: mov qword ptr [rbp - 0x190], rax + _0xffffff8000ec6848: mov qword ptr [rbp - 0x188], r13 + _0xffffff8000ec684f: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6856: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ec685d: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6861: mov rdx, qword ptr [rbp - 0x80] + _0xffffff8000ec6865: mov qword ptr [rdx + 8], rcx + _0xffffff8000ec6869: mov rdx, qword ptr [rbp - 0x80] + _0xffffff8000ec686d: mov qword ptr [rcx], rdx + _0xffffff8000ec6870: mov rcx, qword ptr [rbp - 0x80] + _0xffffff8000ec6874: mov qword ptr [rax + 8], rcx + _0xffffff8000ec6878: mov rax, qword ptr [rbp - 0x378] + _0xffffff8000ec687f: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ec6886: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec688a: mov qword ptr [rbp - 0x2a0], rcx + _0xffffff8000ec6891: lea rdx, [rbp - 0x2a8] + _0xffffff8000ec6898: mov qword ptr [rcx], rdx + _0xffffff8000ec689b: mov qword ptr [rax + 8], rdx + _0xffffff8000ec689f: lea rax, [rbp - 0x298] + _0xffffff8000ec68a6: mov qword ptr [rbp - 0x1b0], rax + _0xffffff8000ec68ad: mov qword ptr [rbp - 0x178], rax + _0xffffff8000ec68b4: mov qword ptr [rbp - 0x170], r13 + _0xffffff8000ec68bb: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec68c2: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ec68c9: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec68cd: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ec68d4: mov qword ptr [rdx + 8], rcx + _0xffffff8000ec68d8: mov rdx, qword ptr [rbp - 0x1b0] + _0xffffff8000ec68df: mov qword ptr [rcx], rdx + _0xffffff8000ec68e2: mov rcx, qword ptr [rbp - 0x1b0] + _0xffffff8000ec68e9: mov qword ptr [rax + 8], rcx + _0xffffff8000ec68ed: lea rax, [rbp - 0x288] + _0xffffff8000ec68f4: mov qword ptr [rbp - 0x310], rax + _0xffffff8000ec68fb: mov qword ptr [rbp - 0x288], rax + _0xffffff8000ec6902: mov qword ptr [rbp - 0x280], rax + _0xffffff8000ec6909: lea rax, [rbp - 0x278] + _0xffffff8000ec6910: mov qword ptr [rbp - 0x198], rax + _0xffffff8000ec6917: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec691e: mov qword ptr [rbp - 0x278], rax + _0xffffff8000ec6925: lea rcx, [rax + 8] + _0xffffff8000ec6929: mov qword ptr [rbp - 0x160], rcx + _0xffffff8000ec6930: mov rax, qword ptr [rax + 8] + _0xffffff8000ec6934: mov qword ptr [rbp - 0x180], rax + _0xffffff8000ec693b: mov rcx, qword ptr [rbp - 0x198] + _0xffffff8000ec6942: lea rdx, [rcx + 8] + _0xffffff8000ec6946: mov qword ptr [rbp - 0x158], rdx + _0xffffff8000ec694d: mov qword ptr [rcx + 8], rax + _0xffffff8000ec6951: mov rax, qword ptr [rbp - 0x198] + _0xffffff8000ec6958: mov rcx, qword ptr [rbp - 0x180] + _0xffffff8000ec695f: mov qword ptr [rcx], rax + _0xffffff8000ec6962: mov rax, qword ptr [rbp - 0x160] + _0xffffff8000ec6969: mov rcx, qword ptr [rbp - 0x198] + _0xffffff8000ec6970: mov qword ptr [rax], rcx + _0xffffff8000ec6973: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec697a: mov qword ptr [rbp - 0x268], rax + _0xffffff8000ec6981: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6985: mov qword ptr [rbp - 0x260], rcx + _0xffffff8000ec698c: lea rdx, [rbp - 0x268] + _0xffffff8000ec6993: mov qword ptr [rcx], rdx + _0xffffff8000ec6996: mov qword ptr [rax + 8], rdx + _0xffffff8000ec699a: lea rax, [rbp - 0x258] + _0xffffff8000ec69a1: mov qword ptr [rbp - 0x168], rax + _0xffffff8000ec69a8: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec69af: mov qword ptr [rbp - 0x258], rax + _0xffffff8000ec69b6: lea rcx, [rax + 8] + _0xffffff8000ec69ba: mov qword ptr [rbp - 0x148], rcx + _0xffffff8000ec69c1: mov rax, qword ptr [rax + 8] + _0xffffff8000ec69c5: mov qword ptr [rbp - 0x150], rax + _0xffffff8000ec69cc: mov rcx, qword ptr [rbp - 0x168] + _0xffffff8000ec69d3: lea rdx, [rcx + 8] + _0xffffff8000ec69d7: mov qword ptr [rbp - 0x140], rdx + _0xffffff8000ec69de: mov qword ptr [rcx + 8], rax + _0xffffff8000ec69e2: mov rax, qword ptr [rbp - 0x168] + _0xffffff8000ec69e9: mov rcx, qword ptr [rbp - 0x150] + _0xffffff8000ec69f0: mov qword ptr [rcx], rax + _0xffffff8000ec69f3: mov rax, qword ptr [rbp - 0x148] + _0xffffff8000ec69fa: mov rcx, qword ptr [rbp - 0x168] + _0xffffff8000ec6a01: mov qword ptr [rax], rcx + _0xffffff8000ec6a04: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ec6a0a: lea ecx, [rax - 0x2fc8c894] + _0xffffff8000ec6a10: lea edx, [rax - 5] + _0xffffff8000ec6a13: lea esi, [rax + 0xdb518d9] + _0xffffff8000ec6a19: lea eax, [rax + 0xdb518db] + _0xffffff8000ec6a1f: mov dil, byte ptr [rbp - 0x1b1] + _0xffffff8000ec6a26: test dil, dil + _0xffffff8000ec6a29: mov rdi, r14 + _0xffffff8000ec6a2c: cmovne rdi, rbx + _0xffffff8000ec6a30: mov edi, dword ptr [rdi] + _0xffffff8000ec6a32: cmovne eax, esi + _0xffffff8000ec6a35: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ec6a3c: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ec6a43: mov dword ptr [r8], eax + _0xffffff8000ec6a46: cmovne edx, ecx + _0xffffff8000ec6a49: mov dword ptr [rsi], edx + _0xffffff8000ec6a4b: mov dword ptr [rbp - 0x3a4], edi + _0xffffff8000ec6a51: je _0xffffff8000ebe6bb + _0xffffff8000ec6a57: jmp _0xffffff8000ebee14 + _0xffffff8000ec6a5c: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ec6a62: lea ecx, [rax + 0x2055307a] + _0xffffff8000ec6a68: lea edx, [rax - 0xa] + _0xffffff8000ec6a6b: cmp dword ptr [rbp - 0x2c], 3 + _0xffffff8000ec6a6f: mov rsi, r14 + _0xffffff8000ec6a72: cmovl rsi, rbx + _0xffffff8000ec6a76: mov esi, dword ptr [rsi] + _0xffffff8000ec6a78: cmovl edx, ecx + _0xffffff8000ec6a7b: mov rcx, qword ptr [rbp - 0x3a0] + _0xffffff8000ec6a82: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ec6a89: mov dword ptr [rdi], edx + _0xffffff8000ec6a8b: add eax, -9 + _0xffffff8000ec6a8e: mov dword ptr [rcx], eax + _0xffffff8000ec6a90: mov dword ptr [rbp - 0x3a4], esi + _0xffffff8000ec6a96: jmp _0xffffff8000ebea0e + _0xffffff8000ec6a9b: mov rax, qword ptr [rbp - 0x78] + _0xffffff8000ec6a9f: mov rcx, rax + _0xffffff8000ec6aa2: movabs rdx, 0x6bfdeeea5d9e7fef + _0xffffff8000ec6aac: xor rcx, rdx + _0xffffff8000ec6aaf: mov rdx, rax + _0xffffff8000ec6ab2: and rdx, 0x5d9e7fef + _0xffffff8000ec6ab9: lea rcx, [rcx + rdx*2] + _0xffffff8000ec6abd: add rcx, qword ptr [rbp - 0x98] + _0xffffff8000ec6ac4: movabs rdx, 0x94021115a2618029 + _0xffffff8000ec6ace: mov byte ptr [rdx + rcx], 0 + _0xffffff8000ec6ad2: inc rax + _0xffffff8000ec6ad5: mov qword ptr [rbp - 0x78], rax + _0xffffff8000ec6ad9: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ec6adf: lea edx, [rcx - 0x243503c2] + _0xffffff8000ec6ae5: lea esi, [rcx + 0x30d26683] + _0xffffff8000ec6aeb: lea edi, [rcx - 9] + _0xffffff8000ec6aee: cmp rax, 0x40 + _0xffffff8000ec6af2: mov rax, r14 + _0xffffff8000ec6af5: cmove rax, rbx + _0xffffff8000ec6af9: mov eax, dword ptr [rax] + _0xffffff8000ec6afb: cmove edi, esi + _0xffffff8000ec6afe: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ec6b05: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ec6b0c: mov dword ptr [r8], edi + _0xffffff8000ec6b0f: cmovne edx, ecx + _0xffffff8000ec6b12: mov dword ptr [rsi], edx + _0xffffff8000ec6b14: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ec6b1a: jmp _0xffffff8000ebea0e + _0xffffff8000ec6b1f: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ec6b25: lea ecx, [rax - 0x8efc023] + _0xffffff8000ec6b2b: lea edx, [rax + 0x4bc49e2] + _0xffffff8000ec6b31: lea esi, [rax + 0x36f76e5e] + _0xffffff8000ec6b37: lea eax, [rax - 7] + _0xffffff8000ec6b3a: cmp dword ptr [rbp - 0x2c], 3 + _0xffffff8000ec6b3e: mov rdi, r14 + _0xffffff8000ec6b41: cmove rdi, rbx + _0xffffff8000ec6b45: mov edi, dword ptr [rdi] + _0xffffff8000ec6b47: cmove eax, esi + _0xffffff8000ec6b4a: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ec6b51: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ec6b58: mov dword ptr [r8], eax + _0xffffff8000ec6b5b: cmovne ecx, edx + _0xffffff8000ec6b5e: mov dword ptr [rsi], ecx + _0xffffff8000ec6b60: mov dword ptr [rbp - 0x3a4], edi + _0xffffff8000ec6b66: jmp _0xffffff8000ebee14 + _0xffffff8000ec6b6b: mov rdi, qword ptr [rbp - 0x3b0] + _0xffffff8000ec6b72: mov rax, qword ptr [rdi + 8] + _0xffffff8000ec6b76: lea rcx, [rbp - 0x230] + _0xffffff8000ec6b7d: mov qword ptr [rbp - 0x1f8], rcx + _0xffffff8000ec6b84: mov qword ptr [rbp - 0x1f0], rcx + _0xffffff8000ec6b8b: mov qword ptr [rbp - 0x1e8], rcx + _0xffffff8000ec6b92: mov qword ptr [rbp - 0x248], rax + _0xffffff8000ec6b99: mov qword ptr [rbp - 0x1e0], rax + _0xffffff8000ec6ba0: mov rcx, qword ptr [rax + 0x20] + _0xffffff8000ec6ba4: mov qword ptr [rbp - 0x1d8], rcx + _0xffffff8000ec6bab: mov rax, qword ptr [rax + 8] + _0xffffff8000ec6baf: mov qword ptr [rbp - 0x1d0], rax + _0xffffff8000ec6bb6: mov rax, qword ptr [rbp - 0x1e0] + _0xffffff8000ec6bbd: mov rcx, qword ptr [rax + 0x10] + _0xffffff8000ec6bc1: mov qword ptr [rbp - 0x1c8], rcx + _0xffffff8000ec6bc8: mov rax, qword ptr [rax + 0x18] + _0xffffff8000ec6bcc: mov qword ptr [rbp - 0x1c0], rax + _0xffffff8000ec6bd3: mov dword ptr [rbp - 0x234], 0x2fc2e0a1 + _0xffffff8000ec6bdd: mov dword ptr [rbp - 0x23c], 0 + _0xffffff8000ec6be7: mov dword ptr [rbp - 0x238], 0x792bf512 + _0xffffff8000ec6bf1: cmp qword ptr [rbp - 0x1d0], 0 + _0xffffff8000ec6bf9: sete al + _0xffffff8000ec6bfc: cmp qword ptr [rbp - 0x1d8], 0 + _0xffffff8000ec6c04: sete cl + _0xffffff8000ec6c07: or cl, al + _0xffffff8000ec6c09: cmp qword ptr [rbp - 0x1c8], 0 + _0xffffff8000ec6c11: sete al + _0xffffff8000ec6c14: or al, cl + _0xffffff8000ec6c16: cmp qword ptr [rbp - 0x1c0], 0 + _0xffffff8000ec6c1e: sete cl + _0xffffff8000ec6c21: or cl, al + _0xffffff8000ec6c23: mov byte ptr [rbp - 0x1b1], cl + _0xffffff8000ec6c29: mov qword ptr [rbp - 0x380], r15 + _0xffffff8000ec6c30: mov qword ptr [rbp - 0x308], r15 + _0xffffff8000ec6c37: mov qword ptr [rbp - 0x300], r15 + _0xffffff8000ec6c3e: mov qword ptr [rbp - 0x388], r12 + _0xffffff8000ec6c45: mov qword ptr [rbp - 0x2f8], r12 + _0xffffff8000ec6c4c: mov qword ptr [rbp - 0x2f0], r12 + _0xffffff8000ec6c53: lea rax, [rbp - 0x2e8] + _0xffffff8000ec6c5a: mov qword ptr [rbp - 0x1b0], rax + _0xffffff8000ec6c61: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6c68: mov qword ptr [rbp - 0x2e8], rax + _0xffffff8000ec6c6f: lea rcx, [rax + 8] + _0xffffff8000ec6c73: mov qword ptr [rbp - 0x1a8], rcx + _0xffffff8000ec6c7a: mov rax, qword ptr [rax + 8] + _0xffffff8000ec6c7e: mov rcx, qword ptr [rbp - 0x1b0] + _0xffffff8000ec6c85: mov qword ptr [rcx + 8], rax + _0xffffff8000ec6c89: mov qword ptr [rbp - 0x1a0], rax + _0xffffff8000ec6c90: mov rcx, qword ptr [rbp - 0x1b0] + _0xffffff8000ec6c97: mov qword ptr [rax], rcx + _0xffffff8000ec6c9a: mov rax, qword ptr [rbp - 0x1a8] + _0xffffff8000ec6ca1: mov rcx, qword ptr [rbp - 0x1b0] + _0xffffff8000ec6ca8: mov qword ptr [rax], rcx + _0xffffff8000ec6cab: lea rax, [rbp - 0x2d8] + _0xffffff8000ec6cb2: mov qword ptr [rbp - 0x310], rax + _0xffffff8000ec6cb9: mov qword ptr [rbp - 0x2d8], rax + _0xffffff8000ec6cc0: mov qword ptr [rbp - 0x2d0], rax + _0xffffff8000ec6cc7: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6cce: mov qword ptr [rbp - 0x2c8], rax + _0xffffff8000ec6cd5: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6cd9: mov qword ptr [rbp - 0x2c0], rcx + _0xffffff8000ec6ce0: lea rdx, [rbp - 0x2c8] + _0xffffff8000ec6ce7: mov qword ptr [rcx], rdx + _0xffffff8000ec6cea: mov qword ptr [rax + 8], rdx + _0xffffff8000ec6cee: lea rax, [rbp - 0x2b8] + _0xffffff8000ec6cf5: mov qword ptr [rbp - 0x198], rax + _0xffffff8000ec6cfc: mov qword ptr [rbp - 0x190], rax + _0xffffff8000ec6d03: mov qword ptr [rbp - 0x188], r13 + _0xffffff8000ec6d0a: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6d11: mov qword ptr [rbp - 0x2b8], rax + _0xffffff8000ec6d18: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6d1c: mov rdx, qword ptr [rbp - 0x198] + _0xffffff8000ec6d23: mov qword ptr [rdx + 8], rcx + _0xffffff8000ec6d27: mov rdx, qword ptr [rbp - 0x198] + _0xffffff8000ec6d2e: mov qword ptr [rcx], rdx + _0xffffff8000ec6d31: mov rcx, qword ptr [rbp - 0x198] + _0xffffff8000ec6d38: mov qword ptr [rax + 8], rcx + _0xffffff8000ec6d3c: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6d43: mov qword ptr [rbp - 0x2a8], rax + _0xffffff8000ec6d4a: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6d4e: mov qword ptr [rbp - 0x2a0], rcx + _0xffffff8000ec6d55: lea rdx, [rbp - 0x2a8] + _0xffffff8000ec6d5c: mov qword ptr [rcx], rdx + _0xffffff8000ec6d5f: mov qword ptr [rax + 8], rdx + _0xffffff8000ec6d63: lea rax, [rbp - 0x298] + _0xffffff8000ec6d6a: mov qword ptr [rbp - 0x180], rax + _0xffffff8000ec6d71: mov qword ptr [rbp - 0x178], rax + _0xffffff8000ec6d78: mov qword ptr [rbp - 0x170], r13 + _0xffffff8000ec6d7f: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6d86: mov qword ptr [rbp - 0x298], rax + _0xffffff8000ec6d8d: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6d91: mov rdx, qword ptr [rbp - 0x180] + _0xffffff8000ec6d98: mov qword ptr [rdx + 8], rcx + _0xffffff8000ec6d9c: mov rdx, qword ptr [rbp - 0x180] + _0xffffff8000ec6da3: mov qword ptr [rcx], rdx + _0xffffff8000ec6da6: mov rcx, qword ptr [rbp - 0x180] + _0xffffff8000ec6dad: mov qword ptr [rax + 8], rcx + _0xffffff8000ec6db1: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6db8: mov qword ptr [rbp - 0x288], rax + _0xffffff8000ec6dbf: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6dc3: mov qword ptr [rbp - 0x280], rcx + _0xffffff8000ec6dca: lea rdx, [rbp - 0x288] + _0xffffff8000ec6dd1: mov qword ptr [rcx], rdx + _0xffffff8000ec6dd4: mov qword ptr [rax + 8], rdx + _0xffffff8000ec6dd8: lea rax, [rbp - 0x278] + _0xffffff8000ec6ddf: mov qword ptr [rbp - 0x168], rax + _0xffffff8000ec6de6: mov qword ptr [rbp - 0x160], rax + _0xffffff8000ec6ded: mov qword ptr [rbp - 0x158], r13 + _0xffffff8000ec6df4: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6dfb: mov qword ptr [rbp - 0x278], rax + _0xffffff8000ec6e02: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6e06: mov rdx, qword ptr [rbp - 0x168] + _0xffffff8000ec6e0d: mov qword ptr [rdx + 8], rcx + _0xffffff8000ec6e11: mov rdx, qword ptr [rbp - 0x168] + _0xffffff8000ec6e18: mov qword ptr [rcx], rdx + _0xffffff8000ec6e1b: mov rcx, qword ptr [rbp - 0x168] + _0xffffff8000ec6e22: mov qword ptr [rax + 8], rcx + _0xffffff8000ec6e26: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6e2d: mov qword ptr [rbp - 0x268], rax + _0xffffff8000ec6e34: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6e38: mov qword ptr [rbp - 0x260], rcx + _0xffffff8000ec6e3f: lea rdx, [rbp - 0x268] + _0xffffff8000ec6e46: mov qword ptr [rcx], rdx + _0xffffff8000ec6e49: mov qword ptr [rax + 8], rdx + _0xffffff8000ec6e4d: lea rax, [rbp - 0x258] + _0xffffff8000ec6e54: mov qword ptr [rbp - 0x150], rax + _0xffffff8000ec6e5b: mov qword ptr [rbp - 0x148], rax + _0xffffff8000ec6e62: mov qword ptr [rbp - 0x140], r13 + _0xffffff8000ec6e69: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec6e70: mov qword ptr [rbp - 0x258], rax + _0xffffff8000ec6e77: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec6e7b: mov rdx, qword ptr [rbp - 0x150] + _0xffffff8000ec6e82: mov qword ptr [rdx + 8], rcx + _0xffffff8000ec6e86: mov rdx, qword ptr [rbp - 0x150] + _0xffffff8000ec6e8d: mov qword ptr [rcx], rdx + _0xffffff8000ec6e90: mov rcx, qword ptr [rbp - 0x150] + _0xffffff8000ec6e97: mov qword ptr [rax + 8], rcx + _0xffffff8000ec6e9b: mov dword ptr [rbp - 0x1fc], 0xffff586c + _0xffffff8000ec6ea5: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ec6eab: lea ecx, [rax + 0x2001b116] + _0xffffff8000ec6eb1: lea edx, [rax - 0x20553071] + _0xffffff8000ec6eb7: lea esi, [rax - 0x4534ff68] + _0xffffff8000ec6ebd: lea eax, [rax - 0x20553080] + _0xffffff8000ec6ec3: mov dil, byte ptr [rbp - 0x1b1] + _0xffffff8000ec6eca: test dil, dil + _0xffffff8000ec6ecd: mov rdi, r14 + _0xffffff8000ec6ed0: cmovne rdi, rbx + _0xffffff8000ec6ed4: mov edi, dword ptr [rdi] + _0xffffff8000ec6ed6: cmovne eax, esi + _0xffffff8000ec6ed9: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ec6ee0: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ec6ee7: mov dword ptr [r8], eax + _0xffffff8000ec6eea: cmovne edx, ecx + _0xffffff8000ec6eed: mov dword ptr [rsi], edx + _0xffffff8000ec6eef: jmp _0xffffff8000ebe719 + _0xffffff8000ec6ef4: mov eax, dword ptr [rbp - 0x1fc] + _0xffffff8000ec6efa: add eax, -8 + _0xffffff8000ec6efd: mov ecx, eax + _0xffffff8000ec6eff: and ecx, 0x76ffbfde + _0xffffff8000ec6f05: xor eax, 0x76ffbfde + _0xffffff8000ec6f0a: lea edx, [rax + rcx*2 - 0x76ffbfdf] + _0xffffff8000ec6f11: inc rdx + _0xffffff8000ec6f14: lea eax, [rax + rcx*2 - 0x76ffbfde] + _0xffffff8000ec6f1b: cmp eax, 1 + _0xffffff8000ec6f1e: mov eax, 1 + _0xffffff8000ec6f23: cmova rax, rdx + _0xffffff8000ec6f27: mov qword ptr [rbp - 0x130], rax + _0xffffff8000ec6f2e: mov qword ptr [rbp - 0x60], 0 + _0xffffff8000ec6f36: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ec6f3c: lea ecx, [rax + 9] + _0xffffff8000ec6f3f: mov edx, dword ptr [rbp - 0x38c] + _0xffffff8000ec6f45: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ec6f4c: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ec6f53: mov dword ptr [rdi], ecx + _0xffffff8000ec6f55: add eax, 0x2e0a4957 + _0xffffff8000ec6f5a: jmp _0xffffff8000ebe848 + _0xffffff8000ec6f5f: mov rax, qword ptr [rbp - 0xc0] + _0xffffff8000ec6f66: mov rcx, rax + _0xffffff8000ec6f69: movabs rdx, 0x5fbcfb6bf9d09bfd + _0xffffff8000ec6f73: xor rcx, rdx + _0xffffff8000ec6f76: lea rdx, [rax + rax] + _0xffffff8000ec6f7a: mov rsi, rdx + _0xffffff8000ec6f7d: movabs rdi, 0x1f3a137fa + _0xffffff8000ec6f87: and rsi, rdi + _0xffffff8000ec6f8a: add rsi, rcx + _0xffffff8000ec6f8d: add rsi, qword ptr [rbp - 0x98] + _0xffffff8000ec6f94: movabs rcx, 0xa0430494062f6403 + _0xffffff8000ec6f9e: mov cl, byte ptr [rcx + rsi] + _0xffffff8000ec6fa1: mov rsi, rax + _0xffffff8000ec6fa4: movabs rdi, 0x2bfbff7c79b7ff7c + _0xffffff8000ec6fae: xor rsi, rdi + _0xffffff8000ec6fb1: and edx, 0xf36ffef8 + _0xffffff8000ec6fb7: add rdx, rsi + _0xffffff8000ec6fba: add rdx, qword ptr [rbp - 0x1d0] + _0xffffff8000ec6fc1: movabs rsi, 0xd404008386480084 + _0xffffff8000ec6fcb: mov byte ptr [rsi + rdx], cl + _0xffffff8000ec6fce: inc rax + _0xffffff8000ec6fd1: mov qword ptr [rbp - 0xc0], rax + _0xffffff8000ec6fd8: mov ecx, dword ptr [rbp - 0x3a4] + _0xffffff8000ec6fde: lea edx, [rcx - 0xdb518de] + _0xffffff8000ec6fe4: lea esi, [rcx - 0xdb518e3] + _0xffffff8000ec6fea: lea edi, [rcx - 0x2e0a4960] + _0xffffff8000ec6ff0: cmp rax, 0x10 + _0xffffff8000ec6ff4: mov rax, r14 + _0xffffff8000ec6ff7: cmove rax, rbx + _0xffffff8000ec6ffb: mov eax, dword ptr [rax] + _0xffffff8000ec6ffd: cmove edi, esi + _0xffffff8000ec7000: mov rsi, qword ptr [rbp - 0x3a0] + _0xffffff8000ec7007: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ec700e: mov dword ptr [r8], edi + _0xffffff8000ec7011: cmovne edx, ecx + _0xffffff8000ec7014: mov dword ptr [rsi], edx + _0xffffff8000ec7016: jmp _0xffffff8000ebf46e + _0xffffff8000ec701b: mov rax, qword ptr [rbp - 0x70] + _0xffffff8000ec701f: movabs rcx, 0x7be0dfffdefffbcf + _0xffffff8000ec7029: xor rcx, rax + _0xffffff8000ec702c: add rcx, qword ptr [rbp - 0x58] + _0xffffff8000ec7030: mov edx, eax + _0xffffff8000ec7032: and edx, 0xdefffbcf + _0xffffff8000ec7038: lea rcx, [rcx + rdx*2] + _0xffffff8000ec703c: add rcx, qword ptr [rbp - 0x98] + _0xffffff8000ec7043: movabs rdx, 0x5e112090433a94eb + _0xffffff8000ec704d: mov byte ptr [rdx + rcx], 0 + _0xffffff8000ec7051: inc rax + _0xffffff8000ec7054: mov rcx, qword ptr [rbp - 0x138] + _0xffffff8000ec705b: mov qword ptr [rbp - 0x70], rax + _0xffffff8000ec705f: mov edx, dword ptr [rbp - 0x3a4] + _0xffffff8000ec7065: lea esi, [rdx - 0x5a5ac01] + _0xffffff8000ec706b: lea edi, [rdx + 0x2e0a495f] + _0xffffff8000ec7071: lea r8d, [rdx + 5] + _0xffffff8000ec7075: cmp rax, rcx + _0xffffff8000ec7078: mov rax, r14 + _0xffffff8000ec707b: cmove rax, rbx + _0xffffff8000ec707f: mov eax, dword ptr [rax] + _0xffffff8000ec7081: cmovne r8d, edi + _0xffffff8000ec7085: mov rcx, qword ptr [rbp - 0x3a0] + _0xffffff8000ec708c: mov rdi, qword ptr [rbp - 0x398] + _0xffffff8000ec7093: mov dword ptr [rdi], r8d + _0xffffff8000ec7096: cmovne esi, edx + _0xffffff8000ec7099: mov dword ptr [rcx], esi + _0xffffff8000ec709b: mov dword ptr [rbp - 0x3a4], eax + _0xffffff8000ec70a1: je _0xffffff8000ebe6bb + _0xffffff8000ec70a7: jmp _0xffffff8000ebee14 + _0xffffff8000ec70ac: mov rax, qword ptr [rbp - 0x128] + _0xffffff8000ec70b3: mov qword ptr [rbp - 0x58], rax + _0xffffff8000ec70b7: mov ecx, eax + _0xffffff8000ec70b9: shl ecx, 6 + _0xffffff8000ec70bc: mov edx, dword ptr [rbp - 0x60] + _0xffffff8000ec70bf: sub edx, ecx + _0xffffff8000ec70c1: mov dword ptr [rbp - 0x200], edx + _0xffffff8000ec70c7: shl rax, 6 + _0xffffff8000ec70cb: mov qword ptr [rbp - 0x138], rax + _0xffffff8000ec70d2: mov rax, qword ptr [rbp - 0x378] + _0xffffff8000ec70d9: mov rcx, qword ptr [rbp - 0x310] + _0xffffff8000ec70e0: mov rdx, qword ptr [rax + 8] + _0xffffff8000ec70e4: mov qword ptr [rcx + 8], rdx + _0xffffff8000ec70e8: mov qword ptr [rcx], rax + _0xffffff8000ec70eb: mov qword ptr [rax + 8], rcx + _0xffffff8000ec70ef: mov qword ptr [rdx], rcx + _0xffffff8000ec70f2: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ec70f8: lea ecx, [rax - 0x2f9260ab] + _0xffffff8000ec70fe: lea edx, [rax + 4] + _0xffffff8000ec7101: lea eax, [rax + 0xdb518de] + _0xffffff8000ec7107: mov rsi, qword ptr [rbp - 0x388] + _0xffffff8000ec710e: cmp rsi, qword ptr [rsi + 8] + _0xffffff8000ec7112: mov rsi, r14 + _0xffffff8000ec7115: cmove rsi, rbx + _0xffffff8000ec7119: mov esi, dword ptr [rsi] + _0xffffff8000ec711b: cmovne edx, eax + _0xffffff8000ec711e: mov rdi, qword ptr [rbp - 0x3a0] + _0xffffff8000ec7125: mov r8, qword ptr [rbp - 0x398] + _0xffffff8000ec712c: mov dword ptr [r8], edx + _0xffffff8000ec712f: cmove ecx, eax + _0xffffff8000ec7132: mov dword ptr [rdi], ecx + _0xffffff8000ec7134: mov dword ptr [rbp - 0x3a4], esi + _0xffffff8000ec713a: je _0xffffff8000ebe6bb + _0xffffff8000ec7140: jmp _0xffffff8000ebea0e + _0xffffff8000ec7145: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ec714c: add rax, 0x18 + _0xffffff8000ec7150: mov rcx, qword ptr [rbp - 0x48] + _0xffffff8000ec7154: mov qword ptr [rcx + 8], rax + _0xffffff8000ec7158: mov rax, qword ptr [rbp - 0x48] + _0xffffff8000ec715c: mov dword ptr [rax + 4], 0xe + _0xffffff8000ec7163: mov rdi, qword ptr [rbp - 0x48] + _0xffffff8000ec7167: mov dword ptr [rbp - 0x238], 0 + _0xffffff8000ec7171: mov dword ptr [rdi], 0x1d + _0xffffff8000ec7177: call sub_0xffffff8000eb7d00 + _0xffffff8000ec717c: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ec7183: mov ecx, dword ptr [rax + 0x10] + _0xffffff8000ec7186: mov rdx, qword ptr [rbp - 0xa8] + _0xffffff8000ec718d: mov dword ptr [rdx + 4], ecx + _0xffffff8000ec7190: add rax, 0x50 + _0xffffff8000ec7194: mov rcx, qword ptr [rbp - 0xa8] + _0xffffff8000ec719b: mov qword ptr [rcx + 8], rax + _0xffffff8000ec719f: mov rdi, qword ptr [rbp - 0xa8] + _0xffffff8000ec71a6: mov dword ptr [rbp - 0x238], 0 + _0xffffff8000ec71b0: mov dword ptr [rdi], 0xfffffffd + _0xffffff8000ec71b6: call sub_0xffffff8000eb7d00 + _0xffffff8000ec71bb: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ec71c2: mov ecx, dword ptr [rax + 0x14] + _0xffffff8000ec71c5: mov rdx, qword ptr [rbp - 0xa0] + _0xffffff8000ec71cc: mov dword ptr [rdx + 4], ecx + _0xffffff8000ec71cf: add rax, 0x54 + _0xffffff8000ec71d3: mov rcx, qword ptr [rbp - 0xa0] + _0xffffff8000ec71da: mov qword ptr [rcx + 8], rax + _0xffffff8000ec71de: mov rdi, qword ptr [rbp - 0xa0] + _0xffffff8000ec71e5: mov dword ptr [rbp - 0x238], 0 + _0xffffff8000ec71ef: mov dword ptr [rdi], 0x25 + _0xffffff8000ec71f5: call sub_0xffffff8000eb7d00 + _0xffffff8000ec71fa: mov rax, qword ptr [rbp - 0x98] + _0xffffff8000ec7201: add rax, 0x18 + _0xffffff8000ec7205: mov qword ptr [rbp - 0x1c8], rax + _0xffffff8000ec720c: mov rax, qword ptr [rbp - 0x388] + _0xffffff8000ec7213: mov rcx, qword ptr [rbp - 0x310] + _0xffffff8000ec721a: mov rdx, qword ptr [rax + 8] + _0xffffff8000ec721e: mov qword ptr [rcx + 8], rdx + _0xffffff8000ec7222: mov qword ptr [rcx], rax + _0xffffff8000ec7225: mov qword ptr [rax + 8], rcx + _0xffffff8000ec7229: mov qword ptr [rdx], rcx + _0xffffff8000ec722c: mov eax, dword ptr [rbp - 0x3a4] + _0xffffff8000ec7232: lea ecx, [rax + 0x4d7cb219] + _0xffffff8000ec7238: lea edx, [rax - 0xa] + _0xffffff8000ec723b: lea eax, [rax + 0x2e0a4956] + _0xffffff8000ec7241: jmp _0xffffff8000ec7107 + + .align 0x100 + sub_0xffffff8000eb75a0: + _0xffffff8000eb75a0: push rbp + _0xffffff8000eb75a1: mov rbp, rsp + _0xffffff8000eb75a4: push r15 + _0xffffff8000eb75a6: push r14 + _0xffffff8000eb75a8: push r13 + _0xffffff8000eb75aa: push r12 + _0xffffff8000eb75ac: push rbx + _0xffffff8000eb75ad: sub rsp, 0x1e8 + _0xffffff8000eb75b4: lea rbx, [rbp - 0x1ec] + _0xffffff8000eb75bb: mov dword ptr [rbp - 0x1ec], ebx + _0xffffff8000eb75c1: lea r14, [rbp - 0x1f0] + _0xffffff8000eb75c8: mov dword ptr [rbp - 0x1f0], r14d + _0xffffff8000eb75cf: lea rax, [rbp - 0x1f8] + _0xffffff8000eb75d6: mov qword ptr [rbp - 0x1f8], rax + _0xffffff8000eb75dd: lea rax, [rbp - 0x200] + _0xffffff8000eb75e4: mov qword ptr [rbp - 0x200], rax + _0xffffff8000eb75eb: mov dword ptr [rbp - 0x204], 0x589abec0 + _0xffffff8000eb75f5: mov qword ptr [rbp - 0x1f8], rbx + _0xffffff8000eb75fc: mov qword ptr [rbp - 0x200], r14 + _0xffffff8000eb7603: mov eax, dword ptr [rbp - 0x204] + _0xffffff8000eb7609: lea ecx, [rax + 0x653b160] + _0xffffff8000eb760f: mov rdx, qword ptr [rbp - 0x1f8] + _0xffffff8000eb7616: mov dword ptr [rdx], ecx + _0xffffff8000eb7618: add eax, 0x653b164 + _0xffffff8000eb761d: mov dword ptr [rbp - 0x1f0], eax + _0xffffff8000eb7623: mov dword ptr [rbp - 0x204], 0x3203048f + _0xffffff8000eb762d: mov qword ptr [rbp - 0x210], rdi + _0xffffff8000eb7634: lea r15, [rbp - 0xa0] + _0xffffff8000eb763b: lea r12, [rip + jumptbl_0xffffff8000eb7cb9] + _0xffffff8000eb7642: lea r13, [rbp - 0x68] + _0xffffff8000eb7646: jmp _0xffffff8000eb7c24 + _0xffffff8000eb764b: nop dword ptr [rax + rax] + nop + _0xffffff8000eb7650: mov rax, qword ptr [rbp - 0x38] + _0xffffff8000eb7654: add rax, 4 + _0xffffff8000eb7658: mov qword ptr [rbp - 0x98], rax + _0xffffff8000eb765f: mov dword ptr [rbp - 0x90], 0x1d51bce8 + _0xffffff8000eb7669: mov dword ptr [rbp - 0x6c], 0 + _0xffffff8000eb7670: mov dword ptr [rbp - 0xa0], 0x26 + _0xffffff8000eb767a: mov rdi, r15 + _0xffffff8000eb767d: call _0xffffff8000eb7d00 + _0xffffff8000eb7682: mov rax, qword ptr [rbp - 0x50] + _0xffffff8000eb7686: mov eax, dword ptr [rax + 0x14] + _0xffffff8000eb7689: mov dword ptr [rbp - 0x54], eax + _0xffffff8000eb768c: mov ecx, dword ptr [rbp - 0x204] + _0xffffff8000eb7692: lea edx, [rcx - 3] + _0xffffff8000eb7695: lea esi, [rcx - 0x2fe98b27] + _0xffffff8000eb769b: lea edi, [rcx - 0x2ceb6b92] + _0xffffff8000eb76a1: lea ecx, [rcx - 0x6c8d2a3] + _0xffffff8000eb76a7: test eax, eax + _0xffffff8000eb76a9: mov rax, r14 + _0xffffff8000eb76ac: cmove rax, rbx + _0xffffff8000eb76b0: mov eax, dword ptr [rax] + _0xffffff8000eb76b2: cmove ecx, edi + _0xffffff8000eb76b5: mov rdi, qword ptr [rbp - 0x200] + _0xffffff8000eb76bc: mov r8, qword ptr [rbp - 0x1f8] + _0xffffff8000eb76c3: mov dword ptr [r8], ecx + _0xffffff8000eb76c6: cmovne edx, esi + _0xffffff8000eb76c9: mov dword ptr [rdi], edx + _0xffffff8000eb76cb: mov dword ptr [rbp - 0x204], eax + _0xffffff8000eb76d1: jmp _0xffffff8000eb78a5 + _0xffffff8000eb76d6: mov rax, qword ptr [rbp - 0x48] + _0xffffff8000eb76da: movzx ecx, byte ptr [rax + 0x10] + _0xffffff8000eb76de: movzx edx, byte ptr [rax + 0x13] + _0xffffff8000eb76e2: movzx esi, byte ptr [rax + 0x12] + _0xffffff8000eb76e6: movzx edi, byte ptr [rax + 0x11] + _0xffffff8000eb76ea: add rax, 0x14 + _0xffffff8000eb76ee: mov qword ptr [rbp - 0x30], rax + _0xffffff8000eb76f2: mov eax, edi + _0xffffff8000eb76f4: and eax, 0xdf + _0xffffff8000eb76f9: xor edi, 0xf3efdf + _0xffffff8000eb76ff: lea eax, [rdi + rax*2] + _0xffffff8000eb7702: shl eax, 8 + _0xffffff8000eb7705: add eax, 0xc102100 + _0xffffff8000eb770a: mov edi, eax + _0xffffff8000eb770c: and edi, 0x769feb00 + _0xffffff8000eb7712: xor eax, 0x769feb4f + _0xffffff8000eb7717: lea eax, [rax + rdi*2 + 0x14b1] + _0xffffff8000eb771e: mov edi, eax + _0xffffff8000eb7720: and edi, 0xdd00 + _0xffffff8000eb7726: and eax, 0xff00 + _0xffffff8000eb772b: xor eax, 0x5ccbdd15 + _0xffffff8000eb7730: lea eax, [rax + rdi*2 - 0x5ccbdd15] + _0xffffff8000eb7737: mov edi, esi + _0xffffff8000eb7739: and edi, 0xed + _0xffffff8000eb773f: xor esi, 0xfbed + _0xffffff8000eb7745: lea esi, [rsi + rdi*2] + _0xffffff8000eb7748: shl esi, 0x10 + _0xffffff8000eb774b: add esi, 0x4130000 + _0xffffff8000eb7751: mov edi, esi + _0xffffff8000eb7753: and edi, 0x7dff0000 + _0xffffff8000eb7759: xor esi, 0x7dff5fb7 + _0xffffff8000eb775f: lea esi, [rsi + rdi*2 + 0xa049] + _0xffffff8000eb7766: mov edi, esi + _0xffffff8000eb7768: and edi, 0x770000 + _0xffffff8000eb776e: and esi, 0xff0000 + _0xffffff8000eb7774: xor esi, 0x5f77dfd8 + _0xffffff8000eb777a: lea esi, [rsi + rdi*2 - 0x5f77dfd8] + _0xffffff8000eb7781: mov edi, edx + _0xffffff8000eb7783: and edi, 0x3f + _0xffffff8000eb7786: xor edx, 0xbf + _0xffffff8000eb778c: lea edx, [rdx + rdi*2] + _0xffffff8000eb778f: shl edx, 0x18 + _0xffffff8000eb7792: add edx, 0x41000000 + _0xffffff8000eb7798: mov edi, edx + _0xffffff8000eb779a: and edi, 0x7d000000 + _0xffffff8000eb77a0: xor edx, 0x7dd7eff5 + _0xffffff8000eb77a6: lea edx, [rdx + rdi*2 - 0x7dd7eff5] + _0xffffff8000eb77ad: mov edi, ecx + _0xffffff8000eb77af: and edi, 0x6f + _0xffffff8000eb77b2: xor ecx, 0x7cdfae6f + _0xffffff8000eb77b8: lea ecx, [rcx + rdi*2 - 0x7cdfae6f] + _0xffffff8000eb77bf: or ecx, edx + _0xffffff8000eb77c1: or ecx, esi + _0xffffff8000eb77c3: or ecx, eax + _0xffffff8000eb77c5: mov eax, ecx + _0xffffff8000eb77c7: and eax, 0x7df575df + _0xffffff8000eb77cc: xor ecx, 0x7df575df + _0xffffff8000eb77d2: lea eax, [rcx + rax*2 - 0x7df575df] + _0xffffff8000eb77d9: mov rcx, qword ptr [rbp - 0x38] + _0xffffff8000eb77dd: mov dword ptr [rbp - 0x98], eax + _0xffffff8000eb77e3: add rcx, 4 + _0xffffff8000eb77e7: mov qword ptr [rbp - 0x90], rcx + _0xffffff8000eb77ee: mov dword ptr [rbp - 0x6c], 0 + _0xffffff8000eb77f5: mov dword ptr [rbp - 0xa0], 0xb + _0xffffff8000eb77ff: mov rdi, r15 + _0xffffff8000eb7802: call sub_0xffffff8000eb7d00 + _0xffffff8000eb7807: mov eax, dword ptr [rbp - 0x9c] + _0xffffff8000eb780d: mov dword ptr [rbp - 0x54], eax + _0xffffff8000eb7810: mov ecx, dword ptr [rbp - 0x204] + _0xffffff8000eb7816: lea edx, [rcx - 2] + _0xffffff8000eb7819: lea esi, [rcx + 0xa19ab6e] + _0xffffff8000eb781f: lea edi, [rcx - 1] + _0xffffff8000eb7822: lea ecx, [rcx - 0x6a3c92b] + _0xffffff8000eb7828: jmp _0xffffff8000eb76a7 + _0xffffff8000eb782d: cmp eax, 0x5eee7025 + _0xffffff8000eb7832: je _0xffffff8000eb783b + _0xffffff8000eb7834: cmp eax, 0x643c9696 + _0xffffff8000eb7839: je _0xffffff8000eb78a5 + _0xffffff8000eb783b: cmp dword ptr [rbp - 0x204], 0x643c9696 + _0xffffff8000eb7845: jmp _0xffffff8000eb78a5 + _0xffffff8000eb7847: nop word ptr [rax + rax] + nop + _0xffffff8000eb7850: mov eax, dword ptr [rbp - 0x3c] + _0xffffff8000eb7853: mov dword ptr [rbp - 0x54], 0xffff586d + _0xffffff8000eb785a: mov ecx, dword ptr [rbp - 0x204] + _0xffffff8000eb7860: lea edx, [rcx - 0x3fdb8bed] + _0xffffff8000eb7866: lea esi, [rcx - 2] + _0xffffff8000eb7869: lea edi, [rcx - 4] + _0xffffff8000eb786c: lea ecx, [rcx - 0x3344b8af] + _0xffffff8000eb7872: cmp eax, 0x60f21f94 + _0xffffff8000eb7877: mov rax, r14 + _0xffffff8000eb787a: cmove rax, rbx + _0xffffff8000eb787e: mov eax, dword ptr [rax] + _0xffffff8000eb7880: cmovne ecx, edi + _0xffffff8000eb7883: mov rdi, qword ptr [rbp - 0x200] + _0xffffff8000eb788a: mov r8, qword ptr [rbp - 0x1f8] + _0xffffff8000eb7891: mov dword ptr [r8], ecx + _0xffffff8000eb7894: cmovne edx, esi + _0xffffff8000eb7897: mov dword ptr [rdi], edx + _0xffffff8000eb7899: mov dword ptr [rbp - 0x204], eax + _0xffffff8000eb789f: jne _0xffffff8000eb7c24 + _0xffffff8000eb78a5: mov eax, dword ptr [rbp - 0x204] + _0xffffff8000eb78ab: mov ecx, 0xa1118fe0 + _0xffffff8000eb78b0: add eax, ecx + _0xffffff8000eb78b2: cmp eax, 5 + _0xffffff8000eb78b5: ja _0xffffff8000eb783b + _0xffffff8000eb78b7: jmp _0xffffff8000eb7cb9 + _0xffffff8000eb78bc: mov rax, qword ptr [rbp - 0x38] + _0xffffff8000eb78c0: mov dword ptr [rbp - 0x54], 0xffff586c + _0xffffff8000eb78c7: mov ecx, dword ptr [rbp - 0x204] + _0xffffff8000eb78cd: lea edx, [rcx - 0x1b8796b3] + _0xffffff8000eb78d3: lea esi, [rcx + 0x2ceb6b90] + _0xffffff8000eb78d9: cmp rax, 0 + _0xffffff8000eb78dd: cmove esi, edx + _0xffffff8000eb78e0: lea edx, [rcx + 0x2ceb6b93] + _0xffffff8000eb78e6: lea ecx, [rcx - 0x162333f2] + _0xffffff8000eb78ec: cmp rax, 0 + _0xffffff8000eb78f0: mov rax, r14 + _0xffffff8000eb78f3: cmove rax, rbx + _0xffffff8000eb78f7: mov eax, dword ptr [rax] + _0xffffff8000eb78f9: cmove edx, ecx + _0xffffff8000eb78fc: mov rcx, qword ptr [rbp - 0x200] + _0xffffff8000eb7903: mov rdi, qword ptr [rbp - 0x1f8] + _0xffffff8000eb790a: mov dword ptr [rdi], edx + _0xffffff8000eb790c: mov dword ptr [rcx], esi + _0xffffff8000eb790e: mov dword ptr [rbp - 0x204], eax + _0xffffff8000eb7914: jmp _0xffffff8000eb78a5 + _0xffffff8000eb7916: mov qword ptr [rbp - 0x50], r15 + _0xffffff8000eb791a: mov rdi, qword ptr [rbp - 0x210] + _0xffffff8000eb7921: mov rax, qword ptr [rdi + 0x10] + _0xffffff8000eb7925: mov qword ptr [rbp - 0x48], rax + _0xffffff8000eb7929: mov eax, dword ptr [rdi + 0x1c] + _0xffffff8000eb792c: mov dword ptr [rbp - 0x3c], eax + _0xffffff8000eb792f: mov rax, qword ptr [rdi + 8] + _0xffffff8000eb7933: mov qword ptr [rbp - 0x38], rax + _0xffffff8000eb7937: mov dword ptr [rbp - 0xa4], 0 + _0xffffff8000eb7941: mov rax, qword ptr [rbp - 0x48] + _0xffffff8000eb7945: lea rcx, [rip + 0xd86] + _0xffffff8000eb794c: mov rcx, qword ptr [rcx] + _0xffffff8000eb794f: lea rcx, [rbp - 0x168] + _0xffffff8000eb7956: mov qword ptr [rbp - 0x1d8], rcx + _0xffffff8000eb795d: mov qword ptr [rbp - 0x168], rcx + _0xffffff8000eb7964: mov qword ptr [rbp - 0x160], rcx + _0xffffff8000eb796b: lea rcx, [rbp - 0x158] + _0xffffff8000eb7972: mov qword ptr [rbp - 0x170], rcx + _0xffffff8000eb7979: mov qword ptr [rbp - 0x158], rcx + _0xffffff8000eb7980: mov qword ptr [rbp - 0x150], rcx + _0xffffff8000eb7987: lea rcx, [rbp - 0x148] + _0xffffff8000eb798e: mov qword ptr [rbp - 0x1e0], rcx + _0xffffff8000eb7995: mov qword ptr [rbp - 0x148], rcx + _0xffffff8000eb799c: mov qword ptr [rbp - 0x140], rcx + _0xffffff8000eb79a3: mov rcx, qword ptr [rbp - 0x1e0] + _0xffffff8000eb79aa: mov qword ptr [rbp - 0x138], rcx + _0xffffff8000eb79b1: mov rdx, qword ptr [rcx + 8] + _0xffffff8000eb79b5: mov qword ptr [rbp - 0x130], rdx + _0xffffff8000eb79bc: lea rsi, [rbp - 0x138] + _0xffffff8000eb79c3: mov qword ptr [rdx], rsi + _0xffffff8000eb79c6: mov qword ptr [rcx + 8], rsi + _0xffffff8000eb79ca: mov rcx, qword ptr [rbp - 0x1e0] + _0xffffff8000eb79d1: mov qword ptr [rbp - 0x128], rcx + _0xffffff8000eb79d8: mov rdx, qword ptr [rcx + 8] + _0xffffff8000eb79dc: mov qword ptr [rbp - 0x120], rdx + _0xffffff8000eb79e3: lea rsi, [rbp - 0x128] + _0xffffff8000eb79ea: mov qword ptr [rdx], rsi + _0xffffff8000eb79ed: mov qword ptr [rcx + 8], rsi + _0xffffff8000eb79f1: mov rcx, qword ptr [rbp - 0x1e0] + _0xffffff8000eb79f8: mov qword ptr [rbp - 0x118], rcx + _0xffffff8000eb79ff: mov rdx, qword ptr [rcx + 8] + _0xffffff8000eb7a03: mov qword ptr [rbp - 0x110], rdx + _0xffffff8000eb7a0a: lea rsi, [rbp - 0x118] + _0xffffff8000eb7a11: mov qword ptr [rdx], rsi + _0xffffff8000eb7a14: mov qword ptr [rcx + 8], rsi + _0xffffff8000eb7a18: mov rcx, qword ptr [rbp - 0x1e0] + _0xffffff8000eb7a1f: mov qword ptr [rbp - 0x108], rcx + _0xffffff8000eb7a26: mov rdx, qword ptr [rcx + 8] + _0xffffff8000eb7a2a: mov qword ptr [rbp - 0x100], rdx + _0xffffff8000eb7a31: lea rsi, [rbp - 0x108] + _0xffffff8000eb7a38: mov qword ptr [rdx], rsi + _0xffffff8000eb7a3b: mov qword ptr [rcx + 8], rsi + _0xffffff8000eb7a3f: mov rcx, qword ptr [rbp - 0x1e0] + _0xffffff8000eb7a46: mov qword ptr [rbp - 0xf8], rcx + _0xffffff8000eb7a4d: mov rdx, qword ptr [rcx + 8] + _0xffffff8000eb7a51: mov qword ptr [rbp - 0xf0], rdx + _0xffffff8000eb7a58: lea rsi, [rbp - 0xf8] + _0xffffff8000eb7a5f: mov qword ptr [rdx], rsi + _0xffffff8000eb7a62: mov qword ptr [rcx + 8], rsi + _0xffffff8000eb7a66: mov rcx, qword ptr [rbp - 0x1e0] + _0xffffff8000eb7a6d: mov qword ptr [rbp - 0xe8], rcx + _0xffffff8000eb7a74: mov rdx, qword ptr [rcx + 8] + _0xffffff8000eb7a78: mov qword ptr [rbp - 0xe0], rdx + _0xffffff8000eb7a7f: lea rsi, [rbp - 0xe8] + _0xffffff8000eb7a86: mov qword ptr [rdx], rsi + _0xffffff8000eb7a89: mov qword ptr [rcx + 8], rsi + _0xffffff8000eb7a8d: mov rcx, qword ptr [rbp - 0x1e0] + _0xffffff8000eb7a94: mov qword ptr [rbp - 0xd8], rcx + _0xffffff8000eb7a9b: mov rdx, qword ptr [rcx + 8] + _0xffffff8000eb7a9f: mov qword ptr [rbp - 0xd0], rdx + _0xffffff8000eb7aa6: lea rsi, [rbp - 0xd8] + _0xffffff8000eb7aad: mov qword ptr [rdx], rsi + _0xffffff8000eb7ab0: mov qword ptr [rcx + 8], rsi + _0xffffff8000eb7ab4: mov rcx, qword ptr [rbp - 0x1e0] + _0xffffff8000eb7abb: mov qword ptr [rbp - 0xc8], rcx + _0xffffff8000eb7ac2: mov rdx, qword ptr [rcx + 8] + _0xffffff8000eb7ac6: mov qword ptr [rbp - 0xc0], rdx + _0xffffff8000eb7acd: lea rsi, [rbp - 0xc8] + _0xffffff8000eb7ad4: mov qword ptr [rdx], rsi + _0xffffff8000eb7ad7: mov qword ptr [rcx + 8], rsi + _0xffffff8000eb7adb: mov rcx, qword ptr [rbp - 0x1e0] + _0xffffff8000eb7ae2: mov qword ptr [rbp - 0xb8], rcx + _0xffffff8000eb7ae9: mov rdx, qword ptr [rcx + 8] + _0xffffff8000eb7aed: mov qword ptr [rbp - 0xb0], rdx + _0xffffff8000eb7af4: lea rsi, [rbp - 0xb8] + _0xffffff8000eb7afb: mov qword ptr [rdx], rsi + _0xffffff8000eb7afe: mov qword ptr [rcx + 8], rsi + _0xffffff8000eb7b02: mov dword ptr [rbp - 0x54], 0xffff586c + _0xffffff8000eb7b09: mov ecx, dword ptr [rbp - 0x204] + _0xffffff8000eb7b0f: lea edx, [rcx - 0x896cf0] + _0xffffff8000eb7b15: lea esi, [rcx + 1] + _0xffffff8000eb7b18: cmp rax, 0 + _0xffffff8000eb7b1c: cmove esi, edx + _0xffffff8000eb7b1f: lea edx, [rcx + 0x2ceb6b91] + _0xffffff8000eb7b25: lea ecx, [rcx + 0x33c5678f] + _0xffffff8000eb7b2b: jmp _0xffffff8000eb78ec + _0xffffff8000eb7b30: mov rax, qword ptr [rbp - 0x30] + _0xffffff8000eb7b34: mov al, byte ptr [rax] + _0xffffff8000eb7b36: mov cl, al + _0xffffff8000eb7b38: add cl, cl + _0xffffff8000eb7b3a: xor al, 0x3e + _0xffffff8000eb7b3c: and cl, 0x7c + _0xffffff8000eb7b3f: add cl, al + _0xffffff8000eb7b41: add cl, 0xf4 + _0xffffff8000eb7b44: mov rax, qword ptr [rbp - 0x38] + _0xffffff8000eb7b48: mov byte ptr [rax], cl + _0xffffff8000eb7b4a: mov rax, qword ptr [rbp - 0x48] + _0xffffff8000eb7b4e: mov al, byte ptr [rax + 0x15] + _0xffffff8000eb7b51: mov cl, al + _0xffffff8000eb7b53: add cl, cl + _0xffffff8000eb7b55: xor al, 0x6a + _0xffffff8000eb7b57: and cl, 0xd4 + _0xffffff8000eb7b5a: add cl, al + _0xffffff8000eb7b5c: add cl, 0xfe + _0xffffff8000eb7b5f: mov rax, qword ptr [rbp - 0x38] + _0xffffff8000eb7b63: mov byte ptr [rax + 1], cl + _0xffffff8000eb7b66: mov rax, qword ptr [rbp - 0x48] + _0xffffff8000eb7b6a: mov al, byte ptr [rax + 0x16] + _0xffffff8000eb7b6d: mov rcx, qword ptr [rbp - 0x38] + _0xffffff8000eb7b71: mov byte ptr [rcx + 2], al + _0xffffff8000eb7b74: mov rax, qword ptr [rbp - 0x48] + _0xffffff8000eb7b78: mov al, byte ptr [rax + 0x17] + _0xffffff8000eb7b7b: mov rcx, qword ptr [rbp - 0x38] + _0xffffff8000eb7b7f: mov byte ptr [rcx + 3], al + _0xffffff8000eb7b82: mov rax, qword ptr [rbp - 0x48] + _0xffffff8000eb7b86: add rax, 0x18 + _0xffffff8000eb7b8a: mov rcx, qword ptr [rbp - 0x38] + _0xffffff8000eb7b8e: mov qword ptr [rbp - 0x80], rax + _0xffffff8000eb7b92: add rcx, 4 + _0xffffff8000eb7b96: mov qword ptr [rbp - 0x98], rcx + _0xffffff8000eb7b9d: lea rax, [rbp - 0xa4] + _0xffffff8000eb7ba4: mov qword ptr [rbp - 0x90], rax + _0xffffff8000eb7bab: mov rax, qword ptr [rbp - 0x38] + _0xffffff8000eb7baf: mov qword ptr [rbp - 0x88], rax + _0xffffff8000eb7bb6: mov dword ptr [rbp - 0x6c], 0 + _0xffffff8000eb7bbd: mov dword ptr [rbp - 0x68], 0xfffffffe + _0xffffff8000eb7bc4: mov qword ptr [rbp - 0x60], r15 + _0xffffff8000eb7bc8: mov rdi, r13 + _0xffffff8000eb7bcb: call sub_0xffffff8000ebe600 + _0xffffff8000eb7bd0: mov eax, dword ptr [rbp - 0x78] + _0xffffff8000eb7bd3: mov dword ptr [rbp - 0x54], eax + _0xffffff8000eb7bd6: mov ecx, dword ptr [rbp - 0x204] + _0xffffff8000eb7bdc: lea edx, [rcx - 0x23c1a3c4] + _0xffffff8000eb7be2: lea esi, [rcx - 0x4375aa9] + _0xffffff8000eb7be8: lea edi, [rcx - 0x5e8d91dc] + _0xffffff8000eb7bee: lea ecx, [rcx + 0xc01e587] + _0xffffff8000eb7bf4: test eax, eax + _0xffffff8000eb7bf6: mov rax, r14 + _0xffffff8000eb7bf9: cmove rax, rbx + _0xffffff8000eb7bfd: mov eax, dword ptr [rax] + _0xffffff8000eb7bff: cmove ecx, edi + _0xffffff8000eb7c02: mov rdi, qword ptr [rbp - 0x200] + _0xffffff8000eb7c09: mov r8, qword ptr [rbp - 0x1f8] + _0xffffff8000eb7c10: mov dword ptr [r8], ecx + _0xffffff8000eb7c13: cmovne edx, esi + _0xffffff8000eb7c16: mov dword ptr [rdi], edx + _0xffffff8000eb7c18: mov dword ptr [rbp - 0x204], eax + _0xffffff8000eb7c1e: jne _0xffffff8000eb78a5 + _0xffffff8000eb7c24: mov eax, dword ptr [rbp - 0x204] + _0xffffff8000eb7c2a: cmp eax, 0x5eee7024 + _0xffffff8000eb7c2f: jg _0xffffff8000eb782d + _0xffffff8000eb7c35: cmp eax, 0x3203048f + _0xffffff8000eb7c3a: je _0xffffff8000eb7916 + _0xffffff8000eb7c40: cmp eax, 0x32030490 + _0xffffff8000eb7c45: je _0xffffff8000eb78bc + _0xffffff8000eb7c4b: cmp eax, 0x32030491 + _0xffffff8000eb7c50: jne _0xffffff8000eb783b + _0xffffff8000eb7c56: mov ebx, dword ptr [rbp - 0xa4] + _0xffffff8000eb7c5c: movabs r14, 0x7fdf7edf17fbefbb + _0xffffff8000eb7c66: xor r14, rbx + _0xffffff8000eb7c69: and rbx, 0x17fbefbb + _0xffffff8000eb7c70: lea rbx, [r14 + rbx*2] + _0xffffff8000eb7c74: movabs r14, 0x80208120e804105d + _0xffffff8000eb7c7e: add r14, rbx + _0xffffff8000eb7c81: mov ebx, dword ptr [rbp - 0x3c] + _0xffffff8000eb7c84: cmp rbx, 0x60f21f94 + _0xffffff8000eb7c8b: sbb r15, r15 + _0xffffff8000eb7c8e: and r15d, 1 + _0xffffff8000eb7c92: shl r15, 0x20 + _0xffffff8000eb7c96: add r15, rbx + _0xffffff8000eb7c99: add r15, -0x60f21f94 + _0xffffff8000eb7ca0: xor eax, eax + _0xffffff8000eb7ca2: cmp r15, r14 + _0xffffff8000eb7ca5: mov ecx, 0xffff587d + _0xffffff8000eb7caa: cmove ecx, eax + _0xffffff8000eb7cad: mov rdi, qword ptr [rbp - 0x210] + _0xffffff8000eb7cb4: mov dword ptr [rdi + 0x18], ecx + _0xffffff8000eb7cb7: jmp _0xffffff8000eb7ccf + _0xffffff8000eb7cb9: movsxd rax, dword ptr [r12 + rax*4] + _0xffffff8000eb7cbd: add rax, r12 + _0xffffff8000eb7cc0: jmp rax + _0xffffff8000eb7cc2: mov eax, dword ptr [rbp - 0x54] + _0xffffff8000eb7cc5: mov rdi, qword ptr [rbp - 0x210] + _0xffffff8000eb7ccc: mov dword ptr [rdi + 0x18], eax + _0xffffff8000eb7ccf: add rsp, 0x1e8 + _0xffffff8000eb7cd6: pop rbx + _0xffffff8000eb7cd7: pop r12 + _0xffffff8000eb7cd9: pop r13 + _0xffffff8000eb7cdb: pop r14 + _0xffffff8000eb7cdd: pop r15 + _0xffffff8000eb7cdf: pop rbp + _0xffffff8000eb7ce0: ret + + .align 0x100 + sub_ffffff8000ec7320: + _0xffffff8000ec7320: push rbp + _0xffffff8000ec7321: mov rbp, rsp + _0xffffff8000ec7324: push r15 + _0xffffff8000ec7326: push r14 + _0xffffff8000ec7328: push r13 + _0xffffff8000ec732a: push r12 + _0xffffff8000ec732c: push rbx + _0xffffff8000ec732d: sub rsp, 0x6a8 + _0xffffff8000ec7334: lea rax, [rbp - 0x6a4] + _0xffffff8000ec733b: mov dword ptr [rbp - 0x6a4], eax + _0xffffff8000ec7341: lea rcx, [rbp - 0x6a8] + _0xffffff8000ec7348: mov dword ptr [rbp - 0x6a8], ecx + _0xffffff8000ec734e: lea r8, [rbp - 0x6b0] + _0xffffff8000ec7355: mov qword ptr [rbp - 0x6b0], r8 + _0xffffff8000ec735c: lea r8, [rbp - 0x6b8] + _0xffffff8000ec7363: mov qword ptr [rbp - 0x6b8], r8 + _0xffffff8000ec736a: mov dword ptr [rbp - 0x6bc], 0x2a5c761e + _0xffffff8000ec7374: mov qword ptr [rbp - 0x6b0], rax + _0xffffff8000ec737b: mov qword ptr [rbp - 0x6b8], rcx + _0xffffff8000ec7382: mov eax, dword ptr [rbp - 0x6bc] + _0xffffff8000ec7388: lea ecx, [rax + 0x4df6982d] + _0xffffff8000ec738e: mov r8, qword ptr [rbp - 0x6b0] + _0xffffff8000ec7395: mov dword ptr [r8], ecx + _0xffffff8000ec7398: add eax, 0x4df6982e + _0xffffff8000ec739d: mov dword ptr [rbp - 0x6a8], eax + _0xffffff8000ec73a3: mov dword ptr [rbp - 0x6bc], 0x78530e4a + _0xffffff8000ec73ad: mov qword ptr [rbp - 0x6c8], rdx + _0xffffff8000ec73b4: mov ebx, esi + _0xffffff8000ec73b6: mov r14, rdi + _0xffffff8000ec73b9: lea r15, [rbp - 0x70] + _0xffffff8000ec73bd: jmp _0xffffff8000ec7545 + _0xffffff8000ec73c2: nop dword ptr [rax] + _0xffffff8000ec73c9: nop dword ptr [rax] + nop + nop + _0xffffff8000ec73d0: lea r12, [rbp - 0x560] + _0xffffff8000ec73d7: mov qword ptr [rbp - 0x68], r12 + _0xffffff8000ec73db: mov dword ptr [rbp - 0x70], 0x24 + _0xffffff8000ec73e2: mov rdi, r15 + _0xffffff8000ec73e5: call sub_0xffffff8000eb7d00 + _0xffffff8000ec73ea: mov qword ptr [rbp - 0x68], r12 + _0xffffff8000ec73ee: mov qword ptr [rbp - 0x60], r14 + _0xffffff8000ec73f2: mov dword ptr [rbp - 0x58], ebx + _0xffffff8000ec73f5: mov dword ptr [rbp - 0x48], 3 + _0xffffff8000ec73fc: mov qword ptr [rbp - 0x40], r15 + _0xffffff8000ec7400: lea r13, [rbp - 0x48] + _0xffffff8000ec7404: mov rdi, r13 + _0xffffff8000ec7407: call sub_0xffffff8000ebe600 + _0xffffff8000ec740c: mov qword ptr [rbp - 0x68], r12 + _0xffffff8000ec7410: mov r12, qword ptr [rbp - 0x6c8] + _0xffffff8000ec7417: mov qword ptr [rbp - 0x60], r12 + _0xffffff8000ec741b: mov dword ptr [rbp - 0x48], 0 + _0xffffff8000ec7422: mov qword ptr [rbp - 0x40], r15 + _0xffffff8000ec7426: mov rdi, r13 + _0xffffff8000ec7429: call sub_0xffffff8000ebe600 + _0xffffff8000ec742e: mov qword ptr [rbp - 0x68], r14 + _0xffffff8000ec7432: mov dword ptr [rbp - 0x6c], ebx + _0xffffff8000ec7435: lea rax, [r12 + 0x10] + _0xffffff8000ec743a: mov qword ptr [rbp - 0x60], rax + _0xffffff8000ec743e: lea rax, [rbp - 0x501] + _0xffffff8000ec7445: mov qword ptr [rbp - 0x58], rax + _0xffffff8000ec7449: mov dword ptr [rbp - 0x4c], 0 + _0xffffff8000ec7450: mov dword ptr [rbp - 0x70], 0x18 + _0xffffff8000ec7457: mov rdi, r15 + _0xffffff8000ec745a: call sub_0xffffff8000eb7d00 + _0xffffff8000ec745f: lea rax, [rip + encryption_data] + _0xffffff8000ec7466: mov qword ptr [rbp - 0x60], rax + _0xffffff8000ec746a: mov rax, qword ptr [rbp - 0x30] + _0xffffff8000ec746e: mov dword ptr [rax + 0x1c], 0x60f27fac + _0xffffff8000ec7475: lea rax, [rbp - 0x500] + _0xffffff8000ec747c: mov qword ptr [rbp - 0x68], rax + _0xffffff8000ec7480: mov rdi, r15 + _0xffffff8000ec7483: call sub_0xffffff8000eb75a0 + _0xffffff8000ec7488: mov eax, dword ptr [rbp - 0x58] + _0xffffff8000ec748b: mov dword ptr [rbp - 0x34], eax + _0xffffff8000ec748e: lea rcx, [rip - 0xebf7] + _0xffffff8000ec7495: mov rcx, qword ptr [rcx] + _0xffffff8000ec7498: mov ecx, dword ptr [rbp - 0x6bc] + _0xffffff8000ec749e: lea edx, [rcx - 0xeadbdad] + _0xffffff8000ec74a4: lea esi, [rcx + 0xb61051] + _0xffffff8000ec74aa: lea edi, [rcx - 0x4736a85d] + _0xffffff8000ec74b0: lea ecx, [rcx - 0x424724aa] + _0xffffff8000ec74b6: test eax, eax + _0xffffff8000ec74b8: lea rax, [rbp - 0x6a8] + _0xffffff8000ec74bf: lea r8, [rbp - 0x6a4] + _0xffffff8000ec74c6: cmove rax, r8 + _0xffffff8000ec74ca: mov eax, dword ptr [rax] + _0xffffff8000ec74cc: cmove ecx, edi + _0xffffff8000ec74cf: mov rdi, qword ptr [rbp - 0x6b8] + _0xffffff8000ec74d6: mov r8, qword ptr [rbp - 0x6b0] + _0xffffff8000ec74dd: mov dword ptr [r8], ecx + _0xffffff8000ec74e0: cmove esi, edx + _0xffffff8000ec74e3: mov dword ptr [rdi], esi + _0xffffff8000ec74e5: mov dword ptr [rbp - 0x6bc], eax + _0xffffff8000ec74eb: jmp _0xffffff8000ec7545 + _0xffffff8000ec74ed: mov dword ptr [rbp - 0x34], 0xffff5883 + _0xffffff8000ec74f4: mov eax, dword ptr [rbp - 0x6bc] + _0xffffff8000ec74fa: lea ecx, [rax - 0x56f92180] + _0xffffff8000ec7500: lea edx, [rax - 1] + _0xffffff8000ec7503: lea esi, [rax - 0x25162f4f] + _0xffffff8000ec7509: lea eax, [rax - 3] + _0xffffff8000ec750c: cmp ebx, 0x1fffffff + _0xffffff8000ec7512: lea rdi, [rbp - 0x6a8] + _0xffffff8000ec7519: lea r8, [rbp - 0x6a4] + _0xffffff8000ec7520: cmova rdi, r8 + _0xffffff8000ec7524: mov edi, dword ptr [rdi] + _0xffffff8000ec7526: cmova eax, esi + _0xffffff8000ec7529: mov rsi, qword ptr [rbp - 0x6b8] + _0xffffff8000ec7530: mov r8, qword ptr [rbp - 0x6b0] + _0xffffff8000ec7537: mov dword ptr [r8], eax + _0xffffff8000ec753a: cmovbe ecx, edx + _0xffffff8000ec753d: mov dword ptr [rsi], ecx + _0xffffff8000ec753f: mov dword ptr [rbp - 0x6bc], edi + _0xffffff8000ec7545: mov eax, dword ptr [rbp - 0x6bc] + _0xffffff8000ec754b: mov ecx, 0x87acf1b7 + _0xffffff8000ec7550: add eax, ecx + _0xffffff8000ec7552: cmp eax, 4 + _0xffffff8000ec7555: ja _0xffffff8000ec79e8 + _0xffffff8000ec755b: lea rcx, [jumptbl_0xffffff8000ec755b + rip] + _0xffffff8000ec7562: movsxd rax, dword ptr [rcx + rax*4] + _0xffffff8000ec7566: add rax, rcx + _0xffffff8000ec7569: jmp rax + _0xffffff8000ec756b: mov r14, qword ptr [rbp - 0x6c8] + _0xffffff8000ec7572: mov qword ptr [rbp - 0x68], r14 + _0xffffff8000ec7576: lea rbx, [rbp - 0x500] + _0xffffff8000ec757d: mov qword ptr [rbp - 0x60], rbx + _0xffffff8000ec7581: mov dword ptr [rbp - 0x48], 0xd + _0xffffff8000ec7588: mov qword ptr [rbp - 0x40], r15 + _0xffffff8000ec758c: lea r12, [rbp - 0x48] + _0xffffff8000ec7590: mov rdi, r12 + _0xffffff8000ec7593: call sub_0xffffff8000ebe600 + _0xffffff8000ec7598: mov qword ptr [rbp - 0x68], r14 + _0xffffff8000ec759c: mov qword ptr [rbp - 0x60], rbx + _0xffffff8000ec75a0: mov dword ptr [rbp - 0x48], 0x11 + _0xffffff8000ec75a7: mov qword ptr [rbp - 0x40], r15 + _0xffffff8000ec75ab: mov rdi, r12 + _0xffffff8000ec75ae: call sub_0xffffff8000ebe600 + _0xffffff8000ec75b3: movzx ebx, byte ptr [rbp - 0x501] + _0xffffff8000ec75ba: mov eax, ebx + _0xffffff8000ec75bc: xor eax, 0x29f1f35 + _0xffffff8000ec75c1: lea ecx, [rax + rax] + _0xffffff8000ec75c4: and ecx, 0x221b5e + _0xffffff8000ec75ca: neg ecx + _0xffffff8000ec75cc: lea eax, [rax + rcx - 0x22ee7251] + _0xffffff8000ec75d3: mov ecx, eax + _0xffffff8000ec75d5: xor ecx, 0x20716d64 + _0xffffff8000ec75db: lea esi, [rax + rax] + _0xffffff8000ec75de: and esi, 0x50197206 + _0xffffff8000ec75e4: xor esi, 0x10192006 + _0xffffff8000ec75ea: add esi, ecx + _0xffffff8000ec75ec: mov ecx, 0xb21642c9 + _0xffffff8000ec75f1: mov eax, ebx + _0xffffff8000ec75f3: mul ecx + _0xffffff8000ec75f5: shr edx, 4 + _0xffffff8000ec75f8: imul eax, edx, 0x17 + _0xffffff8000ec75fb: lea ecx, [rbx + rbx] + _0xffffff8000ec75fe: mov edi, ebx + _0xffffff8000ec7600: sub edi, eax + _0xffffff8000ec7602: imul eax, edx, 0x2e + _0xffffff8000ec7605: lea eax, [rax + rdi*2] + _0xffffff8000ec7608: and eax, 0x1fa + _0xffffff8000ec760d: mov edx, esi + _0xffffff8000ec760f: and edx, eax + _0xffffff8000ec7611: xor eax, esi + _0xffffff8000ec7613: lea eax, [rax + rdx*2] + _0xffffff8000ec7616: movsxd r15, eax + _0xffffff8000ec7619: mov esi, 0xb00e63c0 + _0xffffff8000ec761e: xor rsi, r15 + _0xffffff8000ec7621: movabs rdx, 0x8fb823ee08fb823f + _0xffffff8000ec762b: mov rax, r15 + _0xffffff8000ec762e: mul rdx + _0xffffff8000ec7631: shr rdx, 5 + _0xffffff8000ec7635: imul rax, rdx, 0x39 + _0xffffff8000ec7639: sub r15, rax + _0xffffff8000ec763c: imul rax, rdx, 0x72 + _0xffffff8000ec7640: lea r15, [rax + r15*2] + _0xffffff8000ec7644: movabs rax, 0xf2dffe705f7dff7e + _0xffffff8000ec764e: and rax, r15 + _0xffffff8000ec7651: lea r15, [rax + rsi + 0x6ff19cc0] + _0xffffff8000ec7659: add rsi, rsi + _0xffffff8000ec765c: movabs rax, 0xf2dffe713f6138fe + _0xffffff8000ec7666: and rax, rsi + _0xffffff8000ec7669: sub r15, rax + _0xffffff8000ec766c: mov al, byte ptr [r14 + r15] + _0xffffff8000ec7670: xor al, 0x8c + _0xffffff8000ec7672: mov dl, byte ptr [r14 + 0x10] + _0xffffff8000ec7676: xor dl, 0x8c + _0xffffff8000ec7679: mov sil, dl + _0xffffff8000ec767c: add sil, al + _0xffffff8000ec767f: and dl, al + _0xffffff8000ec7681: add dl, dl + _0xffffff8000ec7683: sub sil, dl + _0xffffff8000ec7686: mov eax, ecx + _0xffffff8000ec7688: and eax, 0xa + _0xffffff8000ec768b: mov edx, 4 + _0xffffff8000ec7690: sub edx, eax + _0xffffff8000ec7692: and edx, 0xa + _0xffffff8000ec7695: mov eax, ecx + _0xffffff8000ec7697: and eax, 0xa0 + _0xffffff8000ec769c: add eax, edx + _0xffffff8000ec769e: mov edx, ecx + _0xffffff8000ec76a0: and edx, 0x110 + _0xffffff8000ec76a6: mov edi, ecx + _0xffffff8000ec76a8: and edi, 4 + _0xffffff8000ec76ab: add edi, 0x28 + _0xffffff8000ec76ae: sub edi, edx + _0xffffff8000ec76b0: and edi, 0x114 + _0xffffff8000ec76b6: add edi, eax + _0xffffff8000ec76b8: xor ebx, 0xfffffffe + _0xffffff8000ec76bb: and ecx, 0x42 + _0xffffff8000ec76be: xor ecx, 2 + _0xffffff8000ec76c1: add ecx, ebx + _0xffffff8000ec76c3: mov ebx, edi + _0xffffff8000ec76c5: and ebx, ecx + _0xffffff8000ec76c7: xor ecx, edi + _0xffffff8000ec76c9: lea ebx, [rcx + rbx*2] + _0xffffff8000ec76cc: movsxd r15, ebx + _0xffffff8000ec76cf: movabs rcx, 0x47ae147ae147ae15 + _0xffffff8000ec76d9: mov rax, r15 + _0xffffff8000ec76dc: mul rcx + _0xffffff8000ec76df: mov rax, r15 + _0xffffff8000ec76e2: sub rax, rdx + _0xffffff8000ec76e5: shr rax, 1 + _0xffffff8000ec76e8: add rax, rdx + _0xffffff8000ec76eb: shr rax, 4 + _0xffffff8000ec76ef: lea rcx, [rax + rax*4] + _0xffffff8000ec76f3: lea rcx, [rcx + rcx*4] + _0xffffff8000ec76f7: mov rdx, r15 + _0xffffff8000ec76fa: sub rdx, rcx + _0xffffff8000ec76fd: imul rax, rax, 0x32 + _0xffffff8000ec7701: lea rax, [rax + rdx*2] + _0xffffff8000ec7705: movabs rcx, 0xfb7fde71ffdfef4a + _0xffffff8000ec770f: and rcx, rax + _0xffffff8000ec7712: movabs rax, 0x7dbfef38ffeff7a5 + _0xffffff8000ec771c: xor rax, r15 + _0xffffff8000ec771f: add rax, rcx + _0xffffff8000ec7722: add rax, r14 + _0xffffff8000ec7725: movabs r15, 0x824010c70010085b + _0xffffff8000ec772f: mov byte ptr [r15 + rax], sil + _0xffffff8000ec7733: movzx ebx, byte ptr [rbp - 0x501] + _0xffffff8000ec773a: mov eax, ebx + _0xffffff8000ec773c: xor eax, 0xa9bed7de + _0xffffff8000ec7741: lea ecx, [rax + rax] + _0xffffff8000ec7744: and ecx, 0x1300024 + _0xffffff8000ec774a: neg ecx + _0xffffff8000ec774c: lea eax, [rax + rcx - 0x2d26d7ee] + _0xffffff8000ec7753: mov ecx, 0x84980034 + _0xffffff8000ec7758: sub ecx, eax + _0xffffff8000ec775a: mov edx, ecx + _0xffffff8000ec775c: and edx, eax + _0xffffff8000ec775e: xor ecx, eax + _0xffffff8000ec7760: lea ecx, [rcx + rdx*2] + _0xffffff8000ec7763: mov edx, ecx + _0xffffff8000ec7765: and edx, eax + _0xffffff8000ec7767: xor ecx, eax + _0xffffff8000ec7769: lea eax, [rcx + rdx*2] + _0xffffff8000ec776c: add ebx, ebx + _0xffffff8000ec776e: mov ecx, ebx + _0xffffff8000ec7770: and ecx, 0xe4 + _0xffffff8000ec7776: imul ecx, ecx, 0x96f213b9 + _0xffffff8000ec777c: and ebx, 0x11a + _0xffffff8000ec7782: imul ebx, ebx, 0x96f213b9 + _0xffffff8000ec7788: mov edx, ebx + _0xffffff8000ec778a: and edx, ecx + _0xffffff8000ec778c: xor ebx, ecx + _0xffffff8000ec778e: lea ebx, [rbx + rdx*2] + _0xffffff8000ec7791: imul ebx, ebx, 0xb4ac0289 + _0xffffff8000ec7797: and ebx, 0x198 + _0xffffff8000ec779d: mov ecx, eax + _0xffffff8000ec779f: and ecx, ebx + _0xffffff8000ec77a1: xor ebx, eax + _0xffffff8000ec77a3: lea ebx, [rbx + rcx*2] + _0xffffff8000ec77a6: movsxd r15, ebx + _0xffffff8000ec77a9: movabs rcx, 0xf83e0f83e0f83e1 + _0xffffff8000ec77b3: mov rax, r15 + _0xffffff8000ec77b6: mul rcx + _0xffffff8000ec77b9: shr rdx, 2 + _0xffffff8000ec77bd: imul rax, rdx, 0x42 + _0xffffff8000ec77c1: mov rcx, r15 + _0xffffff8000ec77c4: sub rcx, rax + _0xffffff8000ec77c7: imul rax, rdx, 0x84 + _0xffffff8000ec77ce: lea rax, [rax + rcx*2] + _0xffffff8000ec77d2: movabs rcx, 0xd7feff7f9a9fb3f0 + _0xffffff8000ec77dc: and rcx, rax + _0xffffff8000ec77df: movabs rax, 0x6bff7fbfcd4fd9f8 + _0xffffff8000ec77e9: xor rax, r15 + _0xffffff8000ec77ec: add rax, rcx + _0xffffff8000ec77ef: add rax, r14 + _0xffffff8000ec77f2: movabs r15, 0x9400804032b02608 + _0xffffff8000ec77fc: mov cl, byte ptr [r15 + rax] + _0xffffff8000ec7800: xor cl, byte ptr [r14 + 0x10] + _0xffffff8000ec7804: mov byte ptr [r14 + 0x10], cl + _0xffffff8000ec7808: mov ebx, 0x88888889 + _0xffffff8000ec780d: movzx esi, byte ptr [rbp - 0x501] + _0xffffff8000ec7814: mov eax, esi + _0xffffff8000ec7816: mul ebx + _0xffffff8000ec7818: mov ebx, edx + _0xffffff8000ec781a: shr ebx, 5 + _0xffffff8000ec781d: imul eax, ebx, 0x3c + _0xffffff8000ec7820: mov edi, esi + _0xffffff8000ec7822: sub edi, eax + _0xffffff8000ec7824: and ebx, 1 + _0xffffff8000ec7827: neg ebx + _0xffffff8000ec7829: and ebx, 0x78 + _0xffffff8000ec782c: lea ebx, [rbx + rdi*2 - 0x3840] + _0xffffff8000ec7833: shr edx, 6 + _0xffffff8000ec7836: mov eax, edx + _0xffffff8000ec7838: imul eax, eax + _0xffffff8000ec783b: sub ebx, eax + _0xffffff8000ec783d: or edx, 0x78 + _0xffffff8000ec7840: imul edx, edx + _0xffffff8000ec7843: add edx, ebx + _0xffffff8000ec7845: mov ebx, 0xbe + _0xffffff8000ec784a: sub ebx, edx + _0xffffff8000ec784c: mov eax, ebx + _0xffffff8000ec784e: xor eax, edx + _0xffffff8000ec7850: mov edi, edx + _0xffffff8000ec7852: and edi, ebx + _0xffffff8000ec7854: mov r8d, ebx + _0xffffff8000ec7857: and r8d, 0x54924254 + _0xffffff8000ec785e: mov r9d, edx + _0xffffff8000ec7861: and r9d, 0x54924254 + _0xffffff8000ec7868: add r9d, 0x292484a8 + _0xffffff8000ec786f: sub r9d, r8d + _0xffffff8000ec7872: and r8d, edx + _0xffffff8000ec7875: add r8d, r8d + _0xffffff8000ec7878: and r9d, 0x54924254 + _0xffffff8000ec787f: add r9d, r8d + _0xffffff8000ec7882: and eax, 0xa1252921 + _0xffffff8000ec7887: and edi, 0x21252921 + _0xffffff8000ec788d: add edi, edi + _0xffffff8000ec788f: add edi, eax + _0xffffff8000ec7891: and ebx, 0xa48948a + _0xffffff8000ec7897: mov eax, edx + _0xffffff8000ec7899: and eax, 0xa48948a + _0xffffff8000ec789e: add eax, ebx + _0xffffff8000ec78a0: add eax, edi + _0xffffff8000ec78a2: add eax, r9d + _0xffffff8000ec78a5: and eax, edx + _0xffffff8000ec78a7: mov ebx, esi + _0xffffff8000ec78a9: xor ebx, 0x53798081 + _0xffffff8000ec78af: lea edx, [rbx + rbx] + _0xffffff8000ec78b2: and edx, 0x3001bc + _0xffffff8000ec78b8: neg edx + _0xffffff8000ec78ba: lea ebx, [rbx + rdx + 0x249a79de] + _0xffffff8000ec78c1: mov edx, 0x881c06a1 + _0xffffff8000ec78c6: sub edx, ebx + _0xffffff8000ec78c8: mov edi, edx + _0xffffff8000ec78ca: and edi, ebx + _0xffffff8000ec78cc: xor edx, ebx + _0xffffff8000ec78ce: add edx, ebx + _0xffffff8000ec78d0: lea ebx, [rdx + rdi*2] + _0xffffff8000ec78d3: add ebx, eax + _0xffffff8000ec78d5: movsxd r15, ebx + _0xffffff8000ec78d8: mov rax, r15 + _0xffffff8000ec78db: xor rax, 0x5a406065 + _0xffffff8000ec78e1: movabs rdx, 0x79fc7fa7adba1f9a + _0xffffff8000ec78eb: add rdx, rax + _0xffffff8000ec78ee: add rax, rax + _0xffffff8000ec78f1: movabs rdi, 0xf3f8ff4f5b743f34 + _0xffffff8000ec78fb: and rdi, rax + _0xffffff8000ec78fe: sub rdx, rdi + _0xffffff8000ec7901: movabs rax, 0x603805808058001 + _0xffffff8000ec790b: and rax, rdx + _0xffffff8000ec790e: movabs rdi, 0x8603805808058001 + _0xffffff8000ec7918: xor rdi, rdx + _0xffffff8000ec791b: lea rax, [rdi + rax*2] + _0xffffff8000ec791f: add r15, r15 + _0xffffff8000ec7922: movabs rdx, 0xf3f8ff4feff4fffe + _0xffffff8000ec792c: and rdx, r15 + _0xffffff8000ec792f: mov r15, rax + _0xffffff8000ec7932: and r15, rdx + _0xffffff8000ec7935: xor rdx, rax + _0xffffff8000ec7938: lea r15, [rdx + r15*2] + _0xffffff8000ec793c: mov al, byte ptr [r14 + r15] + _0xffffff8000ec7940: xor al, 0x62 + _0xffffff8000ec7942: xor cl, 0x62 + _0xffffff8000ec7945: mov dl, cl + _0xffffff8000ec7947: add dl, al + _0xffffff8000ec7949: and cl, al + _0xffffff8000ec794b: add cl, cl + _0xffffff8000ec794d: sub dl, cl + _0xffffff8000ec794f: imul ebx, esi, 0xf86de6ba + _0xffffff8000ec7955: mov eax, 0xa6d3f8f5 + _0xffffff8000ec795a: sub eax, ebx + _0xffffff8000ec795c: mov ecx, eax + _0xffffff8000ec795e: and ecx, ebx + _0xffffff8000ec7960: xor eax, ebx + _0xffffff8000ec7962: lea eax, [rax + rcx*2] + _0xffffff8000ec7965: sar eax, 1 + _0xffffff8000ec7967: mov ecx, eax + _0xffffff8000ec7969: imul ecx, ecx + _0xffffff8000ec796c: lea eax, [rax + rbx] + _0xffffff8000ec796f: mov edi, ebx + _0xffffff8000ec7971: imul edi, edi + _0xffffff8000ec7974: sub ebx, edi + _0xffffff8000ec7976: sub ebx, ecx + _0xffffff8000ec7978: imul eax, eax + _0xffffff8000ec797b: add eax, ebx + _0xffffff8000ec797d: sar eax, 1 + _0xffffff8000ec797f: mov ebx, eax + _0xffffff8000ec7981: xor ebx, 0x1b74d1fe + _0xffffff8000ec7987: mov ecx, 0xfc + _0xffffff8000ec798c: sub ecx, ebx + _0xffffff8000ec798e: xor eax, 0x1b74d102 + _0xffffff8000ec7993: add eax, ecx + _0xffffff8000ec7995: mov ebx, esi + _0xffffff8000ec7997: xor ebx, 0xfffffff8 + _0xffffff8000ec799a: lea ecx, [rsi + rsi] + _0xffffff8000ec799d: and ecx, 8 + _0xffffff8000ec79a0: xor ecx, 8 + _0xffffff8000ec79a3: add ecx, ebx + _0xffffff8000ec79a5: mov ebx, eax + _0xffffff8000ec79a7: and ebx, ecx + _0xffffff8000ec79a9: xor ecx, eax + _0xffffff8000ec79ab: lea ebx, [rcx + rbx*2] + _0xffffff8000ec79ae: movsxd r15, ebx + _0xffffff8000ec79b1: lea rax, [r15 + r15] + _0xffffff8000ec79b5: movabs rcx, 0xefffd7be7ff9dffa + _0xffffff8000ec79bf: and rcx, rax + _0xffffff8000ec79c2: xor r15, 0xfffffffffffffffe + _0xffffff8000ec79c6: add r15, rcx + _0xffffff8000ec79c9: movabs rcx, 0x1000284180062006 + _0xffffff8000ec79d3: and rcx, rax + _0xffffff8000ec79d6: xor rcx, 2 + _0xffffff8000ec79da: add rcx, r15 + _0xffffff8000ec79dd: mov byte ptr [r14 + rcx], dl + _0xffffff8000ec79e1: xor eax, eax + _0xffffff8000ec79e3: jmp _0xffffff8000ec7bff + _0xffffff8000ec79e8: mov qword ptr [rbp - 0x30], r15 + _0xffffff8000ec79ec: lea rax, [rbp - 0x620] + _0xffffff8000ec79f3: mov qword ptr [rbp - 0x688], rax + _0xffffff8000ec79fa: mov qword ptr [rbp - 0x620], rax + _0xffffff8000ec7a01: mov qword ptr [rbp - 0x618], rax + _0xffffff8000ec7a08: lea rax, [rbp - 0x610] + _0xffffff8000ec7a0f: mov qword ptr [rbp - 0x628], rax + _0xffffff8000ec7a16: mov qword ptr [rbp - 0x610], rax + _0xffffff8000ec7a1d: mov qword ptr [rbp - 0x608], rax + _0xffffff8000ec7a24: lea rax, [rbp - 0x600] + _0xffffff8000ec7a2b: mov qword ptr [rbp - 0x690], rax + _0xffffff8000ec7a32: mov qword ptr [rbp - 0x600], rax + _0xffffff8000ec7a39: mov qword ptr [rbp - 0x5f8], rax + _0xffffff8000ec7a40: lea rax, [rbp - 0x5f0] + _0xffffff8000ec7a47: mov qword ptr [rbp - 0x6a0], rax + _0xffffff8000ec7a4e: mov qword ptr [rbp - 0x5f0], rax + _0xffffff8000ec7a55: mov qword ptr [rbp - 0x5e8], rax + _0xffffff8000ec7a5c: mov rax, qword ptr [rbp - 0x6a0] + _0xffffff8000ec7a63: mov qword ptr [rbp - 0x5e0], rax + _0xffffff8000ec7a6a: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec7a6e: mov qword ptr [rbp - 0x5d8], rcx + _0xffffff8000ec7a75: lea rdx, [rbp - 0x5e0] + _0xffffff8000ec7a7c: mov qword ptr [rcx], rdx + _0xffffff8000ec7a7f: mov qword ptr [rax + 8], rdx + _0xffffff8000ec7a83: mov rax, qword ptr [rbp - 0x6a0] + _0xffffff8000ec7a8a: mov qword ptr [rbp - 0x5d0], rax + _0xffffff8000ec7a91: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec7a95: mov qword ptr [rbp - 0x5c8], rcx + _0xffffff8000ec7a9c: lea rdx, [rbp - 0x5d0] + _0xffffff8000ec7aa3: mov qword ptr [rcx], rdx + _0xffffff8000ec7aa6: mov qword ptr [rax + 8], rdx + _0xffffff8000ec7aaa: mov rax, qword ptr [rbp - 0x688] + _0xffffff8000ec7ab1: mov qword ptr [rbp - 0x5c0], rax + _0xffffff8000ec7ab8: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec7abc: mov qword ptr [rbp - 0x5b8], rcx + _0xffffff8000ec7ac3: lea rdx, [rbp - 0x5c0] + _0xffffff8000ec7aca: mov qword ptr [rcx], rdx + _0xffffff8000ec7acd: mov qword ptr [rax + 8], rdx + _0xffffff8000ec7ad1: mov rax, qword ptr [rbp - 0x690] + _0xffffff8000ec7ad8: mov qword ptr [rbp - 0x5b0], rax + _0xffffff8000ec7adf: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec7ae3: mov qword ptr [rbp - 0x5a8], rcx + _0xffffff8000ec7aea: lea rdx, [rbp - 0x5b0] + _0xffffff8000ec7af1: mov qword ptr [rcx], rdx + _0xffffff8000ec7af4: mov qword ptr [rax + 8], rdx + _0xffffff8000ec7af8: lea rax, [rbp - 0x5a0] + _0xffffff8000ec7aff: mov qword ptr [rbp - 0x698], rax + _0xffffff8000ec7b06: mov qword ptr [rbp - 0x5a0], rax + _0xffffff8000ec7b0d: mov qword ptr [rbp - 0x598], rax + _0xffffff8000ec7b14: mov rax, qword ptr [rbp - 0x698] + _0xffffff8000ec7b1b: mov qword ptr [rbp - 0x590], rax + _0xffffff8000ec7b22: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec7b26: mov qword ptr [rbp - 0x588], rcx + _0xffffff8000ec7b2d: lea rdx, [rbp - 0x590] + _0xffffff8000ec7b34: mov qword ptr [rcx], rdx + _0xffffff8000ec7b37: mov qword ptr [rax + 8], rdx + _0xffffff8000ec7b3b: mov rax, qword ptr [rbp - 0x6a0] + _0xffffff8000ec7b42: mov qword ptr [rbp - 0x580], rax + _0xffffff8000ec7b49: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec7b4d: mov qword ptr [rbp - 0x578], rcx + _0xffffff8000ec7b54: lea rdx, [rbp - 0x580] + _0xffffff8000ec7b5b: mov qword ptr [rcx], rdx + _0xffffff8000ec7b5e: mov qword ptr [rax + 8], rdx + _0xffffff8000ec7b62: mov rax, qword ptr [rbp - 0x6a0] + _0xffffff8000ec7b69: mov qword ptr [rbp - 0x570], rax + _0xffffff8000ec7b70: mov rcx, qword ptr [rax + 8] + _0xffffff8000ec7b74: mov qword ptr [rbp - 0x568], rcx + _0xffffff8000ec7b7b: lea rdx, [rbp - 0x570] + _0xffffff8000ec7b82: mov qword ptr [rcx], rdx + _0xffffff8000ec7b85: mov qword ptr [rax + 8], rdx + _0xffffff8000ec7b89: cmp qword ptr [rbp - 0x6c8], 0 + _0xffffff8000ec7b91: sete al + _0xffffff8000ec7b94: test r14, r14 + _0xffffff8000ec7b97: sete cl + _0xffffff8000ec7b9a: or cl, al + _0xffffff8000ec7b9c: mov dword ptr [rbp - 0x34], 0xffff587c + _0xffffff8000ec7ba3: mov eax, dword ptr [rbp - 0x6bc] + _0xffffff8000ec7ba9: lea edx, [rax - 0x2f3959de] + _0xffffff8000ec7baf: lea esi, [rax + 3] + _0xffffff8000ec7bb2: lea edi, [rax - 0x5a48c3c] + _0xffffff8000ec7bb8: lea eax, [rax + 1] + _0xffffff8000ec7bbb: test ebx, ebx + _0xffffff8000ec7bbd: sete r8b + _0xffffff8000ec7bc1: or r8b, cl + _0xffffff8000ec7bc4: lea rcx, [rbp - 0x6a8] + _0xffffff8000ec7bcb: lea r8, [rbp - 0x6a4] + _0xffffff8000ec7bd2: cmovne rcx, r8 + _0xffffff8000ec7bd6: mov ecx, dword ptr [rcx] + _0xffffff8000ec7bd8: cmovne eax, edi + _0xffffff8000ec7bdb: mov rdi, qword ptr [rbp - 0x6b8] + _0xffffff8000ec7be2: mov r8, qword ptr [rbp - 0x6b0] + _0xffffff8000ec7be9: mov dword ptr [r8], eax + _0xffffff8000ec7bec: cmove edx, esi + _0xffffff8000ec7bef: mov dword ptr [rdi], edx + _0xffffff8000ec7bf1: mov dword ptr [rbp - 0x6bc], ecx + _0xffffff8000ec7bf7: jmp _0xffffff8000ec7545 + _0xffffff8000ec7bfc: mov eax, dword ptr [rbp - 0x34] + _0xffffff8000ec7bff: add rsp, 0x6a8 + _0xffffff8000ec7c06: pop rbx + _0xffffff8000ec7c07: pop r12 + _0xffffff8000ec7c09: pop r13 + _0xffffff8000ec7c0b: pop r14 + _0xffffff8000ec7c0d: pop r15 + _0xffffff8000ec7c0f: pop rbp + _0xffffff8000ec7c10: ret + diff --git a/rustpush/open-absinthe/src/bin/enrich_hw_key.rs b/rustpush/open-absinthe/src/bin/enrich_hw_key.rs new file mode 100644 index 00000000..e73b22d3 --- /dev/null +++ b/rustpush/open-absinthe/src/bin/enrich_hw_key.rs @@ -0,0 +1,205 @@ +// enrich_hw_key: Enriches a base64-encoded hardware key with missing _enc +// fields by computing them from plaintext values using the XNU kernel +// encryption function. Only works on x86_64 Linux. +// +// Preserves the original JSON key ordering so the output is byte-compatible +// with the input tool (Go extract-key, SwiftUI app, etc.). +// +// Usage: +// cargo build --bin enrich_hw_key +// ./target/debug/enrich_hw_key --key <base64> +// ./target/debug/enrich_hw_key --file ~/hwkey.b64 +// echo '<base64>' | ./target/debug/enrich_hw_key + +use base64::{engine::general_purpose::STANDARD, Engine}; +use open_absinthe::nac::{enrich_missing_enc_fields, HardwareConfig}; +use serde_json::{Map, Value}; +use std::io::{self, Read}; + +fn main() { + let args: Vec<String> = std::env::args().collect(); + + // Parse input: --key <base64>, --file <path>, or stdin + let b64_input = if let Some(pos) = args.iter().position(|a| a == "--key") { + args.get(pos + 1) + .expect("--key requires a base64 argument") + .clone() + } else if let Some(pos) = args.iter().position(|a| a == "--file") { + let path = args + .get(pos + 1) + .expect("--file requires a file path argument"); + std::fs::read_to_string(path) + .unwrap_or_else(|e| { + eprintln!("Failed to read {}: {}", path, e); + std::process::exit(1); + }) + .trim() + .to_string() + } else { + let mut buf = String::new(); + io::stdin().read_to_string(&mut buf).unwrap_or_else(|e| { + eprintln!("Failed to read stdin: {}", e); + std::process::exit(1); + }); + buf.trim().to_string() + }; + + if b64_input.is_empty() { + eprintln!("Usage: enrich_hw_key --key <base64>"); + eprintln!(" enrich_hw_key --file <path>"); + eprintln!(" echo '<base64>' | enrich_hw_key"); + std::process::exit(1); + } + + // Decode base64 + let json_bytes = STANDARD.decode(&b64_input).unwrap_or_else(|e| { + eprintln!("Base64 decode error: {}", e); + std::process::exit(1); + }); + + // Parse as serde_json::Value to preserve original key ordering. + // With the preserve_order feature, Map uses IndexMap which maintains + // insertion order through serialize/deserialize round-trips. + let mut root: Value = serde_json::from_slice(&json_bytes).unwrap_or_else(|e| { + eprintln!("JSON parse error: {}", e); + std::process::exit(1); + }); + + // Find the inner HardwareConfig — either root.inner (MacOSConfig) or root itself + let is_wrapped = root.get("inner").is_some(); + let inner_value = if is_wrapped { + eprintln!("Parsed as MacOSConfig (wrapped)"); + root.get("inner").unwrap().clone() + } else { + eprintln!("Parsed as bare HardwareConfig"); + root.clone() + }; + + // Deserialize inner as HardwareConfig for enrichment + let mut hw: HardwareConfig = serde_json::from_value(inner_value).unwrap_or_else(|e| { + eprintln!("Failed to parse HardwareConfig: {}", e); + std::process::exit(1); + }); + + // Log before state + eprintln!("Before enrichment:"); + eprintln!( + " platform_serial_number_enc: {} bytes", + hw.platform_serial_number_enc.len() + ); + eprintln!(" platform_uuid_enc: {} bytes", hw.platform_uuid_enc.len()); + eprintln!( + " root_disk_uuid_enc: {} bytes", + hw.root_disk_uuid_enc.len() + ); + eprintln!(" rom_enc: {} bytes", hw.rom_enc.len()); + eprintln!(" mlb_enc: {} bytes", hw.mlb_enc.len()); + + // Enrich + if let Err(e) = enrich_missing_enc_fields(&mut hw) { + eprintln!("Enrichment failed: {}", e); + std::process::exit(1); + } + + // Log after state + eprintln!("After enrichment:"); + eprintln!( + " platform_serial_number_enc: {} bytes", + hw.platform_serial_number_enc.len() + ); + eprintln!(" platform_uuid_enc: {} bytes", hw.platform_uuid_enc.len()); + eprintln!( + " root_disk_uuid_enc: {} bytes", + hw.root_disk_uuid_enc.len() + ); + eprintln!(" rom_enc: {} bytes", hw.rom_enc.len()); + eprintln!(" mlb_enc: {} bytes", hw.mlb_enc.len()); + + // Write enriched _enc fields back into the original Value tree, + // preserving the original key ordering for all other fields. + let target = if is_wrapped { + root.get_mut("inner").unwrap() + } else { + &mut root + }; + + if let Value::Object(map) = target { + write_enc_field(map, "platform_serial_number_enc", &hw.platform_serial_number_enc); + write_enc_field(map, "platform_uuid_enc", &hw.platform_uuid_enc); + write_enc_field(map, "root_disk_uuid_enc", &hw.root_disk_uuid_enc); + write_enc_field(map, "rom_enc", &hw.rom_enc); + write_enc_field(map, "mlb_enc", &hw.mlb_enc); + } + + // Reorder keys to match the exact ordering that Apple expects. + // This matches the Go extract-key tool and Swift app output. + let reordered = if is_wrapped { + let inner_map = root.get("inner").unwrap().as_object().unwrap(); + let outer_map = root.as_object().unwrap(); + let inner_ordered = reorder_inner(inner_map); + let mut outer = Map::new(); + // Outer key order: aoskit_version, inner, protocol_version, device_id, icloud_ua, version + for key in &[ + "aoskit_version", + "inner", + "protocol_version", + "device_id", + "icloud_ua", + "version", + ] { + if *key == "inner" { + outer.insert("inner".to_string(), Value::Object(inner_ordered.clone())); + } else if let Some(v) = outer_map.get(*key) { + outer.insert(key.to_string(), v.clone()); + } + } + Value::Object(outer) + } else { + let map = root.as_object().unwrap(); + Value::Object(reorder_inner(map)) + }; + + let output_json = serde_json::to_vec(&reordered).expect("JSON serialization failed"); + let output_b64 = STANDARD.encode(&output_json); + println!("{}", output_b64); +} + +/// Write an _enc field value into a JSON map, preserving its position if it +/// already exists, or appending it if new. +fn write_enc_field(map: &mut Map<String, Value>, key: &str, data: &[u8]) { + let arr = Value::Array(data.iter().map(|b| Value::Number((*b).into())).collect()); + map.insert(key.to_string(), arr); +} + +/// Reorder inner (HardwareConfig) keys to match the expected ordering. +fn reorder_inner(map: &Map<String, Value>) -> Map<String, Value> { + let key_order = [ + "root_disk_uuid", + "mlb", + "product_name", + "platform_uuid_enc", + "rom", + "platform_serial_number", + "io_mac_address", + "platform_uuid", + "os_build_num", + "platform_serial_number_enc", + "board_id", + "root_disk_uuid_enc", + "mlb_enc", + "rom_enc", + ]; + let mut ordered = Map::new(); + for key in &key_order { + if let Some(v) = map.get(*key) { + ordered.insert(key.to_string(), v.clone()); + } + } + // Include any extra keys not in the standard order (e.g. relay fields) + for (k, v) in map { + if !ordered.contains_key(k) { + ordered.insert(k.clone(), v.clone()); + } + } + ordered +} diff --git a/rustpush/open-absinthe/src/lib.rs b/rustpush/open-absinthe/src/lib.rs new file mode 100644 index 00000000..22f42b43 --- /dev/null +++ b/rustpush/open-absinthe/src/lib.rs @@ -0,0 +1,38 @@ +use std::{error::Error, fmt::Display}; + +pub mod nac; + +#[derive(Debug)] +pub enum AbsintheError { + NacError(i32), + Other(String), +} + +impl Display for AbsintheError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AbsintheError::NacError(code) => write!(f, "NAC error: {}", code), + AbsintheError::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl Error for AbsintheError {} + +impl From<String> for AbsintheError { + fn from(s: String) -> Self { + AbsintheError::Other(s) + } +} + +impl From<&str> for AbsintheError { + fn from(s: &str) -> Self { + AbsintheError::Other(s.to_string()) + } +} + +impl From<std::io::Error> for AbsintheError { + fn from(e: std::io::Error) -> Self { + AbsintheError::Other(format!("IO error: {}", e)) + } +} diff --git a/rustpush/open-absinthe/src/nac.rs b/rustpush/open-absinthe/src/nac.rs new file mode 100644 index 00000000..08c961ba --- /dev/null +++ b/rustpush/open-absinthe/src/nac.rs @@ -0,0 +1,1553 @@ +use std::cell::{RefCell, UnsafeCell}; +use std::collections::HashMap; +use std::fmt; +use std::fs; +use std::io::Read; +use std::rc::Rc; +use std::sync::{Mutex, OnceLock}; + +use goblin::mach::cputype::CPU_TYPE_X86_64; +use goblin::mach::Mach; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize, Serializer, Deserializer, de}; +use sha1::{Sha1, Digest}; +use unicorn_engine::unicorn_const::{Arch, Mode, Prot}; +use unicorn_engine::{RegisterX86, Unicorn}; + +use crate::AbsintheError; + +// ============================================================================ +// NAC relay config (Apple Silicon hardware keys) +// ============================================================================ +// +// Hardware keys extracted from Apple Silicon Macs can't be driven by the +// local unicorn x86-64 emulator. For those users, `extract-key` embeds a +// relay URL + bearer token into the hardware-key JSON blob. At runtime, +// the wrapper calls `set_relay_config` to stash the URL + token + optional +// cert fingerprint here, and `ValidationCtx::new()` checks it first. If a +// relay is configured, NAC flows through a 3-step HTTPS protocol against +// the relay server (`tools/nac-relay`, running on a Mac) instead of the +// local emulator. The relay uses native `AAAbsintheContext` via +// `nac-validation` to produce real Apple-accepted bytes at each step. + +#[derive(Clone, Debug)] +pub struct RelayConfig { + pub url: String, + pub token: Option<String>, + pub cert_fp: Option<String>, +} + +fn relay_config() -> &'static Mutex<Option<RelayConfig>> { + static CONFIG: OnceLock<Mutex<Option<RelayConfig>>> = OnceLock::new(); + CONFIG.get_or_init(|| Mutex::new(None)) +} + +/// Install a NAC relay so subsequent `ValidationCtx::new()` calls route +/// validation through the relay's single-shot `/validation-data` endpoint +/// instead of the local x86-64 emulator. Idempotent; overwriting is fine. +/// +/// The URL should be the full relay endpoint, e.g. +/// `https://host:5001/validation-data`. If a bare base URL is passed +/// (no `/validation-data` suffix), the suffix is appended automatically. +pub fn set_relay_config(url: String, token: Option<String>, cert_fp: Option<String>) { + let trimmed = url.trim_end_matches('/'); + let full_url = if trimmed.ends_with("/validation-data") { + trimmed.to_string() + } else { + format!("{}/validation-data", trimmed) + }; + let cfg = RelayConfig { url: full_url, token, cert_fp }; + let mut slot = match relay_config().lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + info!("NAC relay URL: {}", cfg.url); + *slot = Some(cfg); +} + +fn get_relay_config() -> Option<RelayConfig> { + let slot = match relay_config().lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + slot.clone() +} + +// --------------------------------------------------------------------------- +// Pre-fetched validation data (Apple Silicon relay path) +// +// The bridge pre-fetches validation data from the relay and stashes it here. +// sign() checks this before returning the emulator's NACSign output — if +// set, the relay's data is authoritative and the emulator result is discarded. +// This lets the emulator handle NACInit/KeyEstablishment (producing valid +// request bytes for upstream's Apple handshake) while the relay provides +// the actual signed validation data. +// --------------------------------------------------------------------------- + +fn prefetched_data() -> &'static Mutex<Option<Vec<u8>>> { + static DATA: OnceLock<Mutex<Option<Vec<u8>>>> = OnceLock::new(); + DATA.get_or_init(|| Mutex::new(None)) +} + +/// Stash pre-fetched validation data from the NAC relay. +/// The next call to `ValidationCtx::sign()` will return this data +/// instead of the emulator's NACSign output. +pub fn set_prefetched_validation_data(data: Vec<u8>) { + info!("NAC: stashed {} bytes of pre-fetched relay validation data", data.len()); + let mut slot = match prefetched_data().lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + *slot = Some(data); +} + +/// Take the pre-fetched validation data (if any). Returns None if +/// nothing was stashed or it was already consumed. +fn take_prefetched_validation_data() -> Option<Vec<u8>> { + let mut slot = match prefetched_data().lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + slot.take() +} + +/// Build a ureq agent that trusts the relay's self-signed TLS certificate +/// if a fingerprint is configured. Matches the danger-accept-invalid-certs +/// behavior the master-branch reqwest client used. +fn relay_agent(cfg: &RelayConfig) -> Result<ureq::Agent, AbsintheError> { + use native_tls::TlsConnector; + let mut builder = TlsConnector::builder(); + builder.danger_accept_invalid_certs(true); + builder.danger_accept_invalid_hostnames(true); + let connector = builder + .build() + .map_err(|e| AbsintheError::Other(format!("relay TLS build failed: {e}")))?; + let agent = ureq::AgentBuilder::new() + .tls_connector(std::sync::Arc::new(connector)) + .timeout(std::time::Duration::from_secs(30)) + .build(); + // cert_fp pinning is best-effort: ureq exposes the peer cert only when + // rustls is used. For now we rely on the bearer token as the primary + // authenticator (matches the master-branch reqwest path). + let _ = cfg.cert_fp; + Ok(agent) +} + +// relay_post_json and b64_decode removed — the relay now uses a single-shot +// POST to /validation-data (matching master's approach) instead of a 3-step +// JSON protocol. + +// ============================================================================ +// XNU IOKit property encryption (x86_64 Linux only) +// ============================================================================ + +/// FFI binding to the XNU kernel encryption function extracted from the macOS +/// kernel. Only available when compiled on x86_64-linux (cfg `has_xnu_encrypt` +/// is set by build.rs). +#[cfg(has_xnu_encrypt)] +extern "C" { + fn sub_ffffff8000ec7320(data: *const u8, size: u64, output: *mut u8); +} + +/// Encrypt a plaintext IOKit property value using the XNU kernel function. +/// Returns 17 bytes of encrypted output, or an error if the function is not +/// available on this platform. +#[cfg(has_xnu_encrypt)] +fn encrypt_io_property(data: &[u8]) -> Result<Vec<u8>, AbsintheError> { + let mut output = vec![0u8; 17]; + unsafe { + sub_ffffff8000ec7320(data.as_ptr(), data.len() as u64, output.as_mut_ptr()); + } + Ok(output) +} + +/// Parse a UUID string like "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" into 16 +/// raw bytes. +#[cfg(has_xnu_encrypt)] +fn uuid_str_to_bytes(uuid: &str) -> Result<[u8; 16], AbsintheError> { + let hex_str: String = uuid.chars().filter(|c| *c != '-').collect(); + if hex_str.len() != 32 { + return Err(AbsintheError::Other(format!( + "Invalid UUID length: expected 32 hex chars, got {} from '{}'", + hex_str.len(), + uuid + ))); + } + let mut bytes = [0u8; 16]; + for i in 0..16 { + bytes[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).map_err(|e| { + AbsintheError::Other(format!("Invalid hex in UUID '{}': {}", uuid, e)) + })?; + } + Ok(bytes) +} + +/// Given a HardwareConfig, compute any missing `_enc` fields from their +/// plaintext counterparts using the XNU kernel encryption function. +/// This is needed for keys extracted from macOS High Sierra (10.13) which +/// lacks the encrypted IOKit properties in its kernel. +#[cfg(has_xnu_encrypt)] +fn compute_missing_enc_fields(hw: &mut HardwareConfig) -> Result<(), AbsintheError> { + // platform_serial_number → Gq3489ugfi (serial as string bytes) + if hw.platform_serial_number_enc.is_empty() && !hw.platform_serial_number.is_empty() { + info!("Computing missing platform_serial_number_enc (Gq3489ugfi) from plaintext"); + hw.platform_serial_number_enc = + encrypt_io_property(hw.platform_serial_number.as_bytes())?; + } + + // platform_uuid → Fyp98tpgj (UUID as 16 raw bytes) + if hw.platform_uuid_enc.is_empty() && !hw.platform_uuid.is_empty() { + info!("Computing missing platform_uuid_enc (Fyp98tpgj) from plaintext"); + let uuid_bytes = uuid_str_to_bytes(&hw.platform_uuid)?; + hw.platform_uuid_enc = encrypt_io_property(&uuid_bytes)?; + } + + // root_disk_uuid → kbjfrfpoJU (UUID as 16 raw bytes) + if hw.root_disk_uuid_enc.is_empty() && !hw.root_disk_uuid.is_empty() { + info!("Computing missing root_disk_uuid_enc (kbjfrfpoJU) from plaintext"); + let uuid_bytes = uuid_str_to_bytes(&hw.root_disk_uuid)?; + hw.root_disk_uuid_enc = encrypt_io_property(&uuid_bytes)?; + } + + // mlb → abKPld1EcMni (MLB as raw string bytes) + if hw.mlb_enc.is_empty() && !hw.mlb.is_empty() { + info!("Computing missing mlb_enc (abKPld1EcMni) from plaintext"); + hw.mlb_enc = encrypt_io_property(hw.mlb.as_bytes())?; + } + + // rom → oycqAZloTNDm (ROM as 6 raw bytes) + if hw.rom_enc.is_empty() && !hw.rom.is_empty() { + info!("Computing missing rom_enc (oycqAZloTNDm) from plaintext"); + hw.rom_enc = encrypt_io_property(&hw.rom)?; + } + + Ok(()) +} + +/// Public wrapper for `compute_missing_enc_fields`. +/// On x86_64 Linux, derives any absent `_enc` fields from their plaintext +/// counterparts using the XNU kernel encryption function. +/// On other platforms, returns an error. +pub fn enrich_missing_enc_fields(hw: &mut HardwareConfig) -> Result<(), AbsintheError> { + #[cfg(has_xnu_encrypt)] + { + compute_missing_enc_fields(hw)?; + Ok(()) + } + #[cfg(not(has_xnu_encrypt))] + { + Err(AbsintheError::Other( + "Missing _enc field derivation is only supported on x86_64 Linux builds".into(), + )) + } +} + +// ============================================================================ +// Serde helpers +// ============================================================================ + +pub fn bin_serialize<S>(x: &[u8], s: S) -> Result<S::Ok, S::Error> +where + S: Serializer, +{ + s.serialize_bytes(x) +} + +pub fn bin_deserialize_mac<'de, D>(d: D) -> Result<[u8; 6], D::Error> +where + D: Deserializer<'de>, +{ + let v = bin_deserialize(d)?; + if v.is_empty() { + return Ok([0u8; 6]); + } + v.try_into().map_err(|v: Vec<u8>| { + de::Error::custom(format!("expected 6 bytes for MAC address, got {}", v.len())) + }) +} + +pub fn bin_deserialize<'de, D>(d: D) -> Result<Vec<u8>, D::Error> +where + D: Deserializer<'de>, +{ + struct DataVisitor; + + impl<'de> de::Visitor<'de> for DataVisitor { + type Value = Vec<u8>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a byte array, sequence of u8, or null") + } + + fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E> + where + E: de::Error, + { + Ok(v.to_owned()) + } + + fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E> + where + E: de::Error, + { + Ok(v) + } + + // serde_json represents byte arrays as JSON arrays [u8, u8, ...], + // which calls visit_seq instead of visit_bytes. + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> + where + A: de::SeqAccess<'de>, + { + let mut bytes = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(b) = seq.next_element::<u8>()? { + bytes.push(b); + } + Ok(bytes) + } + + // Handle JSON null → empty Vec (Apple Silicon Macs lack _enc fields) + fn visit_unit<E>(self) -> Result<Self::Value, E> + where + E: de::Error, + { + Ok(Vec::new()) + } + + fn visit_none<E>(self) -> Result<Self::Value, E> + where + E: de::Error, + { + Ok(Vec::new()) + } + } + + d.deserialize_any(DataVisitor) +} + +// ============================================================================ +// HardwareConfig +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HardwareConfig { + pub product_name: String, + #[serde(serialize_with = "bin_serialize", deserialize_with = "bin_deserialize_mac")] + pub io_mac_address: [u8; 6], + pub platform_serial_number: String, + pub platform_uuid: String, + pub root_disk_uuid: String, + pub board_id: String, + pub os_build_num: String, + #[serde(serialize_with = "bin_serialize", deserialize_with = "bin_deserialize")] + pub platform_serial_number_enc: Vec<u8>, // Gq3489ugfi + #[serde(serialize_with = "bin_serialize", deserialize_with = "bin_deserialize")] + pub platform_uuid_enc: Vec<u8>, // Fyp98tpgj + #[serde(serialize_with = "bin_serialize", deserialize_with = "bin_deserialize")] + pub root_disk_uuid_enc: Vec<u8>, // kbjfrfpoJU + #[serde(serialize_with = "bin_serialize", deserialize_with = "bin_deserialize")] + pub rom: Vec<u8>, + #[serde(serialize_with = "bin_serialize", deserialize_with = "bin_deserialize")] + pub rom_enc: Vec<u8>, // oycqAZloTNDm + pub mlb: String, + #[serde(serialize_with = "bin_serialize", deserialize_with = "bin_deserialize")] + pub mlb_enc: Vec<u8>, // abKPld1EcMni +} + +impl HardwareConfig { + pub fn from_validation_data(_data: &[u8]) -> Result<HardwareConfig, AbsintheError> { + Err(AbsintheError::Other( + "from_validation_data is not supported in emulation mode".into(), + )) + } +} + +// ============================================================================ +// Constants +// ============================================================================ + +const BINARY_URL: &str = + "https://github.com/JJTech0130/nacserver/raw/main/IMDAppleServices"; +const BINARY_SHA1: &str = "e1181ccad82e6629d52c6a006645ad87ee59bd13"; + +const HOOK_BASE: u64 = 0xD0_0000; +const HOOK_SIZE: u64 = 0x1000; +const STACK_BASE: u64 = 0x30_0000; +const STACK_SIZE: u64 = 0x10_0000; +const HEAP_BASE: u64 = 0x40_0000; +const HEAP_SIZE: u64 = 0x10_0000; +const STOP_ADDR: u64 = 0x90_0000; + +const NAC_INIT: u64 = 0xB_1DB0; +const NAC_KEY_EST: u64 = 0xB_1DD0; +const NAC_SIGN: u64 = 0xB_1DF0; + +/// x86-64 SysV integer argument registers, in order. +const ARG_REGS: [RegisterX86; 6] = [ + RegisterX86::RDI, + RegisterX86::RSI, + RegisterX86::RDX, + RegisterX86::RCX, + RegisterX86::R8, + RegisterX86::R9, +]; + +// ============================================================================ +// CF Object types for the emulated CoreFoundation +// ============================================================================ + +#[derive(Clone, Debug)] +enum CfObj { + Data(Vec<u8>), + Str(String), + Dict(HashMap<String, usize>), // values are 1-based cf_objects indices +} + +// ============================================================================ +// Emulator shared state +// ============================================================================ + +struct EmuState { + cf_objects: Vec<CfObj>, + heap_use: u64, + hook_map: HashMap<u64, String>, // hook addr → symbol name + hw_iokit: HashMap<String, CfObj>, + root_disk_uuid: String, + eth_iterator_hack: bool, +} + +impl EmuState { + fn new(hw: &HardwareConfig) -> Self { + let mut iokit = HashMap::new(); + + // Data (raw bytes) properties + iokit.insert( + "4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:MLB".into(), + CfObj::Data(hw.mlb.as_bytes().to_vec()), + ); + iokit.insert( + "4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:ROM".into(), + CfObj::Data(hw.rom.clone()), + ); + // Encrypted/obfuscated IOKit properties — present on Intel Macs. + // On Apple Silicon these are empty; skip them so the binary gets NULL + // (same as a real Apple Silicon Mac's IOKit registry). + if !hw.platform_uuid_enc.is_empty() { + iokit.insert("Fyp98tpgj".into(), CfObj::Data(hw.platform_uuid_enc.clone())); + } + if !hw.platform_serial_number_enc.is_empty() { + iokit.insert( + "Gq3489ugfi".into(), + CfObj::Data(hw.platform_serial_number_enc.clone()), + ); + } + iokit.insert("IOMACAddress".into(), CfObj::Data(hw.io_mac_address.to_vec())); + if !hw.mlb_enc.is_empty() { + iokit.insert("abKPld1EcMni".into(), CfObj::Data(hw.mlb_enc.clone())); + } + if !hw.root_disk_uuid_enc.is_empty() { + iokit.insert("kbjfrfpoJU".into(), CfObj::Data(hw.root_disk_uuid_enc.clone())); + } + if !hw.rom_enc.is_empty() { + iokit.insert("oycqAZloTNDm".into(), CfObj::Data(hw.rom_enc.clone())); + } + + // String properties + iokit.insert( + "IOPlatformSerialNumber".into(), + CfObj::Str(hw.platform_serial_number.clone()), + ); + iokit.insert("IOPlatformUUID".into(), CfObj::Str(hw.platform_uuid.clone())); + + // Null-terminated C-string data + let mut pn = hw.product_name.as_bytes().to_vec(); + pn.push(0); + iokit.insert("product-name".into(), CfObj::Data(pn)); + let mut bi = hw.board_id.as_bytes().to_vec(); + bi.push(0); + iokit.insert("board-id".into(), CfObj::Data(bi)); + + Self { + cf_objects: Vec::new(), + heap_use: 0, + hook_map: HashMap::new(), + hw_iokit: iokit, + root_disk_uuid: hw.root_disk_uuid.clone(), + eth_iterator_hack: false, + } + } + + fn heap_alloc(&mut self, size: u64) -> u64 { + // Align to 16 bytes + let aligned = (size + 15) & !15; + let addr = HEAP_BASE + self.heap_use; + self.heap_use += aligned; + if self.heap_use > HEAP_SIZE { + panic!("NAC emulator heap overflow ({} > {})", self.heap_use, HEAP_SIZE); + } + addr + } + + /// Add a CF object, return its 1-based "pointer". + fn cf_add(&mut self, obj: CfObj) -> u64 { + self.cf_objects.push(obj); + self.cf_objects.len() as u64 + } + + fn cf_get(&self, id: u64) -> Option<&CfObj> { + if id == 0 || (id as usize) > self.cf_objects.len() { + None + } else { + Some(&self.cf_objects[(id as usize) - 1]) + } + } +} + +// ============================================================================ +// Binary management +// ============================================================================ + +/// Get the NAC binary bytes, downloading and caching if necessary. +fn get_binary() -> Result<Vec<u8>, AbsintheError> { + let cache_path = "state/IMDAppleServices"; + + // Try reading from cache first + if let Ok(data) = fs::read(cache_path) { + let hash = hex::encode(Sha1::digest(&data)); + if hash == BINARY_SHA1 { + debug!("Using cached IMDAppleServices binary"); + return Ok(data); + } + warn!("Cached binary hash mismatch ({}), re-downloading", hash); + } + + info!("Downloading IMDAppleServices binary from {}", BINARY_URL); + let resp = ureq::get(BINARY_URL) + .call() + .map_err(|e| AbsintheError::Other(format!("Failed to download NAC binary: {}", e)))?; + + let mut data = Vec::new(); + resp.into_reader() + .read_to_end(&mut data) + .map_err(|e| AbsintheError::Other(format!("Failed to read NAC binary: {}", e)))?; + + let hash = hex::encode(Sha1::digest(&data)); + if hash != BINARY_SHA1 { + return Err(AbsintheError::Other(format!( + "NAC binary SHA1 mismatch: expected {}, got {}", + BINARY_SHA1, hash + ))); + } + + let _ = fs::create_dir_all("state"); + if let Err(e) = fs::write(cache_path, &data) { + warn!("Failed to cache NAC binary: {}", e); + } + + info!("Downloaded and verified IMDAppleServices binary ({} bytes)", data.len()); + Ok(data) +} + +/// Extract the x86_64 slice from a (possibly fat) Mach-O binary. +fn extract_x86_64_slice(data: &[u8]) -> Result<Vec<u8>, AbsintheError> { + match Mach::parse(data).map_err(|e| AbsintheError::Other(format!("Mach-O parse error: {}", e)))? { + Mach::Fat(fat) => { + for arch in fat.iter_arches() { + let arch = arch.map_err(|e| { + AbsintheError::Other(format!("Fat arch parse error: {}", e)) + })?; + if arch.cputype == CPU_TYPE_X86_64 { + let start = arch.offset as usize; + let end = start + arch.size as usize; + if end > data.len() { + return Err(AbsintheError::Other( + "x86_64 slice extends beyond file".into(), + )); + } + return Ok(data[start..end].to_vec()); + } + } + Err(AbsintheError::Other("No x86_64 slice in fat binary".into())) + } + Mach::Binary(_) => { + // Already a single-arch binary, use as-is. + Ok(data.to_vec()) + } + } +} + +// ============================================================================ +// Hex encoding helper (avoid adding another dep) +// ============================================================================ +mod hex { + pub fn encode(data: impl AsRef<[u8]>) -> String { + data.as_ref().iter().map(|b| format!("{:02x}", b)).collect() + } +} + +// ============================================================================ +// Unicorn helpers +// ============================================================================ + +fn uc_read_u64(uc: &Unicorn<()>, addr: u64) -> u64 { + let buf = uc.mem_read_as_vec(addr, 8).unwrap_or_default(); + u64::from_le_bytes(buf.try_into().unwrap_or([0u8; 8])) +} + +fn uc_write_u64(uc: &mut Unicorn<()>, addr: u64, val: u64) { + uc.mem_write(addr, &val.to_le_bytes()).unwrap(); +} + +fn uc_read_cstr(uc: &Unicorn<()>, addr: u64, max: usize) -> String { + let buf = uc.mem_read_as_vec(addr, max).unwrap_or_default(); + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..end]).into_owned() +} + +/// Parse a __builtin_CFString pointer: { isa, flags, str_ptr, length } +fn uc_read_cfstr(uc: &Unicorn<()>, ptr: u64) -> String { + let buf = uc.mem_read_as_vec(ptr, 32).unwrap_or_default(); + if buf.len() < 32 { + return String::new(); + } + // isa = bytes 0..8 (ignored) + // flags = bytes 8..16 (ignored) + let str_ptr = u64::from_le_bytes(buf[16..24].try_into().unwrap()); + let length = u64::from_le_bytes(buf[24..32].try_into().unwrap()); + let str_data = uc.mem_read_as_vec(str_ptr, length as usize).unwrap_or_default(); + String::from_utf8_lossy(&str_data).into_owned() +} + +fn arg(uc: &Unicorn<()>, n: usize) -> u64 { + uc.reg_read(ARG_REGS[n]).unwrap_or(0) +} + +fn set_ret(uc: &mut Unicorn<()>, val: u64) { + uc.reg_write(RegisterX86::RAX, val).unwrap(); +} + +// ============================================================================ +// Hook dispatch +// ============================================================================ + +/// Master hook dispatcher — called by the Unicorn code hook for every +/// instruction in the HOOK_BASE region. +fn dispatch(uc: &mut Unicorn<()>, state: &mut EmuState, addr: u64) { + let name = match state.hook_map.get(&addr) { + Some(n) => n.clone(), + None => return, + }; + + let ret: Option<u64> = match name.as_str() { + // Memory / system + "_malloc" => { + let size = arg(uc, 0); + Some(state.heap_alloc(size.max(1))) + } + "_free" => Some(0), + "___stack_chk_guard" => Some(0), + "___memset_chk" => { + let dest = arg(uc, 0); + let c = arg(uc, 1) as u8; + let len = arg(uc, 2) as usize; + let buf = vec![c; len]; + uc.mem_write(dest, &buf).unwrap(); + Some(0) + } + "_memcpy" => { + let dest = arg(uc, 0); + let src = arg(uc, 1); + let len = arg(uc, 2) as usize; + let buf = uc.mem_read_as_vec(src, len).unwrap_or_default(); + uc.mem_write(dest, &buf).unwrap(); + Some(0) + } + "___bzero" => { + let ptr = arg(uc, 0); + let len = arg(uc, 1) as usize; + let buf = vec![0u8; len]; + uc.mem_write(ptr, &buf).unwrap(); + Some(0) + } + "_sysctlbyname" => Some(0), + "_statfs$INODE64" => Some(0), + "_arc4random" => Some(rand::random::<u32>() as u64), + + // IOKit + "_kIOMasterPortDefault" => Some(0), // data symbol, value doesn't matter + "_IORegistryEntryFromPath" => Some(1), + "_IORegistryEntryCreateCFProperty" => { + // (entry, key_cfstr, allocator, options) -> cf_id + let key_ptr = arg(uc, 1); + let key_name = uc_read_cfstr(uc, key_ptr); + debug!("IORegistryEntryCreateCFProperty: key={}", key_name); + if let Some(obj) = state.hw_iokit.get(&key_name).cloned() { + Some(state.cf_add(obj)) + } else { + debug!(" -> key not found, returning NULL"); + Some(0) + } + } + "_IOServiceMatching" => { + let name_ptr = arg(uc, 0); + let name = uc_read_cstr(uc, name_ptr, 256); + debug!("IOServiceMatching: {}", name); + let str_id = state.cf_add(CfObj::Str(name)); + let dict_id = { + let mut d = HashMap::new(); + d.insert("IOProviderClass".into(), str_id as usize); + state.cf_add(CfObj::Dict(d)) + }; + Some(dict_id) + } + "_IOServiceGetMatchingService" => Some(92), + "_IOServiceGetMatchingServices" => { + state.eth_iterator_hack = true; + // Write iterator handle (93) to the output pointer (arg 2) + let existing_ptr = arg(uc, 2); + uc.mem_write(existing_ptr, &[93u8]).unwrap(); + Some(0) + } + "_IOIteratorNext" => { + if state.eth_iterator_hack { + state.eth_iterator_hack = false; + Some(94) + } else { + Some(0) + } + } + "_IORegistryEntryGetParentEntry" => { + let entry = arg(uc, 0) as u8; + let parent_ptr = arg(uc, 2); + uc.mem_write(parent_ptr, &[entry.wrapping_add(100)]).unwrap(); + Some(0) + } + "_IOObjectRelease" => Some(0), + + // DiskArbitration + "_DASessionCreate" => Some(201), + "_DADiskCreateFromBSDName" => Some(202), + "_DADiskCopyDescription" => { + // Create a dictionary containing the volume UUID + let mut d = HashMap::new(); + let uuid_id = state.cf_add(CfObj::Str(state.root_disk_uuid.clone())); + d.insert("DADiskDescriptionVolumeUUIDKey".into(), uuid_id as usize); + Some(state.cf_add(CfObj::Dict(d))) + } + "_kDADiskDescriptionVolumeUUIDKey" => Some(0), // data symbol + + // CoreFoundation allocator / boolean + "_kCFAllocatorDefault" => Some(0), + "_kCFBooleanTrue" => Some(0), + + // CoreFoundation type IDs + "_CFGetTypeID" => { + let obj_id = arg(uc, 0); + match state.cf_get(obj_id) { + Some(CfObj::Data(_)) => Some(1), + Some(CfObj::Str(_)) => Some(2), + _ => Some(0), + } + } + "_CFStringGetTypeID" => Some(2), + "_CFDataGetTypeID" => Some(1), + + // CFData + "_CFDataGetLength" => { + let obj_id = arg(uc, 0); + match state.cf_get(obj_id) { + Some(CfObj::Data(d)) => Some(d.len() as u64), + _ => Some(0), + } + } + "_CFDataGetBytes" => { + let obj_id = arg(uc, 0); + let range_start = arg(uc, 1) as usize; + let range_end = arg(uc, 2) as usize; + let buf_ptr = arg(uc, 3); + if let Some(CfObj::Data(d)) = state.cf_get(obj_id).cloned() { + let slice = &d[range_start..range_end.min(d.len())]; + uc.mem_write(buf_ptr, slice).unwrap(); + Some(slice.len() as u64) + } else { + Some(0) + } + } + + // CFString + "_CFStringGetLength" => { + let obj_id = arg(uc, 0); + match state.cf_get(obj_id) { + Some(CfObj::Str(s)) => Some(s.len() as u64), + _ => Some(0), + } + } + "_CFStringGetMaximumSizeForEncoding" => { + let length = arg(uc, 0); + Some(length) // UTF-8 max = length for ASCII + } + "_CFStringGetCString" => { + let obj_id = arg(uc, 0); + let buf_ptr = arg(uc, 1); + if let Some(CfObj::Str(s)) = state.cf_get(obj_id).cloned() { + uc.mem_write(buf_ptr, s.as_bytes()).unwrap(); + Some(s.len() as u64) + } else { + Some(0) + } + } + "_CFUUIDCreateString" => { + // (allocator, uuid) -> uuid (pass-through) + Some(arg(uc, 1)) + } + + // CFDictionary + "_CFDictionaryCreateMutable" => { + Some(state.cf_add(CfObj::Dict(HashMap::new()))) + } + "_CFDictionarySetValue" => { + let dict_id = arg(uc, 0); + let key_raw = arg(uc, 1); + let val_raw = arg(uc, 2); + // Resolve key to string + let key_str = resolve_cf_or_raw(state, key_raw); + let val_idx = val_raw as usize; + if dict_id > 0 && (dict_id as usize) <= state.cf_objects.len() { + if let CfObj::Dict(ref mut d) = state.cf_objects[(dict_id as usize) - 1] { + d.insert(key_str, val_idx); + } + } + None // void return + } + "_CFDictionaryGetValue" => { + let dict_id = arg(uc, 0); + let key_raw = arg(uc, 1); + // The _kDADiskDescriptionVolumeUUIDKey data symbol resolves to a + // hook address filled with 0xC3 bytes, so reading 8 bytes yields + // 0xC3C3C3C3C3C3C3C3. Recognise that sentinel. + let key_str = if key_raw == 0xC3C3_C3C3_C3C3_C3C3 { + "DADiskDescriptionVolumeUUIDKey".to_string() + } else { + resolve_cf_or_raw(state, key_raw) + }; + debug!("CFDictionaryGetValue dict={} key={}", dict_id, key_str); + if let Some(CfObj::Dict(d)) = state.cf_get(dict_id).cloned() { + if let Some(&val_idx) = d.get(&key_str) { + // Re-add the referenced object so we return a fresh id + if let Some(obj) = state.cf_objects.get(val_idx - 1).cloned() { + Some(state.cf_add(obj)) + } else { + Some(val_idx as u64) + } + } else { + warn!("CFDictionaryGetValue: key '{}' not found", key_str); + Some(0) + } + } else { + Some(0) + } + } + + // CFRelease + "_CFRelease" => Some(0), + + _ => { + debug!("Unhandled hook: {}", name); + Some(0) + } + }; + + if let Some(v) = ret { + set_ret(uc, v); + } +} + +/// Resolve a value that might be a CF object id or a raw pointer. +fn resolve_cf_or_raw(state: &EmuState, val: u64) -> String { + if val > 0 && (val as usize) <= state.cf_objects.len() { + match &state.cf_objects[(val as usize) - 1] { + CfObj::Str(s) => return s.clone(), + CfObj::Data(d) => { + // Try interpreting as a C string + let end = d.iter().position(|&b| b == 0).unwrap_or(d.len()); + return String::from_utf8_lossy(&d[..end]).into_owned(); + } + _ => {} + } + } + // Return the raw value as a string for use as a dict key + format!("0x{:x}", val) +} + +// ============================================================================ +// Hook symbol list +// ============================================================================ + +/// All external symbols that the NAC binary might reference. +const HOOK_SYMBOLS: &[&str] = &[ + "_malloc", + "_free", + "___stack_chk_guard", + "___memset_chk", + "_memcpy", + "___bzero", + "_sysctlbyname", + "_statfs$INODE64", + "_arc4random", + "_kIOMasterPortDefault", + "_IORegistryEntryFromPath", + "_IORegistryEntryCreateCFProperty", + "_IOServiceMatching", + "_IOServiceGetMatchingService", + "_IOServiceGetMatchingServices", + "_IOIteratorNext", + "_IORegistryEntryGetParentEntry", + "_IOObjectRelease", + "_DASessionCreate", + "_DADiskCreateFromBSDName", + "_DADiskCopyDescription", + "_kDADiskDescriptionVolumeUUIDKey", + "_kCFAllocatorDefault", + "_kCFBooleanTrue", + "_CFGetTypeID", + "_CFStringGetTypeID", + "_CFDataGetTypeID", + "_CFDataGetLength", + "_CFDataGetBytes", + "_CFStringGetLength", + "_CFStringGetMaximumSizeForEncoding", + "_CFStringGetCString", + "_CFUUIDCreateString", + "_CFDictionaryCreateMutable", + "_CFDictionarySetValue", + "_CFDictionaryGetValue", + "_CFRelease", +]; + +// ============================================================================ +// ValidationCtx +// ============================================================================ + +/// Public validation context used by rustpush's `MacOSConfig` to generate +/// APNs validation data. Two internal modes: +/// +/// - **Emulator** (default): runs Apple's NAC binary inside a Unicorn x86-64 +/// emulator, feeding it IOKit-equivalent hardware identifiers (with optional +/// `_enc` fields) and handshaking with Apple's validation servers. +/// +/// - **Native** (macOS + `native-nac` feature): delegates each NAC step to +/// Apple's private `AAAbsintheContext` class via our `nac-validation` +/// crate's three-step API (`NacContext::init` → `key_establishment` → +/// `sign`). `new()` calls `NACInit(cert)` and returns the real request +/// bytes that upstream `MacOSConfig::generate_validation_data` then POSTs +/// to `id-initialize-validation`; `key_establishment()` feeds Apple's +/// response back into the same context; `sign()` produces the final +/// validation bytes. Upstream rustpush remains entirely unmodified — the +/// exact same HTTP flow runs, it just happens to be driven by +/// `AAAbsintheContext` instead of the unicorn emulator. `_enc` hardware +/// fields are irrelevant on this path because AAAbsintheContext reads the +/// real hardware identifiers from IOKit itself. +pub struct ValidationCtx { + inner: ValidationCtxInner, +} + +enum ValidationCtxInner { + Emulator { + uc: UnsafeCell<Unicorn<'static, ()>>, + state: Rc<RefCell<EmuState>>, + validation_ctx_addr: u64, + }, + #[cfg(all(target_os = "macos", feature = "native-nac"))] + Native { + // UnsafeCell so `sign(&self)` can call NacContext's `&mut self` + // methods without violating aliasing rules. ValidationCtx is only + // used from a single task — the `unsafe impl Send` below mirrors + // that invariant. + ctx: UnsafeCell<nac_validation::NacContext>, + /// Pre-computed result from the one-shot `generate_validation_data()`. + /// When Some, `sign()` returns this directly — the one-shot already + /// completed the full NACInit → POST → NACKeyEstablishment → NACSign + /// sequence internally. The 3-step `ctx` is still driven for the + /// upstream HTTP flow (so MacOSConfig's POST succeeds), but its + /// NACSign result is discarded in favour of this authoritative bytes. + final_data: Option<Vec<u8>>, + }, + /// Relay mode: single-shot POST to a `tools/nac-relay` server running + /// on a real Mac. The relay does the full NACInit → Apple POST → + /// NACKeyEstablishment → NACSign dance internally and returns the + /// final validation data. Used when the hardware-key JSON carries a + /// `nac_relay_url` (Apple Silicon Macs whose keys can't run in the + /// unicorn x86-64 emulator). + Relay { + /// Complete validation data returned by the relay's single-shot + /// `/validation-data` endpoint. + validation_data: Vec<u8>, + }, +} + +unsafe impl Send for ValidationCtx {} + +impl ValidationCtx { + /// Initialise NAC validation. + /// + /// * `cert_chain` — certificate bytes from Apple's validation cert endpoint + /// * `out_request_bytes` — filled with the session-info-request to send to Apple + /// * `hw_config` — hardware identifiers (extracted from a real Mac) + pub fn new( + cert_chain: &[u8], + out_request_bytes: &mut Vec<u8>, + hw_config: &HardwareConfig, + ) -> Result<ValidationCtx, AbsintheError> { + // ==================================================================== + // Relay path — Apple Silicon hardware keys routed through a Mac-hosted + // `tools/nac-relay` server. Single POST to `/validation-data` returns + // the complete validation data (the relay does the full NACInit → + // Apple POST → NACKeyEstablishment → NACSign dance internally). + // We store the result and return it from sign(); key_establishment + // is a no-op. out_request_bytes is left empty since the relay + // already completed the Apple handshake server-side. + // ==================================================================== + if let Some(cfg) = get_relay_config() { + info!("NAC relay: single-shot POST to {}", cfg.url); + let agent = relay_agent(&cfg)?; + let mut req = agent.post(&cfg.url); + if let Some(ref tok) = cfg.token { + req = req.set("Authorization", &format!("Bearer {tok}")); + } + match req.call() { + Ok(resp) => { + use base64::Engine; + let body = resp.into_string() + .map_err(|e| AbsintheError::Other(format!("relay read error: {e}")))?; + let validation_data = base64::engine::general_purpose::STANDARD + .decode(body.trim()) + .map_err(|e| AbsintheError::Other(format!("relay base64 decode: {e}")))?; + info!("NAC relay: got {} bytes of validation data", validation_data.len()); + // Stash the relay data for sign() and pre-fetch. + // out_request_bytes left empty — the bridge + // pre-fetches and uses RelayOSConfig to return + // relay data directly from generate_validation_data(), + // bypassing the Apple handshake entirely (same as master). + return Ok(ValidationCtx { + inner: ValidationCtxInner::Relay { validation_data }, + }); + } + Err(e) => { + return Err(AbsintheError::Other(format!( + "NAC relay failed ({}): {e}. \ + Ensure the nac-relay server is running on the Mac that provided \ + this hardware key.", + cfg.url + ))); + } + } + } + + // ==================================================================== + // Native NAC fast path — macOS only, requires `native-nac` feature. + // Delegates to AAAbsintheContext via the `nac-validation` crate. The + // unicorn emulator is entirely skipped; hardware `_enc` fields are + // irrelevant because Apple's native framework reads real hardware + // identifiers from IOKit itself. + // + // Strategy: try the one-shot first. It runs the full protocol + // internally (fetch cert → NACInit → POST → NACKeyEstablishment → + // NACSign) and is the most reliable path. We then ALSO run + // NacContext::init to get valid NACInit request bytes for the + // upstream MacOSConfig HTTP flow (POST to id-initialize-validation). + // sign() returns the pre-computed one-shot result, making the + // 3-step ctx's NACSign call unnecessary. + // + // If the one-shot fails (rare), we fall back to using the 3-step + // ctx result from NACSign — same behaviour as before. + // ==================================================================== + #[cfg(all(target_os = "macos", feature = "native-nac"))] + { + info!( + "NAC native path: attempting one-shot via \ + nac_validation::generate_validation_data() …" + ); + let one_shot_result = match nac_validation::generate_validation_data() { + Ok(data) => { + info!( + "NAC one-shot succeeded: {} bytes (will use as final result)", + data.len() + ); + Some(data) + } + Err(e) => { + warn!( + "NAC one-shot failed (will fall back to 3-step result): {}", + e + ); + None + } + }; + + match nac_validation::NacContext::init(cert_chain) { + Ok((ctx, request_bytes)) => { + info!( + "NAC native path: NacContext::init produced {} request bytes", + request_bytes.len() + ); + *out_request_bytes = request_bytes; + return Ok(ValidationCtx { + inner: ValidationCtxInner::Native { + ctx: UnsafeCell::new(ctx), + final_data: one_shot_result, + }, + }); + } + Err(e) => { + warn!( + "NAC native path: NacContext::init failed, falling back to emulator: {}", + e + ); + // Fall through to emulator path below. + } + } + } + + // Log hardware key diagnostics + info!("NAC init: product={} serial={} board={} build={}", + hw_config.product_name, hw_config.platform_serial_number, + hw_config.board_id, hw_config.os_build_num); + info!("NAC init: uuid={} rom={} bytes mlb={} mac={} bytes", + hw_config.platform_uuid, hw_config.rom.len(), + hw_config.mlb, hw_config.io_mac_address.len()); + info!("NAC init: _enc fields: serial_enc={} uuid_enc={} disk_enc={} rom_enc={} mlb_enc={}", + hw_config.platform_serial_number_enc.len(), + hw_config.platform_uuid_enc.len(), + hw_config.root_disk_uuid_enc.len(), + hw_config.rom_enc.len(), + hw_config.mlb_enc.len()); + + // 0. Compute missing _enc fields if we have the XNU encrypt function + // (needed for keys extracted from macOS High Sierra 10.13) + #[allow(unused_mut)] + let hw_config = { + let mut hw = hw_config.clone(); + #[cfg(has_xnu_encrypt)] + compute_missing_enc_fields(&mut hw)?; + hw + }; + + // 1. Load the NAC binary + let raw = get_binary()?; + let binary = extract_x86_64_slice(&raw)?; + + // 2. Create emulator state + let state = Rc::new(RefCell::new(EmuState::new(&hw_config))); + + // 3. Create Unicorn x86-64 engine + let mut uc = Unicorn::new(Arch::X86, Mode::MODE_64) + .map_err(|e| AbsintheError::Other(format!("Unicorn init failed: {:?}", e)))?; + + // 4. Map binary at address 0 + let bin_pages = round_up(binary.len(), 0x1000) as u64; + uc.mem_map(0, bin_pages, Prot::ALL) + .map_err(|e| AbsintheError::Other(format!("Failed to map binary: {:?}", e)))?; + uc.mem_write(0, &binary) + .map_err(|e| AbsintheError::Other(format!("Failed to write binary: {:?}", e)))?; + + // 5. Map hook space (filled with RET instructions) + uc.mem_map(HOOK_BASE, HOOK_SIZE, Prot::ALL) + .map_err(|e| AbsintheError::Other(format!("Failed to map hooks: {:?}", e)))?; + uc.mem_write(HOOK_BASE, &vec![0xC3u8; HOOK_SIZE as usize]) + .map_err(|e| AbsintheError::Other(format!("Failed to write hooks: {:?}", e)))?; + + // 6. Map stack + uc.mem_map(STACK_BASE, STACK_SIZE, Prot::ALL) + .map_err(|e| AbsintheError::Other(format!("Failed to map stack: {:?}", e)))?; + uc.reg_write(RegisterX86::RSP, STACK_BASE + STACK_SIZE) + .map_err(|e| AbsintheError::Other(format!("Failed to set RSP: {:?}", e)))?; + uc.reg_write(RegisterX86::RBP, STACK_BASE + STACK_SIZE) + .map_err(|e| AbsintheError::Other(format!("Failed to set RBP: {:?}", e)))?; + + // 7. Map heap + uc.mem_map(HEAP_BASE, HEAP_SIZE, Prot::ALL) + .map_err(|e| AbsintheError::Other(format!("Failed to map heap: {:?}", e)))?; + + // 8. Map stop page + uc.mem_map(STOP_ADDR, 0x1000, Prot::ALL) + .map_err(|e| AbsintheError::Other(format!("Failed to map stop page: {:?}", e)))?; + uc.mem_write(STOP_ADDR, &vec![0xC3u8; 0x1000]) + .map_err(|e| AbsintheError::Other(format!("Failed to write stop page: {:?}", e)))?; + + // 9. Assign hook addresses and build lookup table + { + let mut st = state.borrow_mut(); + for (i, &sym) in HOOK_SYMBOLS.iter().enumerate() { + let addr = HOOK_BASE + i as u64; + st.hook_map.insert(addr, sym.to_string()); + } + } + + // 10. Resolve Mach-O imports → write hook addresses into GOT + resolve_imports(&mut uc, &binary, &state.borrow())?; + + // 11. Add code hook for the hook space + let hook_state = state.clone(); + uc.add_code_hook(HOOK_BASE, HOOK_BASE + HOOK_SIZE - 1, move |uc, addr, _size| { + let mut st = hook_state.borrow_mut(); + dispatch(uc, &mut st, addr); + }) + .map_err(|e| AbsintheError::Other(format!("Failed to add code hook: {:?}", e)))?; + + // 12. Call nac_init + let cert_addr; + let out_ctx_ptr; + let out_req_ptr; + let out_req_len_ptr; + { + let mut st = state.borrow_mut(); + cert_addr = st.heap_alloc(cert_chain.len() as u64); + out_ctx_ptr = st.heap_alloc(8); + out_req_ptr = st.heap_alloc(8); + out_req_len_ptr = st.heap_alloc(8); + } + uc.mem_write(cert_addr, cert_chain) + .map_err(|e| AbsintheError::Other(format!("Failed to write cert: {:?}", e)))?; + + let ret = call_func( + &mut uc, + NAC_INIT, + &[ + cert_addr, + cert_chain.len() as u64, + out_ctx_ptr, + out_req_ptr, + out_req_len_ptr, + ], + )?; + + if ret != 0 { + return Err(AbsintheError::NacError(-(ret as i64 as i32))); + } + + // Read outputs + let validation_ctx_addr = uc_read_u64(&uc, out_ctx_ptr); + let req_bytes_addr = uc_read_u64(&uc, out_req_ptr); + let req_len = uc_read_u64(&uc, out_req_len_ptr) as usize; + + debug!( + "nac_init: ctx=0x{:x} req_addr=0x{:x} req_len={}", + validation_ctx_addr, req_bytes_addr, req_len + ); + + let request_data = uc + .mem_read_as_vec(req_bytes_addr, req_len) + .map_err(|e| AbsintheError::Other(format!("Failed to read request: {:?}", e)))?; + *out_request_bytes = request_data; + + Ok(ValidationCtx { + inner: ValidationCtxInner::Emulator { + uc: UnsafeCell::new(uc), + state, + validation_ctx_addr, + }, + }) + } + + /// Process the session-info response from Apple. + pub fn key_establishment(&mut self, response: &[u8]) -> Result<(), AbsintheError> { + match &mut self.inner { + ValidationCtxInner::Relay { .. } => { + // No-op — the relay already completed the full handshake + // in the single-shot /validation-data call during new(). + Ok(()) + } + #[cfg(all(target_os = "macos", feature = "native-nac"))] + ValidationCtxInner::Native { ctx, .. } => { + debug!( + "NAC key_establishment: native path, {} bytes -> AAAbsintheContext", + response.len() + ); + // Drive key_establishment on ctx so it reaches a valid state + // (needed in case the 3-step NACSign path is used as fallback). + // Safety: single-task use; see ValidationCtx struct doc. + let ctx = unsafe { &mut *ctx.get() }; + ctx.key_establishment(response) + .map_err(|e| AbsintheError::Other(format!("NACKeyEstablishment: {e}")))?; + Ok(()) + } + ValidationCtxInner::Emulator { uc, state, validation_ctx_addr } => { + let resp_addr = { + let mut st = state.borrow_mut(); + st.heap_alloc(response.len() as u64) + }; + let uc = uc.get_mut(); + uc.mem_write(resp_addr, response) + .map_err(|e| AbsintheError::Other(format!("Failed to write response: {:?}", e)))?; + + let ret = call_func( + uc, + NAC_KEY_EST, + &[*validation_ctx_addr, resp_addr, response.len() as u64], + )?; + + if ret != 0 { + return Err(AbsintheError::NacError(-(ret as i64 as i32))); + } + Ok(()) + } + } + } + + /// Generate signed validation data. + pub fn sign(&self) -> Result<Vec<u8>, AbsintheError> { + // Check for pre-fetched relay data first — if the bridge stashed + // validation data from the NAC relay, it's authoritative. + if let Some(data) = take_prefetched_validation_data() { + info!("NAC sign: returning pre-fetched relay validation data ({} bytes)", data.len()); + return Ok(data); + } + + let (uc, state, validation_ctx_addr) = match &self.inner { + ValidationCtxInner::Relay { validation_data } => { + info!("NAC sign: returning relay validation data ({} bytes)", validation_data.len()); + return Ok(validation_data.clone()); + } + #[cfg(all(target_os = "macos", feature = "native-nac"))] + ValidationCtxInner::Native { ctx, final_data } => { + // If the one-shot pre-computed the result, return it directly — + // it already ran the full NACInit→POST→KeyEst→Sign sequence + // internally and is more reliable than the 3-step path. + if let Some(data) = final_data { + debug!( + "NAC sign: returning pre-computed one-shot result ({} bytes)", + data.len() + ); + return Ok(data.clone()); + } + // No pre-computed data — use the 3-step NACSign result. + // Safety: single-task use; see ValidationCtx struct doc. + let ctx = unsafe { &mut *ctx.get() }; + let bytes = ctx + .sign() + .map_err(|e| AbsintheError::Other(format!("NACSign: {e}")))?; + debug!("NAC sign: native 3-step path produced {} bytes", bytes.len()); + return Ok(bytes); + } + ValidationCtxInner::Emulator { uc, state, validation_ctx_addr } => { + (uc, state, *validation_ctx_addr) + } + }; + // sign() takes &self but we need &mut for the emulator. + // Safety: ValidationCtx is only used from one thread (enforced by + // the single-threaded usage pattern and unsafe Send impl). + let uc = unsafe { &mut *uc.get() }; + + let out_data_ptr; + let out_data_len_ptr; + { + let mut st = state.borrow_mut(); + out_data_ptr = st.heap_alloc(8); + out_data_len_ptr = st.heap_alloc(8); + } + + let ret = call_func( + uc, + NAC_SIGN, + &[ + validation_ctx_addr, + 0, + 0, + out_data_ptr, + out_data_len_ptr, + ], + )?; + + if ret != 0 { + return Err(AbsintheError::NacError(-(ret as i64 as i32))); + } + + let data_addr = uc_read_u64(uc, out_data_ptr); + let data_len = uc_read_u64(uc, out_data_len_ptr) as usize; + + debug!("nac_sign: data_addr=0x{:x} len={}", data_addr, data_len); + + let validation_data = uc + .mem_read_as_vec(data_addr, data_len) + .map_err(|e| AbsintheError::Other(format!("Failed to read validation data: {:?}", e)))?; + + Ok(validation_data) + } +} + +// ============================================================================ +// Function calling helper +// ============================================================================ + +/// Call a function at `addr` with the given arguments, using the x86-64 SysV +/// calling convention. Returns the value in RAX. +fn call_func(uc: &mut Unicorn<'static, ()>, addr: u64, args: &[u64]) -> Result<u64, AbsintheError> { + // Push return address (STOP_ADDR) + let mut rsp = uc.reg_read(RegisterX86::RSP).unwrap(); + rsp -= 8; + uc.reg_write(RegisterX86::RSP, rsp).unwrap(); + uc_write_u64(uc, rsp, STOP_ADDR); + + // Set argument registers + for (i, &val) in args.iter().enumerate() { + if i < 6 { + uc.reg_write(ARG_REGS[i], val).unwrap(); + } else { + // Push remaining args on stack (right to left order already handled) + rsp -= 8; + uc.reg_write(RegisterX86::RSP, rsp).unwrap(); + uc_write_u64(uc, rsp, val); + } + } + + // Run emulation + uc.emu_start(addr, STOP_ADDR, 0, 0) + .map_err(|e| AbsintheError::Other(format!("Emulation error at 0x{:x}: {:?}", addr, e)))?; + + Ok(uc.reg_read(RegisterX86::RAX).unwrap()) +} + +// ============================================================================ +// Mach-O import resolution +// ============================================================================ + +fn resolve_imports( + uc: &mut Unicorn<'static, ()>, + binary: &[u8], + state: &EmuState, +) -> Result<(), AbsintheError> { + let macho = match goblin::mach::MachO::parse(binary, 0) { + Ok(m) => m, + Err(e) => return Err(AbsintheError::Other(format!("Mach-O parse error: {}", e))), + }; + + // Build symbol → hook address lookup + let mut sym_to_hook: HashMap<&str, u64> = HashMap::new(); + for (&addr, name) in &state.hook_map { + // hook_map has full names like "_malloc", import names also have "_malloc" + sym_to_hook.insert(name.as_str(), addr); + } + + // Process all imports (both lazy and non-lazy binds) + match macho.imports() { + Ok(imports) => { + for import in &imports { + if let Some(&hook_addr) = sym_to_hook.get(import.name) { + // import.offset is the file offset where the GOT pointer lives + uc.mem_write(import.offset as u64, &hook_addr.to_le_bytes()) + .map_err(|e| { + AbsintheError::Other(format!( + "Failed to write import {} at 0x{:x}: {:?}", + import.name, import.offset, e + )) + })?; + debug!("Bound {} at offset 0x{:x} → hook 0x{:x}", import.name, import.offset, hook_addr); + } + } + } + Err(e) => { + warn!("Failed to parse Mach-O imports, trying section-based resolution: {}", e); + } + } + + Ok(()) +} + +// ============================================================================ +// Utility +// ============================================================================ + +fn round_up(val: usize, align: usize) -> usize { + (val + align - 1) & !(align - 1) +} + +// Deterministic `_enc` derivation tests for the x86 Linux assembly path. +// +// Why these exist: +// - Some Intel keys are extracted without encrypted IOKit properties. +// - In that case we derive `_enc` fields locally via the XNU routine from +// `src/asm/encrypt.s`. +// - These tests protect that behavior with known-good vectors and ensure +// repeated computation is idempotent. +#[cfg(all(test, has_xnu_encrypt))] +mod tests { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine}; + + fn b64_dec(s: &str) -> Vec<u8> { + STANDARD.decode(s).expect("invalid base64 test vector") + } + + fn b64_enc(bytes: &[u8]) -> String { + STANDARD.encode(bytes) + } + + fn sample_hw_missing_enc() -> HardwareConfig { + HardwareConfig { + product_name: "MacBookAir8,1".into(), + io_mac_address: [0xa4, 0x83, 0xe7, 0x11, 0x47, 0x1c], + platform_serial_number: "C02YT1YMJK7M".into(), + platform_uuid: "11D299A5-CF0B-544D-BAD3-7AC7A6E452D7".into(), + root_disk_uuid: "FCDB63B5-D208-4AEE-B368-3DE952B911FF".into(), + board_id: "Mac-827FAC58A8FDFA22".into(), + os_build_num: "22G513".into(), + platform_serial_number_enc: vec![], + platform_uuid_enc: vec![], + root_disk_uuid_enc: vec![], + rom: b64_dec("V9BNndaG"), + rom_enc: vec![], + mlb: "C02923200KVKN3YAG".into(), + mlb_enc: vec![], + } + } + + #[test] + fn test_compute_missing_enc_fields_matches_known_vectors() { + let mut hw = sample_hw_missing_enc(); + compute_missing_enc_fields(&mut hw).expect("compute_missing_enc_fields failed"); + + println!("computed platform_serial_number_enc={}", b64_enc(&hw.platform_serial_number_enc)); + println!("computed platform_uuid_enc={}", b64_enc(&hw.platform_uuid_enc)); + println!("computed root_disk_uuid_enc={}", b64_enc(&hw.root_disk_uuid_enc)); + println!("computed rom_enc={}", b64_enc(&hw.rom_enc)); + println!("computed mlb_enc={}", b64_enc(&hw.mlb_enc)); + + assert_eq!( + hw.platform_serial_number_enc, + b64_dec("c3kZ7+WofxcjaBTInJCwSV0="), + "platform_serial_number_enc mismatch" + ); + assert_eq!( + hw.platform_uuid_enc, + b64_dec("jGguP3mQH+Vw6dMAWrqZOnk="), + "platform_uuid_enc mismatch" + ); + assert_eq!( + hw.root_disk_uuid_enc, + b64_dec("VvJODAsuSRdGQlhB5kPgf2M="), + "root_disk_uuid_enc mismatch" + ); + assert_eq!(hw.rom_enc, b64_dec("wWF12gciXzN/96bIt/ufTB0="), "rom_enc mismatch"); + assert_eq!(hw.mlb_enc, b64_dec("CKp4ROiInBYAdvnbrbNjjkM="), "mlb_enc mismatch"); + } + + #[test] + fn test_compute_missing_enc_fields_is_idempotent() { + let mut hw = sample_hw_missing_enc(); + compute_missing_enc_fields(&mut hw).expect("first compute_missing_enc_fields failed"); + + let expected_serial = hw.platform_serial_number_enc.clone(); + let expected_uuid = hw.platform_uuid_enc.clone(); + let expected_disk = hw.root_disk_uuid_enc.clone(); + let expected_rom = hw.rom_enc.clone(); + let expected_mlb = hw.mlb_enc.clone(); + + compute_missing_enc_fields(&mut hw).expect("second compute_missing_enc_fields failed"); + + assert_eq!(hw.platform_serial_number_enc, expected_serial); + assert_eq!(hw.platform_uuid_enc, expected_uuid); + assert_eq!(hw.root_disk_uuid_enc, expected_disk); + assert_eq!(hw.rom_enc, expected_rom); + assert_eq!(hw.mlb_enc, expected_mlb); + } +} diff --git a/rustpush/open-absinthe/tests/nac_hwkey_flow.rs b/rustpush/open-absinthe/tests/nac_hwkey_flow.rs new file mode 100644 index 00000000..ecf298ea --- /dev/null +++ b/rustpush/open-absinthe/tests/nac_hwkey_flow.rs @@ -0,0 +1,143 @@ +//! test_nac_validation_flow_from_hw_key_env +//! +//! Purpose: +//! - End-to-end NAC validation smoke test using a real extracted hardware key. +//! - Verifies that `ValidationCtx::{new,key_establishment,sign}` works against +//! Apple's live validation endpoints from Linux. +//! +//! How to run: +//! - Set `HW_KEY_B64` to the base64 key, OR set `HW_KEY_FILE` to a file path. +//! - Run this single test with `-- --nocapture` to inspect lengths and progress. +//! +//! Notes: +//! - If neither env var is set, the test exits early (skip-like behavior). +//! - This is tooling for validation and debugging; production code does not +//! depend on this test. + +use std::io::{Cursor, Read}; + +use open_absinthe::nac::{HardwareConfig, ValidationCtx}; +use serde::Deserialize; + +fn read_response_bytes(resp: ureq::Response) -> Vec<u8> { + let mut buf = Vec::new(); + resp.into_reader().read_to_end(&mut buf).unwrap(); + buf +} + +#[derive(Deserialize)] +struct WrappedHardwareConfig { + inner: HardwareConfig, +} + +fn parse_hw_key_b64(raw: &str) -> HardwareConfig { + use base64::{engine::general_purpose::STANDARD, Engine}; + + let cleaned: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let bytes = STANDARD.decode(cleaned).expect("invalid base64 hardware key"); + + if let Ok(wrapped) = serde_json::from_slice::<WrappedHardwareConfig>(&bytes) { + wrapped.inner + } else { + serde_json::from_slice::<HardwareConfig>(&bytes) + .expect("invalid hardware key json (expected wrapped MacOSConfig or bare HardwareConfig)") + } +} + +#[test] +fn test_nac_validation_flow_from_hw_key_env() { + let key_b64 = if let Ok(v) = std::env::var("HW_KEY_B64") { + v + } else if let Ok(path) = std::env::var("HW_KEY_FILE") { + std::fs::read_to_string(path).expect("failed to read HW_KEY_FILE") + } else { + eprintln!("Skipping test_nac_validation_flow_from_hw_key_env: set HW_KEY_B64 or HW_KEY_FILE"); + return; + }; + + let hw = parse_hw_key_b64(&key_b64); + + println!( + "HW key: model={} build={} serial={} _enc_lens: serial={} uuid={} disk={} rom={} mlb={}", + hw.product_name, + hw.os_build_num, + hw.platform_serial_number, + hw.platform_serial_number_enc.len(), + hw.platform_uuid_enc.len(), + hw.root_disk_uuid_enc.len(), + hw.rom_enc.len(), + hw.mlb_enc.len() + ); + + // Build agent with native TLS for Apple's cert chain + let agent = ureq::AgentBuilder::new() + .tls_connector(std::sync::Arc::new(native_tls::TlsConnector::new().unwrap())) + .build(); + + // Step 1: Fetch validation cert from Apple + let cert_resp = agent + .get("http://static.ess.apple.com/identity/validation/cert-1.0.plist") + .call() + .unwrap(); + let cert_data = read_response_bytes(cert_resp); + let cert_plist: plist::Value = plist::from_reader(Cursor::new(&cert_data)).unwrap(); + let cert_bytes = cert_plist + .as_dictionary() + .unwrap() + .get("cert") + .unwrap() + .as_data() + .unwrap() + .to_vec(); + + println!("Fetched validation cert: {} bytes", cert_bytes.len()); + + // Step 2: nac_init + let mut request_bytes = vec![]; + let mut ctx = ValidationCtx::new(&cert_bytes, &mut request_bytes, &hw).unwrap(); + assert!(!request_bytes.is_empty(), "nac_init should produce request bytes"); + println!("nac_init OK: {} request bytes", request_bytes.len()); + + // Step 3: Send session-info-request to Apple, get session-info back + let session_req = plist::Value::Dictionary(plist::Dictionary::from_iter([( + "session-info-request".to_string(), + plist::Value::Data(request_bytes), + )])); + let mut body = vec![]; + plist::to_writer_xml(&mut body, &session_req).unwrap(); + + let session_resp = agent + .post("https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/initializeValidation") + .send_bytes(&body) + .unwrap(); + let resp_data = read_response_bytes(session_resp); + let resp_plist: plist::Value = plist::from_reader(Cursor::new(&resp_data)).unwrap(); + let session_info = resp_plist + .as_dictionary() + .unwrap() + .get("session-info") + .unwrap() + .as_data() + .unwrap() + .to_vec(); + + println!("Got session-info: {} bytes", session_info.len()); + + // Step 4: nac_key_establishment + ctx.key_establishment(&session_info).unwrap(); + println!("nac_key_establishment OK"); + + // Step 5: nac_sign + let validation_data = ctx.sign().unwrap(); + assert!(!validation_data.is_empty(), "nac_sign should produce validation data"); + println!("nac_sign OK: {} bytes of validation data", validation_data.len()); + + // Typical observed value is around 517 bytes; keep this check loose. + assert!( + (450..=700).contains(&validation_data.len()), + "unexpected validation data length: {}", + validation_data.len() + ); + + println!("SUCCESS: Full NAC validation flow completed using provided hardware key"); +} diff --git a/rustpush/open-absinthe/tests/nac_test.rs b/rustpush/open-absinthe/tests/nac_test.rs new file mode 100644 index 00000000..9b14bfa9 --- /dev/null +++ b/rustpush/open-absinthe/tests/nac_test.rs @@ -0,0 +1,155 @@ +use std::io::{Cursor, Read}; +use open_absinthe::nac::{HardwareConfig, ValidationCtx}; + +fn base64_dec(s: &str) -> Vec<u8> { + use base64::{Engine, engine::general_purpose::STANDARD}; + STANDARD.decode(s).unwrap() +} + +fn read_response_bytes(resp: ureq::Response) -> Vec<u8> { + let mut buf = Vec::new(); + resp.into_reader().read_to_end(&mut buf).unwrap(); + buf +} + +fn sample_hw() -> HardwareConfig { + HardwareConfig { + product_name: "MacBookAir8,1".into(), + io_mac_address: [0xa4, 0x83, 0xe7, 0x11, 0x47, 0x1c], + platform_serial_number: "C02YT1YMJK7M".into(), + platform_uuid: "11D299A5-CF0B-544D-BAD3-7AC7A6E452D7".into(), + root_disk_uuid: "FCDB63B5-D208-4AEE-B368-3DE952B911FF".into(), + board_id: "Mac-827FAC58A8FDFA22".into(), + os_build_num: "22G513".into(), + platform_serial_number_enc: base64_dec("c3kZ7+WofxcjaBTInJCwSV0="), + platform_uuid_enc: base64_dec("jGguP3mQH+Vw6dMAWrqZOnk="), + root_disk_uuid_enc: base64_dec("VvJODAsuSRdGQlhB5kPgf2M="), + rom: base64_dec("V9BNndaG"), + rom_enc: base64_dec("wWF12gciXzN/96bIt/ufTB0="), + mlb: "C02923200KVKN3YAG".into(), + mlb_enc: base64_dec("CKp4ROiInBYAdvnbrbNjjkM="), + } +} + +#[test] +fn test_nac_validation_flow() { + let hw = sample_hw(); + + // Build agent with native TLS for Apple's cert chain + let agent = ureq::AgentBuilder::new() + .tls_connector(std::sync::Arc::new(native_tls::TlsConnector::new().unwrap())) + .build(); + + // Step 1: Fetch validation cert from Apple (HTTP, no TLS issue) + let cert_resp = agent.get("http://static.ess.apple.com/identity/validation/cert-1.0.plist") + .call() + .unwrap(); + let cert_data = read_response_bytes(cert_resp); + let cert_plist: plist::Value = plist::from_reader(Cursor::new(&cert_data)).unwrap(); + let cert_bytes = cert_plist + .as_dictionary() + .unwrap() + .get("cert") + .unwrap() + .as_data() + .unwrap() + .to_vec(); + + println!("Fetched validation cert: {} bytes", cert_bytes.len()); + + // Step 2: nac_init + let mut request_bytes = vec![]; + let mut ctx = ValidationCtx::new(&cert_bytes, &mut request_bytes, &hw).unwrap(); + assert!( + !request_bytes.is_empty(), + "nac_init should produce request bytes" + ); + println!("nac_init OK: {} request bytes", request_bytes.len()); + + // Step 3: Send session-info-request to Apple, get session-info back + let session_req = + plist::Value::Dictionary(plist::Dictionary::from_iter([( + "session-info-request".to_string(), + plist::Value::Data(request_bytes), + )])); + let mut body = vec![]; + plist::to_writer_xml(&mut body, &session_req).unwrap(); + + let session_resp = agent.post( + "https://identity.ess.apple.com/WebObjects/TDIdentityService.woa/wa/initializeValidation", + ) + .send_bytes(&body) + .unwrap(); + let resp_data = read_response_bytes(session_resp); + let resp_plist: plist::Value = plist::from_reader(Cursor::new(&resp_data)).unwrap(); + let session_info = resp_plist + .as_dictionary() + .unwrap() + .get("session-info") + .unwrap() + .as_data() + .unwrap() + .to_vec(); + + println!("Got session-info: {} bytes", session_info.len()); + + // Step 4: nac_key_establishment + ctx.key_establishment(&session_info).unwrap(); + println!("nac_key_establishment OK"); + + // Step 5: nac_sign + let validation_data = ctx.sign().unwrap(); + assert!( + !validation_data.is_empty(), + "nac_sign should produce validation data" + ); + println!( + "nac_sign OK: {} bytes of validation data", + validation_data.len() + ); + println!("SUCCESS: Full NAC validation flow completed on Linux!"); +} + +#[test] +fn test_hardware_config_apple_silicon() { + // Apple Silicon keys have empty _enc fields — verify deserialization works + let json = r#"{ + "product_name": "Mac14,14", + "io_mac_address": [164, 252, 20, 17, 24, 231], + "platform_serial_number": "GYD6Q9YDH4", + "platform_uuid": "4E2CDE99-F091-5723-980B-482821B8A20A", + "root_disk_uuid": "10B6A4C2-D5EB-4CDC-9C60-31325B64AAAE", + "board_id": "Mac14,14", + "os_build_num": "25B78", + "platform_serial_number_enc": [], + "platform_uuid_enc": [], + "root_disk_uuid_enc": [], + "rom": [164, 252, 20, 17, 24, 231], + "rom_enc": [], + "mlb": "H28328500MN21G6AR", + "mlb_enc": [] + }"#; + + let hw: HardwareConfig = serde_json::from_str(json).expect("deser failed"); + assert_eq!(hw.product_name, "Mac14,14"); + assert_eq!(hw.platform_serial_number, "GYD6Q9YDH4"); + assert_eq!(hw.mlb, "H28328500MN21G6AR"); + assert_eq!(hw.rom.len(), 6); + assert!(hw.platform_serial_number_enc.is_empty()); + assert!(hw.mlb_enc.is_empty()); + assert!(hw.rom_enc.is_empty()); + println!("Apple Silicon HardwareConfig deserialized OK: {}", hw.product_name); +} + +#[test] +fn test_hardware_config_json_roundtrip() { + let hw = sample_hw(); + let json = serde_json::to_vec(&hw).unwrap(); + let decoded: HardwareConfig = serde_json::from_slice(&json).unwrap(); + assert_eq!(decoded.platform_serial_number, "C02YT1YMJK7M"); + assert_eq!(decoded.io_mac_address, [0xa4, 0x83, 0xe7, 0x11, 0x47, 0x1c]); + assert_eq!(decoded.product_name, "MacBookAir8,1"); + assert_eq!(decoded.board_id, "Mac-827FAC58A8FDFA22"); + assert_eq!(decoded.mlb, "C02923200KVKN3YAG"); + println!("JSON roundtrip OK"); +} diff --git a/scripts/bootstrap-linux.sh b/scripts/bootstrap-linux.sh new file mode 100755 index 00000000..08080c1a --- /dev/null +++ b/scripts/bootstrap-linux.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# Bootstrap all build dependencies on Linux (Ubuntu/Debian). +# Equivalent to macOS's Homebrew auto-install in check-deps. +set -euo pipefail + +MIN_GO="1.24" +MIN_RUST="1.88" + +echo "" +echo "Checking Linux build dependencies..." +echo "" + +# ── System packages (apt) ───────────────────────────────────── +APT_PACKAGES="" +command -v cmake >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES cmake" +command -v protoc >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES protobuf-compiler" +command -v git >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES git" +command -v curl >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES curl" +command -v wget >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES wget" +command -v make >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES build-essential" +command -v cc >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES build-essential" +command -v g++ >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES build-essential" +dpkg -s pkg-config >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES pkg-config" +dpkg -s libolm-dev >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES libolm-dev" +dpkg -s libclang-dev >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES libclang-dev" +dpkg -s libssl-dev >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES libssl-dev" +# libunicorn-dev: pre-built Unicorn Engine (CPU emulator). Without this, +# the Rust unicorn-engine-sys crate tries to build QEMU from source via CMake, +# which often fails on WSL2 due to qemu/configure issues. +dpkg -s libunicorn-dev >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES libunicorn-dev" +dpkg -s libheif-dev >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES libheif-dev" +command -v sqlite3 >/dev/null 2>&1 || APT_PACKAGES="$APT_PACKAGES sqlite3" + +# Deduplicate +APT_PACKAGES=$(echo "$APT_PACKAGES" | tr ' ' '\n' | sort -u | tr '\n' ' ') + +if [ -n "$APT_PACKAGES" ]; then + echo "Installing system packages:$APT_PACKAGES" + sudo apt-get update -qq + sudo apt-get install -y -qq $APT_PACKAGES + echo "✓ System packages installed" +else + echo "✓ System packages already installed" +fi + +# ── Rust (via rustup) ───────────────────────────────────────── +install_rust() { + echo "Installing Rust via rustup..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + source "$HOME/.cargo/env" + echo "✓ Rust $(rustc --version | awk '{print $2}') installed" +} + +if command -v rustc >/dev/null 2>&1; then + RUST_VER=$(rustc --version | awk '{print $2}') + RUST_MAJOR=$(echo "$RUST_VER" | cut -d. -f1) + RUST_MINOR=$(echo "$RUST_VER" | cut -d. -f2) + NEED_MINOR=$(echo "$MIN_RUST" | cut -d. -f2) + if [ "$RUST_MAJOR" -lt 1 ] || { [ "$RUST_MAJOR" -eq 1 ] && [ "$RUST_MINOR" -lt "$NEED_MINOR" ]; }; then + echo "Rust $RUST_VER is too old (need $MIN_RUST+), upgrading..." + if command -v rustup >/dev/null 2>&1; then + rustup update stable + echo "✓ Rust updated to $(rustc --version | awk '{print $2}')" + else + install_rust + fi + else + echo "✓ Rust $RUST_VER" + fi +else + install_rust +fi + +# Make sure cargo is on PATH for this session +if [ -f "$HOME/.cargo/env" ]; then + source "$HOME/.cargo/env" +fi + +# ── Go ──────────────────────────────────────────────────────── +install_go() { + echo "Installing Go from go.dev..." + local GO_VERSION + GO_VERSION=$(curl -sSL 'https://go.dev/dl/?mode=json' | grep -o '"version": *"go[0-9.]*"' | head -1 | grep -o 'go[0-9.]*') + if [ -z "$GO_VERSION" ]; then + GO_VERSION="go1.25.0" + fi + local ARCH + case "$(uname -m)" in + x86_64) ARCH=amd64 ;; + aarch64) ARCH=arm64 ;; + *) ARCH=amd64 ;; + esac + echo " Downloading $GO_VERSION (linux/$ARCH)..." + curl -sSL "https://go.dev/dl/${GO_VERSION}.linux-${ARCH}.tar.gz" -o /tmp/go.tar.gz + sudo rm -rf /usr/local/go + sudo tar -C /usr/local -xzf /tmp/go.tar.gz + rm -f /tmp/go.tar.gz + export PATH="/usr/local/go/bin:$PATH" + # Persist for future shells + if ! grep -q '/usr/local/go/bin' "$HOME/.profile" 2>/dev/null; then + echo 'export PATH=/usr/local/go/bin:$PATH' >> "$HOME/.profile" + fi + echo "✓ Go $(go version | awk '{print $3}') installed to /usr/local/go" +} + +if command -v go >/dev/null 2>&1; then + GO_VER=$(go version | awk '{print $3}' | sed 's/^go//') + GO_MAJOR=$(echo "$GO_VER" | cut -d. -f1) + GO_MINOR=$(echo "$GO_VER" | cut -d. -f2) + NEED_MINOR=$(echo "$MIN_GO" | cut -d. -f2) + if [ "$GO_MAJOR" -lt 1 ] || { [ "$GO_MAJOR" -eq 1 ] && [ "$GO_MINOR" -lt "$NEED_MINOR" ]; }; then + echo "Go $GO_VER is too old (need $MIN_GO+), upgrading..." + install_go + else + echo "✓ Go $GO_VER" + fi +else + install_go +fi + +# ── Apple Root CA ───────────────────────────────────────────── +# Apple's identity servers use a cert signed by Apple Root CA, +# which isn't in the default Ubuntu/Debian trust store. +if openssl s_client -connect identity.ess.apple.com:443 </dev/null 2>&1 | grep -q "Verify return code: 0"; then + echo "✓ Apple Root CA already trusted" +else + echo "Installing Apple Root CA..." + wget -qO /tmp/AppleRootCA.cer 'https://www.apple.com/appleca/AppleIncRootCertificate.cer' + sudo openssl x509 -inform DER -in /tmp/AppleRootCA.cer \ + -out /usr/local/share/ca-certificates/AppleRootCA.crt + sudo update-ca-certificates --fresh >/dev/null 2>&1 + rm -f /tmp/AppleRootCA.cer + echo "✓ Apple Root CA installed" +fi + +echo "" +echo "All dependencies ready." +echo "" diff --git a/scripts/install-beeper-linux.sh b/scripts/install-beeper-linux.sh new file mode 100755 index 00000000..f59adfcb --- /dev/null +++ b/scripts/install-beeper-linux.sh @@ -0,0 +1,1156 @@ +#!/bin/bash +set -euo pipefail + +BINARY="$1" +DATA_DIR="$2" +PREBUILT_BBCTL="${3:-}" + +BRIDGE_NAME="${BRIDGE_NAME:-sh-imessage}" + +BINARY="$(cd "$(dirname "$BINARY")" && pwd)/$(basename "$BINARY")" +CONFIG="$DATA_DIR/config.yaml" + +# Where we build/cache bbctl (sparse clone — only cmd/bbctl/) +BBCTL_DIR="${BBCTL_DIR:-$HOME/.local/share/mautrix-imessage/bbctl}" +BBCTL_REPO="${BBCTL_REPO:-https://github.com/lrhodin/imessage.git}" +BBCTL_BRANCH="${BBCTL_BRANCH:-master}" + +echo "" +echo "═══════════════════════════════════════════════" +echo " iMessage Bridge Setup (Beeper · Linux)" +echo "═══════════════════════════════════════════════" +echo "" + +# ── Stop bridge for the duration of setup ───────────────────── +# systemctl stop prevents Restart=always from kicking in (systemd only +# auto-restarts after process exits, not after admin stop). No need to +# mask — masking fails when the unit file already exists on disk. +if systemctl --user is-active mautrix-imessage >/dev/null 2>&1; then + systemctl --user stop mautrix-imessage + echo "✓ Stopped running bridge" +elif systemctl is-active mautrix-imessage >/dev/null 2>&1; then + sudo systemctl stop mautrix-imessage + echo "✓ Stopped running bridge" +fi + +# ── Permission repair helper ────────────────────────────────── +# Detects and fixes broken permissions in config.yaml. Matches the same +# patterns as repairPermissions() / fixPermissionsOnDisk() in the Go code: +# - Empty username: "@:beeper.com", "@": ... +# - Example defaults: "@admin:example.com", "example.com", "*": relay +# Usage: fix_permissions <config_path> <username> +fix_permissions() { + local config="$1" whoami="$2" + if grep -q '"@:\|"@":\|@.*example\.com\|"\*":.*relay' "$config" 2>/dev/null; then + local mxid="@${whoami}:beeper.com" + sed -i '/permissions:/,/^[^ ]/{ + s/"@[^"]*": admin/"'"$mxid"'": admin/ + /@.*example\.com/d + /"\*":.*relay/d + /"@":/d + /"@:/d + }' "$config" + return 0 + fi + return 1 +} + +# ── Build bbctl from source ─────────────────────────────────── +BBCTL="$BBCTL_DIR/bbctl" + +# Warn about old full-repo clone and offer to remove it +OLD_BBCTL_DIR="$HOME/.local/share/mautrix-imessage/bridge-manager" +if [ -d "$OLD_BBCTL_DIR/.git" ] && [ "$BBCTL_DIR" != "$OLD_BBCTL_DIR" ]; then + echo "⚠ Found old full-repo clone at $OLD_BBCTL_DIR" + echo " This is no longer needed (bbctl now uses a sparse checkout)." + if [ -t 0 ]; then + read -p " Delete it to free disk space? [Y/n]: " DEL_OLD + case "$DEL_OLD" in + [nN]*) ;; + *) rm -rf "$OLD_BBCTL_DIR" + echo " ✓ Removed $OLD_BBCTL_DIR" ;; + esac + else + echo " You can safely delete it: rm -rf $OLD_BBCTL_DIR" + fi +fi + +build_bbctl() { + echo "Building bbctl..." + mkdir -p "$(dirname "$BBCTL_DIR")" + if [ -d "$BBCTL_DIR/.git" ]; then + cd "$BBCTL_DIR" + git fetch --quiet origin + git reset --hard --quiet "origin/$BBCTL_BRANCH" + else + rm -rf "$BBCTL_DIR" + git clone --filter=blob:none --no-checkout --quiet \ + --branch "$BBCTL_BRANCH" "$BBCTL_REPO" "$BBCTL_DIR" + cd "$BBCTL_DIR" + git sparse-checkout init --cone + git sparse-checkout set cmd/bbctl + git checkout --quiet "$BBCTL_BRANCH" + fi + go build -o bbctl ./cmd/bbctl/ 2>&1 + cd - >/dev/null + echo "✓ Built bbctl" +} + +if [ -n "$PREBUILT_BBCTL" ] && [ -x "$PREBUILT_BBCTL" ]; then + # Install the bbctl built by `make build` into BBCTL_DIR + mkdir -p "$BBCTL_DIR" + cp "$PREBUILT_BBCTL" "$BBCTL" + echo "✓ Installed bbctl to $BBCTL_DIR/" +elif [ ! -x "$BBCTL" ]; then + build_bbctl +else + echo "✓ Found bbctl: $BBCTL" +fi + +# ── Check bbctl login ──────────────────────────────────────── +WHOAMI_CHECK=$("$BBCTL" whoami 2>&1 || true) +if echo "$WHOAMI_CHECK" | grep -qi "not logged in" || [ -z "$WHOAMI_CHECK" ]; then + echo "" + echo "Not logged into Beeper. Running bbctl login..." + echo "" + "$BBCTL" login +fi +# Capture username (discard stderr so "Fetching whoami..." doesn't contaminate) +WHOAMI=$("$BBCTL" whoami 2>/dev/null | head -1 || true) +# On slow machines the Beeper API may not have the username ready yet — retry +if [ -z "$WHOAMI" ] || [ "$WHOAMI" = "null" ]; then + for i in 1 2 3 4 5; do + echo " Waiting for username from Beeper API (attempt $i/5)..." + sleep 3 + WHOAMI=$("$BBCTL" whoami 2>/dev/null | head -1 || true) + [ -n "$WHOAMI" ] && [ "$WHOAMI" != "null" ] && break + done +fi +if [ -z "$WHOAMI" ] || [ "$WHOAMI" = "null" ]; then + echo "" + echo "ERROR: Could not get username from Beeper API." + echo " This can happen when the API is slow to propagate after login." + echo " Wait a minute and re-run the install." + exit 1 +fi +echo "✓ Logged in: $WHOAMI" + +# ── Check for existing bridge registration ──────────────────── +# If the bridge is already registered on the server but we're about to +# generate a fresh config (no local config file), the old registration's +# rooms would be orphaned. Delete it first so the server cleans up rooms. +EXISTING_BRIDGE=$("$BBCTL" whoami 2>&1 | grep "^\s*$BRIDGE_NAME " || true) +if [ -n "$EXISTING_BRIDGE" ] && [ ! -f "$CONFIG" ]; then + echo "" + echo "⚠ Found existing '$BRIDGE_NAME' registration on server but no local config." + echo " Deleting old registration to avoid orphaned rooms..." + "$BBCTL" delete "$BRIDGE_NAME" + echo "✓ Old registration cleaned up" + echo " Waiting for server-side deletion to complete..." + sleep 5 +fi + +# ── Generate config via bbctl ───────────────────────────────── +mkdir -p "$DATA_DIR" +if [ -f "$CONFIG" ] && [ -z "$EXISTING_BRIDGE" ]; then + # Config exists locally but bridge isn't registered on server (e.g. bbctl + # delete was run manually). The stale config has an invalid as_token and + # the DB references rooms that no longer exist. + # + # Double-check by retrying bbctl whoami — a transient network error or the + # bridge restarting can cause the first check to return empty even though + # the registration is fine. + echo "⚠ Bridge not found in bbctl whoami — retrying in 3s to rule out transient error..." + sleep 3 + EXISTING_BRIDGE=$("$BBCTL" whoami 2>&1 | grep "^\s*$BRIDGE_NAME " || true) + if [ -z "$EXISTING_BRIDGE" ]; then + echo "⚠ Local config exists but bridge is not registered on server." + echo " Removing stale config and database to re-register..." + rm -f "$CONFIG" + rm -f "$DATA_DIR"/mautrix-imessage.db* + else + echo "✓ Bridge found on retry — keeping existing config and database" + fi +fi +if [ -f "$CONFIG" ]; then + echo "✓ Config already exists at $CONFIG" +else + echo "Generating Beeper config..." + for attempt in 1 2 3 4 5; do + if "$BBCTL" config --type imessage-v2 -o "$CONFIG" "$BRIDGE_NAME" 2>&1; then + break + fi + if [ "$attempt" -eq 5 ]; then + echo "ERROR: Failed to register appservice after $attempt attempts." + exit 1 + fi + echo " Retrying in 5s... (attempt $attempt/5)" + sleep 5 + done + # Make DB path absolute — everything lives in DATA_DIR + sed -i "s|uri: file:mautrix-imessage.db|uri: file:$DATA_DIR/mautrix-imessage.db|" "$CONFIG" + # Also catch sqlite:// URIs from newer bbctl versions + sed -i "s|uri: sqlite:mautrix-imessage.db|uri: sqlite:$DATA_DIR/mautrix-imessage.db|" "$CONFIG" + # iMessage CloudKit chats can have tens of thousands of messages. + # Deliver all history in one forward batch to avoid DAG fragmentation. + sed -i 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + sed -i 's/max_catchup_messages: [0-9]*/max_catchup_messages: 5000/' "$CONFIG" + sed -i 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + # Enable unlimited backward backfill (default is 0 which disables it) + sed -i 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + # Use 1s between batches — fast enough for backfill, prevents idle hot-loop + sed -i 's/batch_delay: [0-9]*/batch_delay: 1/' "$CONFIG" + echo "✓ Config saved to $CONFIG" +fi + +# No bridge-state override needed here — the bridge will post its own +# state when it actually starts at the end of setup. + +# ── Belt-and-suspenders: fix broken permissions ─────────────── +if [ -n "$WHOAMI" ] && [ "$WHOAMI" != "null" ]; then + if fix_permissions "$CONFIG" "$WHOAMI"; then + echo "✓ Fixed permissions: @${WHOAMI}:beeper.com → admin" + fi +else + if grep -q '"@:\|"@":\|@.*example\.com' "$CONFIG" 2>/dev/null; then + echo "" + echo "ERROR: Config has broken permissions and cannot determine your username." + echo " Try: $BBCTL login && rm $CONFIG && re-run make install-beeper" + echo "" + exit 1 + fi +fi + +# Ensure backfill settings are sane for existing configs +PATCHED_BACKFILL=false +# Only enable unlimited backward backfill when max_initial is uncapped. +# When the user caps max_initial_messages, max_batches stays at 0 so the +# bridge won't backfill beyond the cap. +if grep -q 'max_initial_messages: 2147483647' "$CONFIG" 2>/dev/null; then + if grep -q 'max_batches: 0$' "$CONFIG" 2>/dev/null; then + sed -i 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + PATCHED_BACKFILL=true + fi +fi +if grep -q 'max_initial_messages: [0-9]\{1,2\}$' "$CONFIG" 2>/dev/null; then + sed -i 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + PATCHED_BACKFILL=true +fi +if grep -q 'batch_size: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + PATCHED_BACKFILL=true +fi +if grep -q 'batch_delay: 0$' "$CONFIG" 2>/dev/null; then + sed -i 's/batch_delay: 0$/batch_delay: 1/' "$CONFIG" + PATCHED_BACKFILL=true +fi +if [ "$PATCHED_BACKFILL" = true ]; then + echo "✓ Updated backfill settings (max_initial=unlimited, batch_size=10000, max_batches=-1)" +fi + +if ! grep -q "beeper" "$CONFIG" 2>/dev/null; then + echo "" + echo "WARNING: Config doesn't appear to contain Beeper details." + echo " Try: rm $CONFIG && re-run make install-beeper" + echo "" + exit 1 +fi + +# ── Ensure cloudkit_backfill key exists in config ───────────── +if ! grep -q 'cloudkit_backfill:' "$CONFIG" 2>/dev/null; then + # Insert after initial_sync_days if it exists (old configs), otherwise append + if grep -q 'initial_sync_days:' "$CONFIG" 2>/dev/null; then + sed -i '/initial_sync_days:/a\ cloudkit_backfill: false' "$CONFIG" + else + echo " cloudkit_backfill: false" >> "$CONFIG" + fi +fi + +# ── Ensure backfill_source key exists in config ─────────────── +if ! grep -q 'backfill_source:' "$CONFIG" 2>/dev/null; then + sed -i '/cloudkit_backfill:/a\ backfill_source: cloudkit' "$CONFIG" +fi + +# ── CloudKit backfill toggle ─────────────────────────────────── +# Only prompt on first run (fresh DB). On re-runs, preserve existing setting. +DB_PATH_CHECK=$(grep 'uri:' "$CONFIG" | head -1 | sed 's/.*uri: file://' | sed 's/?.*//') +IS_FRESH_DB=false +if [ -z "$DB_PATH_CHECK" ] || [ ! -f "$DB_PATH_CHECK" ]; then + IS_FRESH_DB=true +fi + +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ -t 0 ]; then + if [ "$IS_FRESH_DB" = "true" ]; then + echo "" + echo "CloudKit Backfill:" + echo " When enabled, the bridge will sync your iMessage history from iCloud." + echo " This requires entering your device PIN during login to join the iCloud Keychain." + echo " When disabled, only new real-time messages are bridged (no PIN needed)." + echo "" + read -p "Enable CloudKit message history backfill? [y/N]: " ENABLE_BACKFILL + case "$ENABLE_BACKFILL" in + [yY]*) ENABLE_BACKFILL=true ;; + *) ENABLE_BACKFILL=false ;; + esac + sed -i "s/cloudkit_backfill: .*/cloudkit_backfill: $ENABLE_BACKFILL/" "$CONFIG" + if [ "$ENABLE_BACKFILL" = "true" ]; then + echo "✓ CloudKit backfill enabled — you'll be asked for your device PIN during login" + else + echo "✓ CloudKit backfill disabled — real-time messages only, no PIN needed" + fi + else + # Re-run: show current setting without prompting + if [ "$CURRENT_BACKFILL" = "true" ]; then + echo "✓ Backfill source: CloudKit (iCloud sync)" + else + echo "✓ Backfill: disabled (real-time messages only)" + fi + fi +fi + +# ── Max initial messages (new database + CloudKit backfill + interactive) ── +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ "$CURRENT_BACKFILL" = "true" ] && [ "$IS_FRESH_DB" = "true" ] && [ -t 0 ]; then + echo "" + echo "By default, all messages per chat will be backfilled." + echo "If you choose to limit, the minimum is 100 messages per chat." + read -p "Would you like to limit the number of messages? [y/N]: " LIMIT_MSGS + case "$LIMIT_MSGS" in + [yY]*) + while true; do + read -p "Max messages per chat (minimum 100): " MAX_MSGS + MAX_MSGS=$(echo "$MAX_MSGS" | tr -dc '0-9') + if [ -n "$MAX_MSGS" ] && [ "$MAX_MSGS" -ge 100 ] 2>/dev/null; then + break + fi + echo "Minimum is 100. Please enter a value of 100 or more." + done + sed -i "s/max_initial_messages: [0-9]*/max_initial_messages: $MAX_MSGS/" "$CONFIG" + # Disable backward backfill so the cap is the final word on message count + sed -i 's/max_batches: -1$/max_batches: 0/' "$CONFIG" + echo "✓ Max initial messages set to $MAX_MSGS per chat" + ;; + *) + echo "✓ Backfilling all messages" + ;; + esac +fi + +# Tune backfill settings when CloudKit backfill is enabled +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ "$CURRENT_BACKFILL" = "true" ]; then + PATCHED_BACKFILL=false + # Only enable unlimited backward backfill when max_initial is uncapped. + # When the user caps max_initial_messages, max_batches stays at 0 so the + # bridge won't backfill beyond the cap. + if grep -q 'max_initial_messages: 2147483647' "$CONFIG" 2>/dev/null; then + if grep -q 'max_batches: 0$' "$CONFIG" 2>/dev/null; then + sed -i 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + PATCHED_BACKFILL=true + fi + fi + if grep -q 'max_initial_messages: [0-9]\{1,2\}$' "$CONFIG" 2>/dev/null; then + sed -i 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'max_catchup_messages: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i 's/max_catchup_messages: [0-9]*/max_catchup_messages: 5000/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'batch_size: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if [ "$PATCHED_BACKFILL" = true ]; then + echo "✓ Updated backfill settings (max_initial=unlimited, batch_size=10000, max_batches=-1)" + fi +fi + +# ── Restore CardDAV config from backup ──────────────────────── +CARDDAV_BACKUP="$DATA_DIR/.carddav-config" +if [ -f "$CARDDAV_BACKUP" ]; then + CHECK_EMAIL=$(grep 'email:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*email: *//;s/['\"]//g" | tr -d ' ' || true) + if [ -z "$CHECK_EMAIL" ]; then + source "$CARDDAV_BACKUP" + if [ -n "${SAVED_CARDDAV_EMAIL:-}" ] && [ -n "${SAVED_CARDDAV_ENC:-}" ]; then + python3 -c " +import re +text = open('$CONFIG').read() +if 'carddav:' not in text: + lines = text.split('\\n') + insert_at = len(lines) + in_network = False + for i, line in enumerate(lines): + if line.startswith('network:'): + in_network = True + continue + if in_network and line and not line[0].isspace() and not line.startswith('#'): + insert_at = i + break + carddav = [' carddav:', ' email: \"\"', ' url: \"\"', ' username: \"\"', ' password_encrypted: \"\"'] + lines = lines[:insert_at] + carddav + lines[insert_at:] + text = '\\n'.join(lines) +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"$SAVED_CARDDAV_EMAIL\"') +text = patch(text, 'url', '\"$SAVED_CARDDAV_URL\"') +text = patch(text, 'username', '\"$SAVED_CARDDAV_USERNAME\"') +text = patch(text, 'password_encrypted', '\"$SAVED_CARDDAV_ENC\"') +open('$CONFIG', 'w').write(text) +" + echo "✓ Restored CardDAV config: $SAVED_CARDDAV_EMAIL" + fi + fi +fi + +# ── Contact source (runs every time, can reconfigure) ───────── +if [ -t 0 ]; then + CURRENT_CARDDAV_EMAIL=$(grep 'email:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*email: *//;s/['\"]//g" | tr -d ' ' || true) + CONFIGURE_CARDDAV=false + + if [ -n "$CURRENT_CARDDAV_EMAIL" ] && [ "$CURRENT_CARDDAV_EMAIL" != '""' ]; then + echo "" + echo "Contact source: External CardDAV ($CURRENT_CARDDAV_EMAIL)" + read -p "Change contact provider? [y/N]: " CHANGE_CONTACTS + case "$CHANGE_CONTACTS" in + [yY]*) CONFIGURE_CARDDAV=true ;; + esac + else + echo "" + echo "Contact source (for resolving names in chats):" + echo " 1) iCloud (default — uses your Apple ID)" + echo " 2) Google Contacts (requires app password)" + echo " 3) Fastmail" + echo " 4) Nextcloud" + echo " 5) Other CardDAV server" + read -p "Choice [1]: " CONTACT_CHOICE + CONTACT_CHOICE="${CONTACT_CHOICE:-1}" + if [ "$CONTACT_CHOICE" != "1" ]; then + CONFIGURE_CARDDAV=true + fi + fi + + if [ "$CONFIGURE_CARDDAV" = true ]; then + # Show menu if we're changing from an existing provider + if [ -n "$CURRENT_CARDDAV_EMAIL" ] && [ "$CURRENT_CARDDAV_EMAIL" != '""' ]; then + echo "" + echo " 1) iCloud (remove external CardDAV)" + echo " 2) Google Contacts (requires app password)" + echo " 3) Fastmail" + echo " 4) Nextcloud" + echo " 5) Other CardDAV server" + read -p "Choice: " CONTACT_CHOICE + fi + + CARDDAV_EMAIL="" + CARDDAV_PASSWORD="" + CARDDAV_USERNAME="" + CARDDAV_URL="" + + if [ "${CONTACT_CHOICE:-}" = "1" ]; then + # Remove external CardDAV — clear the config fields + python3 -c " +import re +text = open('$CONFIG').read() +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"\"') +text = patch(text, 'url', '\"\"') +text = patch(text, 'username', '\"\"') +text = patch(text, 'password_encrypted', '\"\"') +open('$CONFIG', 'w').write(text) +" + rm -f "$CARDDAV_BACKUP" + echo "✓ Switched to iCloud contacts" + elif [ -n "${CONTACT_CHOICE:-}" ]; then + read -p "Email address: " CARDDAV_EMAIL + if [ -z "$CARDDAV_EMAIL" ]; then + echo "ERROR: Email is required." >&2 + exit 1 + fi + + case "$CONTACT_CHOICE" in + 2) + CARDDAV_URL="https://www.googleapis.com/carddav/v1/principals/$CARDDAV_EMAIL/lists/default/" + echo " Note: Use a Google App Password, without spaces (https://myaccount.google.com/apppasswords)" + ;; + 3) + CARDDAV_URL="https://carddav.fastmail.com/dav/addressbooks/user/$CARDDAV_EMAIL/Default/" + echo " Note: Use a Fastmail App Password (Settings → Privacy & Security → App Passwords)" + ;; + 4) + read -p "Nextcloud server URL (e.g. https://cloud.example.com): " NC_SERVER + NC_SERVER="${NC_SERVER%/}" + CARDDAV_URL="$NC_SERVER/remote.php/dav" + ;; + 5) + read -p "CardDAV server URL: " CARDDAV_URL + if [ -z "$CARDDAV_URL" ]; then + echo "ERROR: URL is required." >&2 + exit 1 + fi + ;; + esac + + read -p "Username (leave empty to use email): " CARDDAV_USERNAME + read -s -p "App password: " CARDDAV_PASSWORD + echo "" + if [ -z "$CARDDAV_PASSWORD" ]; then + echo "ERROR: Password is required." >&2 + exit 1 + fi + + # Encrypt password and patch config + CARDDAV_ARGS="--email $CARDDAV_EMAIL --password $CARDDAV_PASSWORD --url $CARDDAV_URL" + if [ -n "$CARDDAV_USERNAME" ]; then + CARDDAV_ARGS="$CARDDAV_ARGS --username $CARDDAV_USERNAME" + fi + CARDDAV_JSON=$("$BINARY" carddav-setup $CARDDAV_ARGS 2>/dev/null) || CARDDAV_JSON="" + + if [ -z "$CARDDAV_JSON" ]; then + echo "⚠ CardDAV setup failed. You can configure it manually in $CONFIG" + else + CARDDAV_RESOLVED_URL=$(echo "$CARDDAV_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['url'])") + CARDDAV_ENC=$(echo "$CARDDAV_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['password_encrypted'])") + EFFECTIVE_USERNAME="${CARDDAV_USERNAME:-$CARDDAV_EMAIL}" + python3 -c " +import re +text = open('$CONFIG').read() +if 'carddav:' not in text: + lines = text.split('\\n') + insert_at = len(lines) + in_network = False + for i, line in enumerate(lines): + if line.startswith('network:'): + in_network = True + continue + if in_network and line and not line[0].isspace() and not line.startswith('#'): + insert_at = i + break + carddav = [' carddav:', ' email: \"\"', ' url: \"\"', ' username: \"\"', ' password_encrypted: \"\"'] + lines = lines[:insert_at] + carddav + lines[insert_at:] + text = '\\n'.join(lines) +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"$CARDDAV_EMAIL\"') +text = patch(text, 'url', '\"$CARDDAV_RESOLVED_URL\"') +text = patch(text, 'username', '\"$EFFECTIVE_USERNAME\"') +text = patch(text, 'password_encrypted', '\"$CARDDAV_ENC\"') +open('$CONFIG', 'w').write(text) +" + echo "✓ CardDAV configured: $CARDDAV_EMAIL → $CARDDAV_RESOLVED_URL" + cat > "$CARDDAV_BACKUP" << BKEOF +SAVED_CARDDAV_EMAIL="$CARDDAV_EMAIL" +SAVED_CARDDAV_URL="$CARDDAV_RESOLVED_URL" +SAVED_CARDDAV_USERNAME="$EFFECTIVE_USERNAME" +SAVED_CARDDAV_ENC="$CARDDAV_ENC" +BKEOF + fi + fi + fi +fi + +# ── Brief init start (fresh install only) ──────────────────── +# On a fresh install with no prior session, start the bridge briefly so it +# creates the DB schema and appears in Beeper as "stopped" during setup. +# We kill it immediately — all config questions (video, HEIC, handle) and +# the iCloud sync gate are answered next, THEN Apple login (APNs) happens +# at the very end so no messages are buffered before the bridge is ready. +_SESSION_FILE_CHECK="${XDG_DATA_HOME:-$HOME/.local/share}/mautrix-imessage/session.json" +if [ "$IS_FRESH_DB" = "true" ]; then + echo "" + echo "Initializing bridge database..." + if ! (cd "$DATA_DIR" && "$BINARY" init-db -c "$CONFIG"); then + echo "✗ Bridge database initialization failed — check the output above for details" + exit 1 + fi + echo "✓ Bridge database initialized — answering setup questions" +fi + +# ── Ensure bridge is stopped during setup ───────────────────── +# bbctl config posts StateStarting which makes Beeper show "Running". +# Stopping the systemd service disconnects the websocket, which makes +# Beeper detect it as unreachable and overrides the stale state. +if systemctl --user is-active mautrix-imessage >/dev/null 2>&1; then + systemctl --user stop mautrix-imessage +elif systemctl is-active mautrix-imessage >/dev/null 2>&1; then + sudo systemctl stop mautrix-imessage +fi + +# ── Check for existing login / prompt if needed ────────────── +DB_URI=$(grep 'uri:' "$CONFIG" | head -1 | sed 's/.*uri: file://' | sed 's/?.*//') +NEEDS_LOGIN=false + +SESSION_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/mautrix-imessage" +SESSION_FILE="$SESSION_DIR/session.json" +if [ -z "$DB_URI" ] || [ ! -f "$DB_URI" ]; then + # DB missing — check if session.json can auto-restore (has hardware_key) + if [ -f "$SESSION_FILE" ] && grep -q '"hardware_key"' "$SESSION_FILE" 2>/dev/null; then + echo "✓ No database yet, but session state found — bridge will auto-restore login" + NEEDS_LOGIN=false + else + NEEDS_LOGIN=true + fi +elif command -v sqlite3 >/dev/null 2>&1; then + LOGIN_COUNT=$(sqlite3 "$DB_URI" "SELECT count(*) FROM user_login;" 2>/dev/null || echo "0") + if [ "$LOGIN_COUNT" = "0" ]; then + # DB exists but no logins — check if auto-restore is possible + if [ -f "$SESSION_FILE" ] && grep -q '"hardware_key"' "$SESSION_FILE" 2>/dev/null; then + echo "✓ No login in database, but session state found — bridge will auto-restore" + NEEDS_LOGIN=false + else + NEEDS_LOGIN=true + fi + fi +else + NEEDS_LOGIN=true +fi + +# Require re-login if keychain trust-circle state is missing. +# This catches upgrades from pre-keychain versions where the device-passcode +# step was never run. If trustedpeers.plist exists with a user_identity, the +# keychain was joined successfully and any transient PCS errors are harmless. +TRUSTEDPEERS_FILE="$SESSION_DIR/trustedpeers.plist" +FORCE_CLEAR_STATE=false +if [ "$NEEDS_LOGIN" = "false" ]; then + HAS_CLIQUE=false + if [ -f "$TRUSTEDPEERS_FILE" ]; then + if grep -q "<key>userIdentity</key>\|<key>user_identity</key>" "$TRUSTEDPEERS_FILE" 2>/dev/null; then + HAS_CLIQUE=true + fi + fi + + if [ "$HAS_CLIQUE" != "true" ]; then + echo "⚠ Existing login found, but keychain trust-circle is not initialized." + echo " Forcing fresh login so device-passcode step can run." + NEEDS_LOGIN=true + FORCE_CLEAR_STATE=true + fi +fi + +# ── Ensure video_transcoding key exists in config ────────────── +if ! grep -q 'video_transcoding:' "$CONFIG" 2>/dev/null; then + sed -i '/cloudkit_backfill:/i\ video_transcoding: false' "$CONFIG" +fi + +# ── Video transcoding (ffmpeg) ───────────────────────────────── +CURRENT_VIDEO_TRANSCODING=$(grep 'video_transcoding:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*video_transcoding: *//' || true) +if [ -t 0 ]; then + echo "" + echo "Video Transcoding:" + echo " When enabled, non-MP4 videos (e.g. QuickTime .mov) are automatically" + echo " converted to MP4 for broad Matrix client compatibility." + echo " Requires ffmpeg." + echo "" + if [ "$CURRENT_VIDEO_TRANSCODING" = "true" ]; then + read -p "Enable video transcoding/remuxing? [Y/n]: " ENABLE_VT + case "$ENABLE_VT" in + [nN]*) + sed -i "s/video_transcoding: .*/video_transcoding: false/" "$CONFIG" + echo "✓ Video transcoding disabled" + ;; + *) + if ! command -v ffmpeg >/dev/null 2>&1; then + echo " ffmpeg not found — installing..." + if command -v apt >/dev/null 2>&1; then + sudo apt install -y ffmpeg + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y ffmpeg + elif command -v pacman >/dev/null 2>&1; then + sudo pacman -S --noconfirm ffmpeg + elif command -v zypper >/dev/null 2>&1; then + sudo zypper install -y ffmpeg + elif command -v apk >/dev/null 2>&1; then + sudo apk add ffmpeg + else + echo " ⚠ Could not detect package manager — please install ffmpeg manually" + fi + fi + echo "✓ Video transcoding enabled" + ;; + esac + else + read -p "Enable video transcoding/remuxing? [y/N]: " ENABLE_VT + case "$ENABLE_VT" in + [yY]*) + sed -i "s/video_transcoding: .*/video_transcoding: true/" "$CONFIG" + if ! command -v ffmpeg >/dev/null 2>&1; then + echo " ffmpeg not found — installing..." + if command -v apt >/dev/null 2>&1; then + sudo apt install -y ffmpeg + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y ffmpeg + elif command -v pacman >/dev/null 2>&1; then + sudo pacman -S --noconfirm ffmpeg + elif command -v zypper >/dev/null 2>&1; then + sudo zypper install -y ffmpeg + elif command -v apk >/dev/null 2>&1; then + sudo apk add ffmpeg + else + echo " ⚠ Could not detect package manager — please install ffmpeg manually" + fi + fi + echo "✓ Video transcoding enabled" + ;; + *) + echo "✓ Video transcoding disabled" + ;; + esac + fi +fi + +# ── Ensure heic_conversion key exists in config ────────────── +if ! grep -q 'heic_conversion:' "$CONFIG" 2>/dev/null; then + sed -i '/video_transcoding:/a\ heic_conversion: false' "$CONFIG" +fi + +# ── HEIC conversion (libheif) ───────────────────────────────── +CURRENT_HEIC_CONVERSION=$(grep 'heic_conversion:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_conversion: *//' || true) +if [ -t 0 ]; then + echo "" + echo "HEIC Conversion:" + echo " When enabled, HEIC/HEIF images are automatically converted to JPEG" + echo " for broad Matrix client compatibility." + echo " Requires libheif." + echo "" + if [ "$CURRENT_HEIC_CONVERSION" = "true" ]; then + read -p "Enable HEIC to JPEG conversion? [Y/n]: " ENABLE_HC + case "$ENABLE_HC" in + [nN]*) + sed -i "s/heic_conversion: .*/heic_conversion: false/" "$CONFIG" + echo "✓ HEIC conversion disabled" + ;; + *) + if command -v apt >/dev/null 2>&1; then + dpkg -s libheif-dev >/dev/null 2>&1 || sudo apt install -y libheif-dev + elif command -v dnf >/dev/null 2>&1; then + rpm -q libheif-devel >/dev/null 2>&1 || sudo dnf install -y libheif-devel + elif command -v pacman >/dev/null 2>&1; then + pacman -Qi libheif >/dev/null 2>&1 || sudo pacman -S --noconfirm libheif + elif command -v zypper >/dev/null 2>&1; then + rpm -q libheif-devel >/dev/null 2>&1 || sudo zypper install -y libheif-devel + elif command -v apk >/dev/null 2>&1; then + apk info -e libheif-dev >/dev/null 2>&1 || sudo apk add libheif-dev + else + echo " ⚠ Could not detect package manager — please install libheif manually" + fi + echo "✓ HEIC conversion enabled" + ;; + esac + else + read -p "Enable HEIC to JPEG conversion? [y/N]: " ENABLE_HC + case "$ENABLE_HC" in + [yY]*) + sed -i "s/heic_conversion: .*/heic_conversion: true/" "$CONFIG" + if command -v apt >/dev/null 2>&1; then + dpkg -s libheif-dev >/dev/null 2>&1 || sudo apt install -y libheif-dev + elif command -v dnf >/dev/null 2>&1; then + rpm -q libheif-devel >/dev/null 2>&1 || sudo dnf install -y libheif-devel + elif command -v pacman >/dev/null 2>&1; then + pacman -Qi libheif >/dev/null 2>&1 || sudo pacman -S --noconfirm libheif + elif command -v zypper >/dev/null 2>&1; then + rpm -q libheif-devel >/dev/null 2>&1 || sudo zypper install -y libheif-devel + elif command -v apk >/dev/null 2>&1; then + apk info -e libheif-dev >/dev/null 2>&1 || sudo apk add libheif-dev + else + echo " ⚠ Could not detect package manager — please install libheif manually" + fi + echo "✓ HEIC conversion enabled" + ;; + *) + echo "✓ HEIC conversion disabled" + ;; + esac + fi +fi + +# ── HEIC JPEG quality (only if HEIC conversion is enabled) ─── +HEIC_ENABLED=$(grep 'heic_conversion:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_conversion: *//' || true) +if [ "$HEIC_ENABLED" = "true" ]; then + if ! grep -q 'heic_jpeg_quality:' "$CONFIG" 2>/dev/null; then + sed -i '/heic_conversion:/a\ heic_jpeg_quality: 95' "$CONFIG" + fi +else + sed -i '/heic_jpeg_quality:/d' "$CONFIG" +fi +if [ "$HEIC_ENABLED" = "true" ] && [ -t 0 ]; then + CURRENT_QUALITY=$(grep 'heic_jpeg_quality:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_jpeg_quality: *//' || echo "95") + [ -z "$CURRENT_QUALITY" ] && CURRENT_QUALITY=95 + echo "" + read -p "JPEG quality for HEIC conversion (1–100) [$CURRENT_QUALITY]: " NEW_QUALITY + if [ -n "$NEW_QUALITY" ]; then + if [ "$NEW_QUALITY" -ge 1 ] 2>/dev/null && [ "$NEW_QUALITY" -le 100 ] 2>/dev/null; then + sed -i "s/heic_jpeg_quality: .*/heic_jpeg_quality: $NEW_QUALITY/" "$CONFIG" + echo "✓ JPEG quality set to $NEW_QUALITY" + else + echo " ⚠ Invalid quality '$NEW_QUALITY' — keeping $CURRENT_QUALITY" + fi + else + echo "✓ JPEG quality: $CURRENT_QUALITY" + fi +fi + +# ── Write auto-update wrapper ───────────────────────────────── +cat > "$DATA_DIR/start.sh" << HEADER_EOF +#!/bin/bash +BBCTL_DIR="$BBCTL_DIR" +BBCTL_BRANCH="$BBCTL_BRANCH" +BINARY="$BINARY" +CONFIG="$CONFIG" +HEADER_EOF +cat >> "$DATA_DIR/start.sh" << 'BODY_EOF' +BBCTL_REPO="${BBCTL_REPO:-https://github.com/lrhodin/imessage.git}" + +# Extend PATH to find go +export PATH="$PATH:/usr/local/go/bin:/opt/homebrew/bin:$HOME/go/bin" + +# ANSI helpers +BOLD='\033[1m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[0;33m' +DIM='\033[2m' +RESET='\033[0m' + +ts() { date '+%H:%M:%S'; } +ok() { printf "${DIM}$(ts)${RESET} ${GREEN}✓${RESET} %s\n" "$*"; } +step() { printf "${DIM}$(ts)${RESET} ${CYAN}▶${RESET} %s\n" "$*"; } +warn() { printf "${DIM}$(ts)${RESET} ${YELLOW}⚠${RESET} %s\n" "$*"; } + +printf "\n ${BOLD}iMessage Bridge${RESET}\n\n" + +# Bootstrap sparse clone if it doesn't exist yet +if [ ! -d "$BBCTL_DIR/.git" ] && command -v go >/dev/null 2>&1; then + step "Setting up bbctl sparse checkout..." + EXISTING_BBCTL="" + [ -x "$BBCTL_DIR/bbctl" ] && EXISTING_BBCTL=$(mktemp) && cp "$BBCTL_DIR/bbctl" "$EXISTING_BBCTL" + rm -rf "$BBCTL_DIR" + mkdir -p "$(dirname "$BBCTL_DIR")" + git clone --filter=blob:none --no-checkout --quiet \ + --branch "$BBCTL_BRANCH" "$BBCTL_REPO" "$BBCTL_DIR" + git -C "$BBCTL_DIR" sparse-checkout init --cone + git -C "$BBCTL_DIR" sparse-checkout set cmd/bbctl + git -C "$BBCTL_DIR" checkout --quiet "$BBCTL_BRANCH" + (cd "$BBCTL_DIR" && go build -o bbctl ./cmd/bbctl/ 2>&1) | sed 's/^/ /' + [ -n "$EXISTING_BBCTL" ] && rm -f "$EXISTING_BBCTL" + ok "bbctl ready" +fi + +if [ -d "$BBCTL_DIR/.git" ] && command -v go >/dev/null 2>&1; then + git -C "$BBCTL_DIR" fetch origin --quiet 2>/dev/null || true + LOCAL=$(git -C "$BBCTL_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") + REMOTE=$(git -C "$BBCTL_DIR" rev-parse --short "origin/$BBCTL_BRANCH" 2>/dev/null || echo "unknown") + if [ "$LOCAL" != "$REMOTE" ] && [ "$LOCAL" != "unknown" ] && [ "$REMOTE" != "unknown" ]; then + step "Updating bbctl $LOCAL → $REMOTE" + T0=$(date +%s) + git -C "$BBCTL_DIR" reset --hard "origin/$BBCTL_BRANCH" --quiet + step "Building bbctl..." + (cd "$BBCTL_DIR" && go build -o bbctl ./cmd/bbctl/ 2>&1) | sed 's/^/ /' + T1=$(date +%s) + ok "bbctl updated ($(( T1 - T0 ))s)" + else + ok "bbctl $LOCAL" + fi +elif [ -d "$BBCTL_DIR/.git" ]; then + warn "go not found — skipping bbctl update" +fi + +# Fix permissions before starting — the config upgrader may have replaced +# the user's permissions with example.com defaults on a previous run. +# Detects: empty username (@:, @":), example.com defaults, wildcard relay. +if grep -q '"@:\|"@":\|@.*example\.com\|"\*":.*relay' "$CONFIG" 2>/dev/null; then + BBCTL_BIN="$BBCTL_DIR/bbctl" + if [ -x "$BBCTL_BIN" ]; then + FIX_USER=$("$BBCTL_BIN" whoami 2>/dev/null | head -1 || true) + if [ -n "$FIX_USER" ] && [ "$FIX_USER" != "null" ]; then + FIX_MXID="@${FIX_USER}:beeper.com" + sed -i '/permissions:/,/^[^ ]/{ + s/"@[^"]*": admin/"'"$FIX_MXID"'": admin/ + /@.*example\.com/d + /"\*":.*relay/d + /"@":/d + /"@:/d + }' "$CONFIG" + ok "Fixed permissions: $FIX_MXID" + fi + fi +fi + +step "Starting bridge..." +exec "$BINARY" -n -c "$CONFIG" +BODY_EOF +chmod +x "$DATA_DIR/start.sh" + +# ── iCloud sync gate (CloudKit + fresh DB) ─────────────────── +# Runs before Apple login so that iCloud is fully synced before APNs first +# connects. This ensures CloudKit backfill can deduplicate any messages that +# Apple buffers and delivers the moment the bridge registers with APNs. +_ck_backfill=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +_ck_source=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) +if [ "$IS_FRESH_DB" = "true" ] && [ "$_ck_backfill" = "true" ] && [ "$_ck_source" != "chatdb" ] && [ -t 0 ]; then + echo "" + echo "┌─────────────────────────────────────────────────────────────┐" + echo "│ Last step: sync iCloud Messages before starting │" + echo "│ │" + echo "│ On your iPhone, iPad, Mac, or OpenBubbles: │" + echo "│ Settings → [Your Name] → iCloud → Messages → Sync Now │" + echo "│ │" + echo "│ Wait for sync to complete, then press Y to start. │" + echo "└─────────────────────────────────────────────────────────────┘" + echo "" + read -p "Have you synced iCloud Messages and are ready to start? [y/N]: " _sync_ready + case "$_sync_ready" in + [yY]*) echo "✓ Starting bridge" ;; + *) + echo "" + echo "Re-run 'make install-beeper' after syncing iCloud Messages." + exit 0 + ;; + esac +fi + +# ── Apple login (APNs connects here — after all questions) ─── +LOGIN_RAN=false +if [ "$NEEDS_LOGIN" = "true" ]; then + echo "" + echo "┌─────────────────────────────────────────────────┐" + echo "│ No valid iMessage login found — starting login │" + echo "└─────────────────────────────────────────────────┘" + echo "" + # Stop the bridge if running (otherwise it holds the DB lock) + if systemctl --user is-active mautrix-imessage >/dev/null 2>&1; then + systemctl --user stop mautrix-imessage + elif systemctl is-active mautrix-imessage >/dev/null 2>&1; then + sudo systemctl stop mautrix-imessage + fi + + if [ "${FORCE_CLEAR_STATE:-false}" = "true" ]; then + echo "Clearing stale local state before login..." + rm -f "$DB_URI" "$DB_URI-wal" "$DB_URI-shm" + rm -f "$SESSION_DIR/session.json" "$SESSION_DIR/identity.plist" "$SESSION_DIR/trustedpeers.plist" + fi + + # Run login from DATA_DIR so that relative paths (state/anisette/) + # resolve to the same location as when systemd runs the bridge. + (cd "$DATA_DIR" && "$BINARY" login -n -c "$CONFIG") + LOGIN_RAN=true + echo "" + + # Re-check permissions after login — the config upgrader may have + # corrupted them even with -n if repairPermissions couldn't determine + # the username. + if [ -n "$WHOAMI" ] && [ "$WHOAMI" != "null" ]; then + if fix_permissions "$CONFIG" "$WHOAMI"; then + echo "✓ Fixed permissions after login: @${WHOAMI}:beeper.com → admin" + fi + fi +fi + +# ── Stop bridge before applying config changes ──────────────── +if systemctl --user is-active mautrix-imessage >/dev/null 2>&1; then + systemctl --user stop mautrix-imessage +elif systemctl is-active mautrix-imessage >/dev/null 2>&1; then + sudo systemctl stop mautrix-imessage +fi + +# ── Preferred handle (runs every time, can reconfigure) ──────── +HANDLE_BACKUP="$DATA_DIR/.preferred-handle" +# Re-read in case login just set it +CURRENT_HANDLE=$(grep 'preferred_handle:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*preferred_handle: *//;s/['\"]//g" | tr -d ' ' || true) + +# Try to recover from backups if not set in config +if [ -z "$CURRENT_HANDLE" ]; then + if command -v sqlite3 >/dev/null 2>&1 && [ -n "${DB_URI:-}" ] && [ -f "${DB_URI:-}" ]; then + CURRENT_HANDLE=$(sqlite3 "$DB_URI" "SELECT json_extract(metadata, '$.preferred_handle') FROM user_login LIMIT 1;" 2>/dev/null || true) + fi + if [ -z "$CURRENT_HANDLE" ] && [ -f "$SESSION_DIR/session.json" ] && command -v python3 >/dev/null 2>&1; then + CURRENT_HANDLE=$(python3 -c "import json; print(json.load(open('$SESSION_DIR/session.json')).get('preferred_handle',''))" 2>/dev/null || true) + fi + if [ -z "$CURRENT_HANDLE" ] && [ -f "$HANDLE_BACKUP" ]; then + CURRENT_HANDLE=$(cat "$HANDLE_BACKUP") + fi +fi + +# Skip handle prompt if login just ran and already set a handle — login +# asks "Send messages as:" so no need to ask twice. +if [ -t 0 ] && { [ "$LOGIN_RAN" != "true" ] || [ -z "$CURRENT_HANDLE" ]; }; then + # Get available handles from session state (available after login) + AVAILABLE_HANDLES=$("$BINARY" list-handles 2>/dev/null | grep -E '^(tel:|mailto:)' || true) + if [ -n "$AVAILABLE_HANDLES" ]; then + echo "" + echo "Preferred handle (your iMessage sender address):" + i=1 + declare -a HANDLE_LIST=() + while IFS= read -r h; do + MARKER="" + if [ "$h" = "$CURRENT_HANDLE" ]; then + MARKER=" (current)" + fi + echo " $i) $h$MARKER" + HANDLE_LIST+=("$h") + i=$((i + 1)) + done <<< "$AVAILABLE_HANDLES" + + if [ -n "$CURRENT_HANDLE" ]; then + read -p "Choice [keep current]: " HANDLE_CHOICE + else + read -p "Choice [1]: " HANDLE_CHOICE + fi + + if [ -n "$HANDLE_CHOICE" ]; then + if [ "$HANDLE_CHOICE" -ge 1 ] 2>/dev/null && [ "$HANDLE_CHOICE" -le "${#HANDLE_LIST[@]}" ] 2>/dev/null; then + CURRENT_HANDLE="${HANDLE_LIST[$((HANDLE_CHOICE - 1))]}" + fi + elif [ -z "$CURRENT_HANDLE" ] && [ ${#HANDLE_LIST[@]} -gt 0 ]; then + CURRENT_HANDLE="${HANDLE_LIST[0]}" + fi + elif [ -n "$CURRENT_HANDLE" ]; then + echo "" + echo "Preferred handle: $CURRENT_HANDLE" + read -p "New handle, or Enter to keep current: " NEW_HANDLE + if [ -n "$NEW_HANDLE" ]; then + CURRENT_HANDLE="$NEW_HANDLE" + fi + else + # list-handles returned empty (e.g. session not yet populated). + # Fall back to manual entry so the bridge doesn't start without a handle. + echo "" + echo "Could not detect handles automatically." + read -p "Enter your iMessage handle (e.g. tel:+12345678900 or mailto:you@icloud.com): " CURRENT_HANDLE + fi +fi + +# Write preferred handle to config (add key if missing, patch if present) +if [ -n "${CURRENT_HANDLE:-}" ]; then + if grep -q 'preferred_handle:' "$CONFIG" 2>/dev/null; then + sed -i "s|preferred_handle: .*|preferred_handle: '$CURRENT_HANDLE'|" "$CONFIG" + else + sed -i "/^network:/a\\ preferred_handle: '$CURRENT_HANDLE'" "$CONFIG" + fi + echo "✓ Preferred handle: $CURRENT_HANDLE" + echo "$CURRENT_HANDLE" > "$HANDLE_BACKUP" +fi + +# ── Install / update systemd service ───────────────────────── +# Detect whether systemd user sessions work. In containers (LXC) or when +# running as root, the user instance is often unavailable — fall back to a +# system-level service in that case. +USER_SERVICE_FILE="$HOME/.config/systemd/user/mautrix-imessage.service" +SYSTEM_SERVICE_FILE="/etc/systemd/system/mautrix-imessage.service" + +if command -v systemctl >/dev/null 2>&1; then + if systemctl --user status >/dev/null 2>&1; then + SYSTEMD_MODE="user" + SERVICE_FILE="$USER_SERVICE_FILE" + else + SYSTEMD_MODE="system" + SERVICE_FILE="$SYSTEM_SERVICE_FILE" + fi +else + SYSTEMD_MODE="none" + SERVICE_FILE="" +fi + +install_systemd_user() { + # Enable lingering so user services survive SSH session closures + if command -v loginctl >/dev/null 2>&1 && [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" != "yes" ]; then + sudo loginctl enable-linger "$USER" 2>/dev/null || true + fi + mkdir -p "$(dirname "$USER_SERVICE_FILE")" + cat > "$USER_SERVICE_FILE" << EOF +[Unit] +Description=mautrix-imessage bridge (Beeper) +After=network.target + +[Service] +Type=simple +WorkingDirectory=$DATA_DIR +ExecStart=/bin/bash $DATA_DIR/start.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +EOF + systemctl --user daemon-reload + systemctl --user enable mautrix-imessage +} + +install_systemd_system() { + cat > "$SYSTEM_SERVICE_FILE" << EOF +[Unit] +Description=mautrix-imessage bridge (Beeper) +After=network.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=$DATA_DIR +ExecStart=/bin/bash $DATA_DIR/start.sh +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + systemctl enable mautrix-imessage +} + +if [ "$SYSTEMD_MODE" = "user" ]; then + if [ -f "$USER_SERVICE_FILE" ]; then + install_systemd_user + systemctl --user restart mautrix-imessage + echo "✓ Bridge restarted" + else + echo "" + read -p "Install as a systemd user service? [Y/n] " answer + case "$answer" in + [nN]*) ;; + *) install_systemd_user + systemctl --user start mautrix-imessage + echo "✓ Bridge started (systemd user service installed)" ;; + esac + fi +elif [ "$SYSTEMD_MODE" = "system" ]; then + if [ -f "$SYSTEM_SERVICE_FILE" ]; then + install_systemd_system + systemctl restart mautrix-imessage + echo "✓ Bridge restarted" + else + echo "" + echo "Note: systemd user session not available (container/root)." + read -p "Install as a system-level systemd service? [Y/n] " answer + case "$answer" in + [nN]*) ;; + *) install_systemd_system + systemctl start mautrix-imessage + echo "✓ Bridge started (system service installed)" ;; + esac + fi +fi + +echo "" +echo "═══════════════════════════════════════════════" +echo " Setup Complete" +echo "═══════════════════════════════════════════════" +echo "" +echo " Binary: $BINARY" +echo " Config: $CONFIG" +echo "" +if [ "$SYSTEMD_MODE" = "user" ] && [ -f "$USER_SERVICE_FILE" ]; then + echo " Status: systemctl --user status mautrix-imessage" + echo " Logs: journalctl --user -u mautrix-imessage -f" + echo " Stop: systemctl --user stop mautrix-imessage" + echo " Restart: systemctl --user restart mautrix-imessage" +elif [ "$SYSTEMD_MODE" = "system" ] && [ -f "$SYSTEM_SERVICE_FILE" ]; then + echo " Status: systemctl status mautrix-imessage" + echo " Logs: journalctl -u mautrix-imessage -f" + echo " Stop: systemctl stop mautrix-imessage" + echo " Restart: systemctl restart mautrix-imessage" +else + echo " Run manually:" + echo " cd $(dirname "$CONFIG") && $BINARY -c $CONFIG" +fi +echo "" diff --git a/scripts/install-beeper.sh b/scripts/install-beeper.sh new file mode 100755 index 00000000..81990692 --- /dev/null +++ b/scripts/install-beeper.sh @@ -0,0 +1,1138 @@ +#!/bin/bash +set -euo pipefail + +BINARY="$1" +DATA_DIR="$2" +BUNDLE_ID="$3" +PREBUILT_BBCTL="${4:-}" + +BRIDGE_NAME="${BRIDGE_NAME:-sh-imessage}" + +BINARY="$(cd "$(dirname "$BINARY")" && pwd)/$(basename "$BINARY")" +CONFIG="$DATA_DIR/config.yaml" +PLIST="$HOME/Library/LaunchAgents/$BUNDLE_ID.plist" + +# Where we build/cache bbctl (sparse clone — only cmd/bbctl/) +BBCTL_DIR="${BBCTL_DIR:-$HOME/.local/share/mautrix-imessage/bbctl}" +BBCTL_REPO="${BBCTL_REPO:-https://github.com/lrhodin/imessage.git}" +BBCTL_BRANCH="${BBCTL_BRANCH:-master}" + +echo "" +echo "═══════════════════════════════════════════════" +echo " iMessage Bridge Setup (Beeper)" +echo "═══════════════════════════════════════════════" +echo "" + +# ── Stop any running bridge instance immediately ────────────── +# Do this before any setup work so the bridge isn't running while we ask +# questions, patch config, or run init-db. On re-setup scenarios (bbctl delete), +# systemd/LaunchAgent may have restarted the bridge — stop it now. +launchctl bootout "gui/$(id -u)/$BUNDLE_ID" 2>/dev/null || true + +# ── Permission repair helper ────────────────────────────────── +# Detects and fixes broken permissions in config.yaml. Matches the same +# patterns as repairPermissions() / fixPermissionsOnDisk() in the Go code: +# - Empty username: "@:beeper.com", "@": ... +# - Example defaults: "@admin:example.com", "example.com", "*": relay +# Usage: fix_permissions <config_path> <username> +fix_permissions() { + local config="$1" whoami="$2" + if grep -q '"@:\|"@":\|@.*example\.com\|"\*":.*relay' "$config" 2>/dev/null; then + local mxid="@${whoami}:beeper.com" + sed -i '' '/permissions:/,/^[^ ]/{ + s/"@[^"]*": admin/"'"$mxid"'": admin/ + /@.*example\.com/d + /"\*":.*relay/d + /"@":/d + /"@:/d + }' "$config" + return 0 + fi + return 1 +} + +# ── Build bbctl from source ─────────────────────────────────── +BBCTL="$BBCTL_DIR/bbctl" + +# Warn about old full-repo clone and offer to remove it +OLD_BBCTL_DIR="$HOME/.local/share/mautrix-imessage/bridge-manager" +if [ -d "$OLD_BBCTL_DIR/.git" ] && [ "$BBCTL_DIR" != "$OLD_BBCTL_DIR" ]; then + echo "⚠ Found old full-repo clone at $OLD_BBCTL_DIR" + echo " This is no longer needed (bbctl now uses a sparse checkout)." + if [ -t 0 ]; then + read -p " Delete it to free disk space? [Y/n]: " DEL_OLD + case "$DEL_OLD" in + [nN]*) ;; + *) rm -rf "$OLD_BBCTL_DIR" + echo " ✓ Removed $OLD_BBCTL_DIR" ;; + esac + else + echo " You can safely delete it: rm -rf $OLD_BBCTL_DIR" + fi +fi + +build_bbctl() { + echo "Building bbctl..." + mkdir -p "$(dirname "$BBCTL_DIR")" + if [ -d "$BBCTL_DIR/.git" ]; then + cd "$BBCTL_DIR" + git fetch --quiet origin + git reset --hard --quiet "origin/$BBCTL_BRANCH" + else + rm -rf "$BBCTL_DIR" + git clone --filter=blob:none --no-checkout --quiet \ + --branch "$BBCTL_BRANCH" "$BBCTL_REPO" "$BBCTL_DIR" + cd "$BBCTL_DIR" + git sparse-checkout init --cone + git sparse-checkout set cmd/bbctl + git checkout --quiet "$BBCTL_BRANCH" + fi + go build -o bbctl ./cmd/bbctl/ 2>&1 + cd - >/dev/null + echo "✓ Built bbctl" +} + +if [ -n "$PREBUILT_BBCTL" ] && [ -x "$PREBUILT_BBCTL" ]; then + # Install the bbctl built by `make build` into BBCTL_DIR + mkdir -p "$BBCTL_DIR" + cp "$PREBUILT_BBCTL" "$BBCTL" + echo "✓ Installed bbctl to $BBCTL_DIR/" +elif [ ! -x "$BBCTL" ]; then + build_bbctl +else + echo "✓ Found bbctl: $BBCTL" + # Update if repo has changes + if [ -d "$BBCTL_DIR/.git" ]; then + cd "$BBCTL_DIR" + git fetch --quiet origin 2>/dev/null || true + LOCAL=$(git rev-parse HEAD 2>/dev/null) + REMOTE=$(git rev-parse "origin/$BBCTL_BRANCH" 2>/dev/null || echo "$LOCAL") + cd - >/dev/null + if [ "$LOCAL" != "$REMOTE" ]; then + echo " Updating bbctl..." + build_bbctl + fi + fi +fi + +# ── Check bbctl login ──────────────────────────────────────── +if ! "$BBCTL" whoami >/dev/null 2>&1 || "$BBCTL" whoami 2>&1 | grep -qi "not logged in"; then + echo "" + echo "Not logged into Beeper. Running bbctl login..." + echo "" + "$BBCTL" login +fi +# Capture username (discard stderr so "Fetching whoami..." doesn't contaminate) +WHOAMI=$("$BBCTL" whoami 2>/dev/null | head -1 || true) +# On slow machines the Beeper API may not have the username ready yet — retry +if [ -z "$WHOAMI" ] || [ "$WHOAMI" = "null" ]; then + for i in 1 2 3 4 5; do + echo " Waiting for username from Beeper API (attempt $i/5)..." + sleep 3 + WHOAMI=$("$BBCTL" whoami 2>/dev/null | head -1 || true) + [ -n "$WHOAMI" ] && [ "$WHOAMI" != "null" ] && break + done +fi +if [ -z "$WHOAMI" ] || [ "$WHOAMI" = "null" ]; then + echo "" + echo "ERROR: Could not get username from Beeper API." + echo " This can happen when the API is slow to propagate after login." + echo " Wait a minute and re-run: make install-beeper" + exit 1 +fi +echo "✓ Logged in: $WHOAMI" + +# ── Check for existing bridge registration ──────────────────── +# If the bridge is already registered on the server but we're about to +# generate a fresh config (no local config file), the old registration's +# rooms would be orphaned. Delete it first so the server cleans up rooms. +EXISTING_BRIDGE=$("$BBCTL" whoami 2>&1 | grep "^\s*$BRIDGE_NAME " || true) +if [ -n "$EXISTING_BRIDGE" ] && [ ! -f "$CONFIG" ]; then + echo "" + echo "⚠ Found existing '$BRIDGE_NAME' registration on server but no local config." + echo " Deleting old registration to avoid orphaned rooms..." + "$BBCTL" delete "$BRIDGE_NAME" + echo "✓ Old registration cleaned up" + echo " Waiting for server-side deletion to complete..." + sleep 5 +fi + +# ── Generate config via bbctl ───────────────────────────────── +mkdir -p "$DATA_DIR" +if [ -f "$CONFIG" ] && [ -z "$EXISTING_BRIDGE" ]; then + # Config exists locally but bridge isn't registered on server (e.g. bbctl + # delete was run manually). The stale config has an invalid as_token and + # the DB references rooms that no longer exist. + # + # Double-check by retrying bbctl whoami — a transient network error or the + # bridge restarting can cause the first check to return empty even though + # the registration is fine. + echo "⚠ Bridge not found in bbctl whoami — retrying in 3s to rule out transient error..." + sleep 3 + EXISTING_BRIDGE=$("$BBCTL" whoami 2>&1 | grep "^\s*$BRIDGE_NAME " || true) + if [ -z "$EXISTING_BRIDGE" ]; then + echo "⚠ Local config exists but bridge is not registered on server." + echo " Removing stale config and database to re-register..." + rm -f "$CONFIG" + DB_DIR="$(cd "$DATA_DIR" && pwd)" + rm -f "$DB_DIR"/mautrix-imessage.db* + else + echo "✓ Bridge found on retry — keeping existing config and database" + fi +fi +if [ -f "$CONFIG" ]; then + echo "✓ Config already exists at $CONFIG" + echo " Delete it to regenerate from Beeper." +else + echo "Generating Beeper config..." + for attempt in 1 2 3 4 5; do + if "$BBCTL" config --type imessage-v2 -o "$CONFIG" "$BRIDGE_NAME" 2>&1; then + break + fi + if [ "$attempt" -eq 5 ]; then + echo "ERROR: Failed to register appservice after $attempt attempts." + exit 1 + fi + echo " Retrying in 5s... (attempt $attempt/5)" + sleep 5 + done + # Make DB path absolute so it doesn't depend on working directory + DATA_ABS_TMP="$(cd "$DATA_DIR" && pwd)" + sed -i '' "s|uri: file:mautrix-imessage.db|uri: file:$DATA_ABS_TMP/mautrix-imessage.db|" "$CONFIG" + # iMessage CloudKit chats can have tens of thousands of messages. + # Deliver all history in one forward batch to avoid DAG fragmentation. + sed -i '' 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + sed -i '' 's/max_catchup_messages: [0-9]*/max_catchup_messages: 5000/' "$CONFIG" + sed -i '' 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + # Enable unlimited backward backfill (default is 0 which disables it) + sed -i '' 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + # Use 1s between batches — fast enough for backfill, prevents idle hot-loop + sed -i '' 's/batch_delay: [0-9]*/batch_delay: 1/' "$CONFIG" + + echo "✓ Config saved to $CONFIG" +fi + +# No bridge-state override needed here — the bridge will post its own +# state when it actually starts at the end of setup. + +# ── Belt-and-suspenders: fix broken permissions ─────────────── +if [ -n "$WHOAMI" ] && [ "$WHOAMI" != "null" ]; then + if fix_permissions "$CONFIG" "$WHOAMI"; then + echo "✓ Fixed permissions: @${WHOAMI}:beeper.com → admin" + fi +else + if grep -q '"@:\|"@":\|@.*example\.com' "$CONFIG" 2>/dev/null; then + echo "" + echo "ERROR: Config has broken permissions and cannot determine your username." + echo " Try: $BBCTL login && rm $CONFIG && re-run make install-beeper" + echo "" + exit 1 + fi +fi + +# Ensure backfill settings are sane for existing configs +PATCHED_BACKFILL=false +# Only enable unlimited backward backfill when max_initial is uncapped. +# When the user caps max_initial_messages, max_batches stays at 0 so the +# bridge won't backfill beyond the cap. +if grep -q 'max_initial_messages: 2147483647' "$CONFIG" 2>/dev/null; then + if grep -q 'max_batches: 0$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + PATCHED_BACKFILL=true + fi +fi +if grep -q 'max_initial_messages: [0-9]\{1,2\}$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + PATCHED_BACKFILL=true +fi +if grep -q 'batch_size: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + PATCHED_BACKFILL=true +fi +if grep -q 'batch_delay: 0$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/batch_delay: 0$/batch_delay: 1/' "$CONFIG" + PATCHED_BACKFILL=true +fi +if [ "$PATCHED_BACKFILL" = true ]; then + echo "✓ Updated backfill settings (max_initial=unlimited, batch_size=10000, max_batches=-1)" +fi + +if ! grep -q "beeper" "$CONFIG" 2>/dev/null; then + echo "" + echo "WARNING: Config doesn't appear to contain Beeper details." + echo " Try: rm $CONFIG && re-run make install-beeper" + echo "" + exit 1 +fi + +# ── Ensure cloudkit_backfill key exists in config ───────────── +if ! grep -q 'cloudkit_backfill:' "$CONFIG" 2>/dev/null; then + # Insert after initial_sync_days if it exists (old configs), otherwise append + if grep -q 'initial_sync_days:' "$CONFIG" 2>/dev/null; then + sed -i '' '/initial_sync_days:/a\ + cloudkit_backfill: false' "$CONFIG" + else + echo " cloudkit_backfill: false" >> "$CONFIG" + fi +fi + +# ── Ensure backfill_source key exists in config ─────────────── +if ! grep -q 'backfill_source:' "$CONFIG" 2>/dev/null; then + sed -i '' '/cloudkit_backfill:/a\ + backfill_source: cloudkit' "$CONFIG" +fi + +# ── Backfill source selection ───────────────────────────────── +# On first run (fresh DB), show a 3-way prompt. On re-runs, preserve existing. +DB_PATH_CHECK=$(grep 'uri:' "$CONFIG" | head -1 | sed 's/.*uri: file://' | sed 's/?.*//') +IS_FRESH_DB=false +if [ -z "$DB_PATH_CHECK" ] || [ ! -f "$DB_PATH_CHECK" ]; then + IS_FRESH_DB=true +fi + +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +CURRENT_SOURCE=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) + +if [ -t 0 ]; then + if [ "$IS_FRESH_DB" = "true" ]; then + echo "" + echo "Message History Backfill:" + echo " 1) iCloud (CloudKit) — sync from iCloud, requires device PIN" + echo " 2) Local chat.db — for legacy systems, read macOS Messages database, requires Full Disk Access" + echo " 3) Disabled — real-time messages only" + echo "" + read -p "Choose [1/2/3]: " BACKFILL_CHOICE + case "$BACKFILL_CHOICE" in + 2) + sed -i '' "s/cloudkit_backfill: .*/cloudkit_backfill: true/" "$CONFIG" + sed -i '' "s/backfill_source: .*/backfill_source: chatdb/" "$CONFIG" + echo "✓ Chat.db backfill enabled — requires Full Disk Access for the bridge binary" + ;; + 3) + sed -i '' "s/cloudkit_backfill: .*/cloudkit_backfill: false/" "$CONFIG" + sed -i '' "s/backfill_source: .*/backfill_source: cloudkit/" "$CONFIG" + echo "✓ Backfill disabled — real-time messages only, no PIN needed" + ;; + *) + sed -i '' "s/cloudkit_backfill: .*/cloudkit_backfill: true/" "$CONFIG" + sed -i '' "s/backfill_source: .*/backfill_source: cloudkit/" "$CONFIG" + echo "✓ CloudKit backfill enabled — you'll be asked for your device PIN during login" + ;; + esac + else + # Re-run: show current setting, allow changing CloudKit on/off + if [ "$CURRENT_BACKFILL" = "true" ] && [ "$CURRENT_SOURCE" = "chatdb" ]; then + echo "✓ Backfill source: chat.db (local macOS Messages database)" + elif [ "$CURRENT_BACKFILL" = "true" ]; then + echo "✓ Backfill source: CloudKit (iCloud sync)" + else + echo "✓ Backfill: disabled (real-time messages only)" + fi + fi +fi + +# ── Full Disk Access check for chat.db mode (macOS only) ────── +CURRENT_SOURCE=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) +if [ "$CURRENT_SOURCE" = "chatdb" ] && [ "$(uname -s)" = "Darwin" ]; then + CHATDB_PATH="$HOME/Library/Messages/chat.db" + if [ -f "$CHATDB_PATH" ]; then + if ! sqlite3 "$CHATDB_PATH" "SELECT 1 FROM message LIMIT 1" >/dev/null 2>&1; then + echo "" + echo "⚠ Full Disk Access is required for chat.db backfill." + echo " Opening System Settings → Privacy & Security → Full Disk Access..." + echo " Grant access to the bridge binary, then press Enter to continue." + open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles" 2>/dev/null + read -p "Press Enter when Full Disk Access has been granted..." + if sqlite3 "$CHATDB_PATH" "SELECT 1 FROM message LIMIT 1" >/dev/null 2>&1; then + echo "✓ Full Disk Access confirmed" + else + echo "⚠ chat.db still not accessible — the bridge will prompt again on startup" + fi + else + echo "✓ Full Disk Access: granted" + fi + else + echo "⚠ chat.db not found at $CHATDB_PATH — is Messages set up on this Mac?" + fi +fi + +# ── Max initial messages (new database + CloudKit backfill + interactive) ── +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ "$CURRENT_BACKFILL" = "true" ] && [ -t 0 ]; then + DB_PATH=$(grep 'uri:' "$CONFIG" | head -1 | sed 's/.*uri: file://' | sed 's/?.*//') + if [ -z "$DB_PATH" ] || [ ! -f "$DB_PATH" ]; then + echo "" + echo "By default, all messages per chat will be backfilled." + echo "If you choose to limit, the minimum is 100 messages per chat." + read -p "Would you like to limit the number of messages? [y/N]: " LIMIT_MSGS + case "$LIMIT_MSGS" in + [yY]*) + while true; do + read -p "Max messages per chat (minimum 100): " MAX_MSGS + MAX_MSGS=$(echo "$MAX_MSGS" | tr -dc '0-9') + if [ -n "$MAX_MSGS" ] && [ "$MAX_MSGS" -ge 100 ] 2>/dev/null; then + break + fi + echo "Minimum is 100. Please enter a value of 100 or more." + done + sed -i '' "s/max_initial_messages: [0-9]*/max_initial_messages: $MAX_MSGS/" "$CONFIG" + # Disable backward backfill so the cap is the final word on message count + sed -i '' 's/max_batches: -1$/max_batches: 0/' "$CONFIG" + echo "✓ Max initial messages set to $MAX_MSGS per chat" + ;; + *) + echo "✓ Backfilling all messages" + ;; + esac + fi +fi + +# Tune backfill settings when CloudKit backfill is enabled +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ "$CURRENT_BACKFILL" = "true" ]; then + PATCHED_BACKFILL=false + # Only enable unlimited backward backfill when max_initial is uncapped. + # When the user caps max_initial_messages, max_batches stays at 0 so the + # bridge won't backfill beyond the cap. + if grep -q 'max_initial_messages: 2147483647' "$CONFIG" 2>/dev/null; then + if grep -q 'max_batches: 0$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + PATCHED_BACKFILL=true + fi + fi + if grep -q 'max_initial_messages: [0-9]\{1,2\}$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'max_catchup_messages: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/max_catchup_messages: [0-9]*/max_catchup_messages: 5000/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'batch_size: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if [ "$PATCHED_BACKFILL" = true ]; then + echo "✓ Updated backfill settings (max_initial=unlimited, batch_size=10000, max_batches=-1)" + fi +fi + +# ── Restore CardDAV config from backup ──────────────────────── +# Skip when using chat.db — local macOS Contacts are used automatically. +CARDDAV_BACKUP="$DATA_DIR/.carddav-config" +CURRENT_SOURCE_CHECK=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) +if [ "$CURRENT_SOURCE_CHECK" != "chatdb" ] && [ -f "$CARDDAV_BACKUP" ]; then + CHECK_EMAIL=$(grep 'email:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*email: *//;s/['\"]//g" | tr -d ' ' || true) + if [ -z "$CHECK_EMAIL" ]; then + source "$CARDDAV_BACKUP" + if [ -n "${SAVED_CARDDAV_EMAIL:-}" ] && [ -n "${SAVED_CARDDAV_ENC:-}" ]; then + python3 -c " +import re +text = open('$CONFIG').read() +if 'carddav:' not in text: + lines = text.split('\\n') + insert_at = len(lines) + in_network = False + for i, line in enumerate(lines): + if line.startswith('network:'): + in_network = True + continue + if in_network and line and not line[0].isspace() and not line.startswith('#'): + insert_at = i + break + carddav = [' carddav:', ' email: \"\"', ' url: \"\"', ' username: \"\"', ' password_encrypted: \"\"'] + lines = lines[:insert_at] + carddav + lines[insert_at:] + text = '\\n'.join(lines) +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"$SAVED_CARDDAV_EMAIL\"') +text = patch(text, 'url', '\"$SAVED_CARDDAV_URL\"') +text = patch(text, 'username', '\"$SAVED_CARDDAV_USERNAME\"') +text = patch(text, 'password_encrypted', '\"$SAVED_CARDDAV_ENC\"') +open('$CONFIG', 'w').write(text) +" + echo "✓ Restored CardDAV config: $SAVED_CARDDAV_EMAIL" + fi + fi +fi + +# ── Contact source (runs every time, can reconfigure) ───────── +# Skip when using chat.db — local macOS Contacts are used automatically. +CURRENT_SOURCE=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) +if [ "$CURRENT_SOURCE" = "chatdb" ]; then + echo "✓ Contact source: local macOS Contacts (via chat.db)" +elif [ -t 0 ]; then + CURRENT_CARDDAV_EMAIL=$(grep 'email:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*email: *//;s/['\"]//g" | tr -d ' ' || true) + CONFIGURE_CARDDAV=false + + if [ -n "$CURRENT_CARDDAV_EMAIL" ] && [ "$CURRENT_CARDDAV_EMAIL" != '""' ]; then + echo "" + echo "Contact source: External CardDAV ($CURRENT_CARDDAV_EMAIL)" + read -p "Change contact provider? [y/N]: " CHANGE_CONTACTS + case "$CHANGE_CONTACTS" in + [yY]*) CONFIGURE_CARDDAV=true ;; + esac + else + echo "" + echo "Contact source (for resolving names in chats):" + echo " 1) iCloud (default — uses your Apple ID)" + echo " 2) Google Contacts (requires app password)" + echo " 3) Fastmail" + echo " 4) Nextcloud" + echo " 5) Other CardDAV server" + read -p "Choice [1]: " CONTACT_CHOICE + CONTACT_CHOICE="${CONTACT_CHOICE:-1}" + if [ "$CONTACT_CHOICE" != "1" ]; then + CONFIGURE_CARDDAV=true + fi + fi + + if [ "$CONFIGURE_CARDDAV" = true ]; then + # Show menu if we're changing from an existing provider + if [ -n "$CURRENT_CARDDAV_EMAIL" ] && [ "$CURRENT_CARDDAV_EMAIL" != '""' ]; then + echo "" + echo " 1) iCloud (remove external CardDAV)" + echo " 2) Google Contacts (requires app password)" + echo " 3) Fastmail" + echo " 4) Nextcloud" + echo " 5) Other CardDAV server" + read -p "Choice: " CONTACT_CHOICE + fi + + CARDDAV_EMAIL="" + CARDDAV_PASSWORD="" + CARDDAV_USERNAME="" + CARDDAV_URL="" + + if [ "${CONTACT_CHOICE:-}" = "1" ]; then + # Remove external CardDAV — clear the config fields + python3 -c " +import re +text = open('$CONFIG').read() +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"\"') +text = patch(text, 'url', '\"\"') +text = patch(text, 'username', '\"\"') +text = patch(text, 'password_encrypted', '\"\"') +open('$CONFIG', 'w').write(text) +" + rm -f "$CARDDAV_BACKUP" + echo "✓ Switched to iCloud contacts" + elif [ -n "${CONTACT_CHOICE:-}" ]; then + read -p "Email address: " CARDDAV_EMAIL + if [ -z "$CARDDAV_EMAIL" ]; then + echo "ERROR: Email is required." >&2 + exit 1 + fi + + case "$CONTACT_CHOICE" in + 2) + CARDDAV_URL="https://www.googleapis.com/carddav/v1/principals/$CARDDAV_EMAIL/lists/default/" + echo " Note: Use a Google App Password, without spaces (https://myaccount.google.com/apppasswords)" + ;; + 3) + CARDDAV_URL="https://carddav.fastmail.com/dav/addressbooks/user/$CARDDAV_EMAIL/Default/" + echo " Note: Use a Fastmail App Password (Settings → Privacy & Security → App Passwords)" + ;; + 4) + read -p "Nextcloud server URL (e.g. https://cloud.example.com): " NC_SERVER + NC_SERVER="${NC_SERVER%/}" + CARDDAV_URL="$NC_SERVER/remote.php/dav" + ;; + 5) + read -p "CardDAV server URL: " CARDDAV_URL + if [ -z "$CARDDAV_URL" ]; then + echo "ERROR: URL is required." >&2 + exit 1 + fi + ;; + esac + + read -p "Username (leave empty to use email): " CARDDAV_USERNAME + read -s -p "App password: " CARDDAV_PASSWORD + echo "" + if [ -z "$CARDDAV_PASSWORD" ]; then + echo "ERROR: Password is required." >&2 + exit 1 + fi + + # Encrypt password and patch config + CARDDAV_ARGS="--email $CARDDAV_EMAIL --password $CARDDAV_PASSWORD --url $CARDDAV_URL" + if [ -n "$CARDDAV_USERNAME" ]; then + CARDDAV_ARGS="$CARDDAV_ARGS --username $CARDDAV_USERNAME" + fi + CARDDAV_JSON=$("$BINARY" carddav-setup $CARDDAV_ARGS 2>/dev/null) || CARDDAV_JSON="" + + if [ -z "$CARDDAV_JSON" ]; then + echo "⚠ CardDAV setup failed. You can configure it manually in $CONFIG" + else + CARDDAV_RESOLVED_URL=$(echo "$CARDDAV_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['url'])") + CARDDAV_ENC=$(echo "$CARDDAV_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['password_encrypted'])") + EFFECTIVE_USERNAME="${CARDDAV_USERNAME:-$CARDDAV_EMAIL}" + python3 -c " +import re +text = open('$CONFIG').read() +if 'carddav:' not in text: + lines = text.split('\\n') + insert_at = len(lines) + in_network = False + for i, line in enumerate(lines): + if line.startswith('network:'): + in_network = True + continue + if in_network and line and not line[0].isspace() and not line.startswith('#'): + insert_at = i + break + carddav = [' carddav:', ' email: \"\"', ' url: \"\"', ' username: \"\"', ' password_encrypted: \"\"'] + lines = lines[:insert_at] + carddav + lines[insert_at:] + text = '\\n'.join(lines) +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"$CARDDAV_EMAIL\"') +text = patch(text, 'url', '\"$CARDDAV_RESOLVED_URL\"') +text = patch(text, 'username', '\"$EFFECTIVE_USERNAME\"') +text = patch(text, 'password_encrypted', '\"$CARDDAV_ENC\"') +open('$CONFIG', 'w').write(text) +" + echo "✓ CardDAV configured: $CARDDAV_EMAIL → $CARDDAV_RESOLVED_URL" + cat > "$CARDDAV_BACKUP" << BKEOF +SAVED_CARDDAV_EMAIL="$CARDDAV_EMAIL" +SAVED_CARDDAV_URL="$CARDDAV_RESOLVED_URL" +SAVED_CARDDAV_USERNAME="$EFFECTIVE_USERNAME" +SAVED_CARDDAV_ENC="$CARDDAV_ENC" +BKEOF + fi + fi + fi +fi + +# ── Check for existing login / prompt if needed ────────────── +DB_URI=$(grep 'uri:' "$CONFIG" | head -1 | sed 's/.*uri: file://' | sed 's/?.*//') +NEEDS_LOGIN=false + +SESSION_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/mautrix-imessage" +SESSION_FILE="$SESSION_DIR/session.json" + +# ── Brief init start (fresh install only) ──────────────────── +# On a fresh install with no prior session, start the bridge briefly so it +# creates the DB schema and appears in Beeper as "stopped" during setup. +# We kill it immediately — all config questions (video, HEIC, handle) and +# the iCloud sync gate are answered next, THEN Apple login (APNs) happens +# at the very end so no messages are buffered before the bridge is ready. +if [ "$IS_FRESH_DB" = "true" ]; then + echo "" + echo "Initializing bridge database..." + if ! (cd "$DATA_DIR" && "$BINARY" init-db -c "$CONFIG"); then + echo "✗ Bridge database initialization failed — check the output above for details" + exit 1 + fi + echo "✓ Bridge database initialized — answering setup questions" +fi + +# ── Ensure bridge is stopped during setup ───────────────────── +# bbctl config posts StateStarting which makes Beeper show "Running". +# Stopping the LaunchAgent disconnects the websocket, which makes +# Beeper detect it as unreachable and overrides the stale state. +launchctl bootout "gui/$(id -u)/$BUNDLE_ID" 2>/dev/null || true + +if [ -z "$DB_URI" ] || [ ! -f "$DB_URI" ]; then + # DB missing — check if session.json can auto-restore (has hardware_key for Linux, or macOS) + if [ -f "$SESSION_FILE" ] && { grep -q '"hardware_key"' "$SESSION_FILE" 2>/dev/null || [ "$(uname -s)" = "Darwin" ]; }; then + echo "✓ No database yet, but session state found — bridge will auto-restore login" + NEEDS_LOGIN=false + else + NEEDS_LOGIN=true + fi +elif command -v sqlite3 >/dev/null 2>&1; then + LOGIN_COUNT=$(sqlite3 "$DB_URI" "SELECT count(*) FROM user_login;" 2>/dev/null || echo "0") + if [ "$LOGIN_COUNT" = "0" ]; then + if [ -f "$SESSION_FILE" ] && { grep -q '"hardware_key"' "$SESSION_FILE" 2>/dev/null || [ "$(uname -s)" = "Darwin" ]; }; then + echo "✓ No login in database, but session state found — bridge will auto-restore" + NEEDS_LOGIN=false + else + NEEDS_LOGIN=true + fi + fi +else + NEEDS_LOGIN=true +fi + +# Require re-login if keychain trust-circle state is missing. +# This catches upgrades from pre-keychain versions where the device-passcode +# step was never run. If trustedpeers.plist exists with a user_identity, the +# keychain was joined successfully and any transient PCS errors are harmless. +TRUSTEDPEERS_FILE="$SESSION_DIR/trustedpeers.plist" +FORCE_CLEAR_STATE=false +# Trust-circle only applies to CloudKit backfill — chatdb never creates +# trustedpeers.plist. Match Go's UseCloudKitBackfill(): cloudkit_backfill +# must be true AND backfill_source must not be "chatdb". +CK_ENABLED=$(awk '/cloudkit_backfill:/{print $2; exit}' "$CONFIG" 2>/dev/null) +BF_SOURCE=$(awk '/backfill_source:/{print $2; exit}' "$CONFIG" 2>/dev/null) +if [ "$NEEDS_LOGIN" = "false" ] && [ "$CK_ENABLED" = "true" ] && [ "$BF_SOURCE" != "chatdb" ]; then + HAS_CLIQUE=false + if [ -f "$TRUSTEDPEERS_FILE" ]; then + if grep -q "<key>userIdentity</key>\|<key>user_identity</key>" "$TRUSTEDPEERS_FILE" 2>/dev/null; then + HAS_CLIQUE=true + fi + fi + + if [ "$HAS_CLIQUE" != "true" ]; then + echo "⚠ Existing login found, but keychain trust-circle is not initialized." + echo " Forcing fresh login so device-passcode step can run." + NEEDS_LOGIN=true + FORCE_CLEAR_STATE=true + fi +fi + +# ── Ensure video_transcoding key exists in config ────────────── +if ! grep -q 'video_transcoding:' "$CONFIG" 2>/dev/null; then + sed -i '' '/cloudkit_backfill:/i\ + video_transcoding: false' "$CONFIG" +fi + +# ── Video transcoding (ffmpeg) ───────────────────────────────── +CURRENT_VIDEO_TRANSCODING=$(grep 'video_transcoding:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*video_transcoding: *//' || true) +if [ -t 0 ]; then + echo "" + echo "Video Transcoding:" + echo " When enabled, non-MP4 videos (e.g. QuickTime .mov) are automatically" + echo " converted to MP4 for broad Matrix client compatibility." + echo " Requires ffmpeg." + echo "" + if [ "$CURRENT_VIDEO_TRANSCODING" = "true" ]; then + read -p "Enable video transcoding/remuxing? [Y/n]: " ENABLE_VT + case "$ENABLE_VT" in + [nN]*) + sed -i '' "s/video_transcoding: .*/video_transcoding: false/" "$CONFIG" + echo "✓ Video transcoding disabled" + ;; + *) + if ! command -v ffmpeg >/dev/null 2>&1; then + echo " ffmpeg not found — installing via Homebrew..." + brew install ffmpeg + fi + echo "✓ Video transcoding enabled" + ;; + esac + else + read -p "Enable video transcoding/remuxing? [y/N]: " ENABLE_VT + case "$ENABLE_VT" in + [yY]*) + sed -i '' "s/video_transcoding: .*/video_transcoding: true/" "$CONFIG" + if ! command -v ffmpeg >/dev/null 2>&1; then + echo " ffmpeg not found — installing via Homebrew..." + brew install ffmpeg + fi + echo "✓ Video transcoding enabled" + ;; + *) + echo "✓ Video transcoding disabled" + ;; + esac + fi +fi + +# ── Ensure heic_conversion key exists in config ────────────── +if ! grep -q 'heic_conversion:' "$CONFIG" 2>/dev/null; then + sed -i '' '/video_transcoding:/a\ + heic_conversion: false' "$CONFIG" +fi + +# ── HEIC conversion (libheif) ───────────────────────────────── +CURRENT_HEIC_CONVERSION=$(grep 'heic_conversion:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_conversion: *//' || true) +if [ -t 0 ]; then + echo "" + echo "HEIC Conversion:" + echo " When enabled, HEIC/HEIF images are automatically converted to JPEG" + echo " for broad Matrix client compatibility." + echo " Requires libheif." + echo "" + if [ "$CURRENT_HEIC_CONVERSION" = "true" ]; then + read -p "Enable HEIC to JPEG conversion? [Y/n]: " ENABLE_HC + case "$ENABLE_HC" in + [nN]*) + sed -i '' "s/heic_conversion: .*/heic_conversion: false/" "$CONFIG" + echo "✓ HEIC conversion disabled" + ;; + *) + if command -v brew >/dev/null 2>&1; then + brew list libheif >/dev/null 2>&1 || brew install libheif + fi + echo "✓ HEIC conversion enabled" + ;; + esac + else + read -p "Enable HEIC to JPEG conversion? [y/N]: " ENABLE_HC + case "$ENABLE_HC" in + [yY]*) + sed -i '' "s/heic_conversion: .*/heic_conversion: true/" "$CONFIG" + if command -v brew >/dev/null 2>&1; then + brew list libheif >/dev/null 2>&1 || brew install libheif + fi + echo "✓ HEIC conversion enabled" + ;; + *) + echo "✓ HEIC conversion disabled" + ;; + esac + fi +fi + +# ── HEIC JPEG quality (only if HEIC conversion is enabled) ─── +HEIC_ENABLED=$(grep 'heic_conversion:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_conversion: *//' || true) +if [ "$HEIC_ENABLED" = "true" ]; then + if ! grep -q 'heic_jpeg_quality:' "$CONFIG" 2>/dev/null; then + sed -i '' "$(printf '/heic_conversion:/a\\\n heic_jpeg_quality: 95')" "$CONFIG" + fi +else + sed -i '' '/heic_jpeg_quality:/d' "$CONFIG" +fi +if [ "$HEIC_ENABLED" = "true" ] && [ -t 0 ]; then + CURRENT_QUALITY=$(grep 'heic_jpeg_quality:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_jpeg_quality: *//' || echo "95") + [ -z "$CURRENT_QUALITY" ] && CURRENT_QUALITY=95 + echo "" + read -p "JPEG quality for HEIC conversion (1–100) [$CURRENT_QUALITY]: " NEW_QUALITY + if [ -n "$NEW_QUALITY" ]; then + if [ "$NEW_QUALITY" -ge 1 ] 2>/dev/null && [ "$NEW_QUALITY" -le 100 ] 2>/dev/null; then + sed -i '' "s/heic_jpeg_quality: .*/heic_jpeg_quality: $NEW_QUALITY/" "$CONFIG" + echo "✓ JPEG quality set to $NEW_QUALITY" + else + echo " ⚠ Invalid quality '$NEW_QUALITY' — keeping $CURRENT_QUALITY" + fi + else + echo "✓ JPEG quality: $CURRENT_QUALITY" + fi +fi + +# ── iCloud sync gate (CloudKit + fresh DB) ─────────────────── +# Runs before Apple login so that iCloud is fully synced before APNs first +# connects. This ensures CloudKit backfill can deduplicate any messages that +# Apple buffers and delivers the moment the bridge registers with APNs. +_ck_backfill=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +_ck_source=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) +if [ "$IS_FRESH_DB" = "true" ] && [ "$_ck_backfill" = "true" ] && [ "$_ck_source" != "chatdb" ] && [ -t 0 ]; then + echo "" + echo "┌─────────────────────────────────────────────────────────────┐" + echo "│ Last step: sync iCloud Messages before starting │" + echo "│ │" + echo "│ On your iPhone, iPad, Mac, or OpenBubbles: │" + echo "│ Settings → [Your Name] → iCloud → Messages → Sync Now │" + echo "│ │" + echo "│ Wait for sync to complete, then press Y to start. │" + echo "└─────────────────────────────────────────────────────────────┘" + echo "" + read -p "Have you synced iCloud Messages and are ready to start? [y/N]: " _sync_ready + case "$_sync_ready" in + [yY]*) echo "✓ Starting bridge" ;; + *) + echo "" + echo "Re-run 'make install-beeper' after syncing iCloud Messages." + exit 0 + ;; + esac +fi + +# ── Apple login (APNs connects here — after all questions) ─── +# check-restore runs first: if session.json + keystore are intact, no login needed. +if [ "$NEEDS_LOGIN" = "true" ] && [ "${FORCE_CLEAR_STATE:-false}" != "true" ] && "$BINARY" check-restore 2>/dev/null; then + echo "✓ Backup session state validated — bridge will auto-restore login" + NEEDS_LOGIN=false +fi + +LOGIN_RAN=false +if [ "$NEEDS_LOGIN" = "true" ]; then + LOGIN_RAN=true + echo "" + echo "┌─────────────────────────────────────────────────┐" + echo "│ No valid iMessage login found — starting login │" + echo "└─────────────────────────────────────────────────┘" + echo "" + # Stop the bridge if running (otherwise it holds the DB lock) + GUI_DOMAIN_TMP="gui/$(id -u)" + launchctl bootout "$GUI_DOMAIN_TMP/$BUNDLE_ID" 2>/dev/null || true + + if [ "${FORCE_CLEAR_STATE:-false}" = "true" ]; then + echo "Clearing stale local state before login..." + rm -f "$DB_URI" "$DB_URI-wal" "$DB_URI-shm" + rm -f "$SESSION_DIR/session.json" "$SESSION_DIR/identity.plist" "$SESSION_DIR/trustedpeers.plist" + fi + + # Run login from the data directory so the keystore (state/keystore.plist) + # is written to the same location the launchd service will read from. + (cd "$DATA_DIR" && "$BINARY" login -n -c "$CONFIG") + echo "" + + # Re-check permissions after login — the config upgrader may have + # corrupted them even with -n if repairPermissions couldn't determine + # the username. + if [ -n "$WHOAMI" ] && [ "$WHOAMI" != "null" ]; then + if fix_permissions "$CONFIG" "$WHOAMI"; then + echo "✓ Fixed permissions after login: @${WHOAMI}:beeper.com → admin" + fi + fi +fi + +# ── Stop bridge before applying config changes ──────────────── +launchctl bootout "gui/$(id -u)/$BUNDLE_ID" 2>/dev/null || true + +# ── Preferred handle (runs every time, can reconfigure) ──────── +HANDLE_BACKUP="$DATA_DIR/.preferred-handle" +CURRENT_HANDLE=$(grep 'preferred_handle:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*preferred_handle: *//;s/['\"]//g" | tr -d ' ' || true) + +# Try to recover from backups if not set in config +if [ -z "$CURRENT_HANDLE" ]; then + if command -v sqlite3 >/dev/null 2>&1 && [ -n "${DB_URI:-}" ] && [ -f "${DB_URI:-}" ]; then + CURRENT_HANDLE=$(sqlite3 "$DB_URI" "SELECT json_extract(metadata, '$.preferred_handle') FROM user_login LIMIT 1;" 2>/dev/null || true) + fi + if [ -z "$CURRENT_HANDLE" ] && [ -f "$SESSION_DIR/session.json" ] && command -v python3 >/dev/null 2>&1; then + CURRENT_HANDLE=$(python3 -c "import json; print(json.load(open('$SESSION_DIR/session.json')).get('preferred_handle',''))" 2>/dev/null || true) + fi + if [ -z "$CURRENT_HANDLE" ] && [ -f "$HANDLE_BACKUP" ]; then + CURRENT_HANDLE=$(cat "$HANDLE_BACKUP") + fi +fi + +# Skip handle prompt if login just ran and already set a handle (the login +# flow on macOS asks for handle during Apple ID auth — no need to ask twice). +# On Linux (external-key flow), login doesn't ask, so CURRENT_HANDLE is empty +# and the prompt still shows. +if [ -t 0 ] && { [ "$LOGIN_RAN" != "true" ] || [ -z "$CURRENT_HANDLE" ]; }; then + # Get available handles from session state (available after login) + AVAILABLE_HANDLES=$("$BINARY" list-handles 2>/dev/null | grep -E '^(tel:|mailto:)' || true) + if [ -n "$AVAILABLE_HANDLES" ]; then + echo "" + echo "Preferred handle (your iMessage sender address):" + i=1 + declare -a HANDLE_LIST=() + while IFS= read -r h; do + MARKER="" + if [ "$h" = "$CURRENT_HANDLE" ]; then + MARKER=" (current)" + fi + echo " $i) $h$MARKER" + HANDLE_LIST+=("$h") + i=$((i + 1)) + done <<< "$AVAILABLE_HANDLES" + + if [ -n "$CURRENT_HANDLE" ]; then + read -p "Choice [keep current]: " HANDLE_CHOICE + else + read -p "Choice [1]: " HANDLE_CHOICE + fi + + if [ -n "$HANDLE_CHOICE" ]; then + if [ "$HANDLE_CHOICE" -ge 1 ] 2>/dev/null && [ "$HANDLE_CHOICE" -le "${#HANDLE_LIST[@]}" ] 2>/dev/null; then + CURRENT_HANDLE="${HANDLE_LIST[$((HANDLE_CHOICE - 1))]}" + fi + elif [ -z "$CURRENT_HANDLE" ] && [ ${#HANDLE_LIST[@]} -gt 0 ]; then + CURRENT_HANDLE="${HANDLE_LIST[0]}" + fi + elif [ -n "$CURRENT_HANDLE" ]; then + echo "" + echo "Preferred handle: $CURRENT_HANDLE" + read -p "New handle, or Enter to keep current: " NEW_HANDLE + if [ -n "$NEW_HANDLE" ]; then + CURRENT_HANDLE="$NEW_HANDLE" + fi + else + # list-handles returned empty (e.g. session not yet populated). + # Fall back to manual entry so the bridge doesn't start without a handle. + echo "" + echo "Could not detect handles automatically." + read -p "Enter your iMessage handle (e.g. tel:+12345678900 or mailto:you@icloud.com): " CURRENT_HANDLE + fi +fi + +# Write preferred handle to config (add key if missing, patch if present) +if [ -n "${CURRENT_HANDLE:-}" ]; then + if grep -q 'preferred_handle:' "$CONFIG" 2>/dev/null; then + sed -i '' "s|preferred_handle: .*|preferred_handle: '$CURRENT_HANDLE'|" "$CONFIG" + else + sed -i '' "/^network:/a\\ +\\ preferred_handle: '$CURRENT_HANDLE' +" "$CONFIG" + fi + echo "✓ Preferred handle: $CURRENT_HANDLE" + echo "$CURRENT_HANDLE" > "$HANDLE_BACKUP" +fi + +# ── Install LaunchAgent ─────────────────────────────────────── +CONFIG_ABS="$(cd "$DATA_DIR" && pwd)/config.yaml" +DATA_ABS="$(cd "$DATA_DIR" && pwd)" +LOG_OUT="$DATA_ABS/bridge.stdout.log" +LOG_ERR="$DATA_ABS/bridge.stderr.log" + +# ── Write auto-update wrapper ───────────────────────────────── +cat > "$DATA_ABS/start.sh" << HEADER_EOF +#!/bin/bash +BBCTL_DIR="$BBCTL_DIR" +BBCTL_BRANCH="$BBCTL_BRANCH" +BINARY="$BINARY" +CONFIG="$CONFIG_ABS" +HEADER_EOF +cat >> "$DATA_ABS/start.sh" << 'BODY_EOF' +BBCTL_REPO="${BBCTL_REPO:-https://github.com/lrhodin/imessage.git}" + +# ANSI helpers +BOLD='\033[1m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[0;33m' +DIM='\033[2m' +RESET='\033[0m' + +ts() { date '+%H:%M:%S'; } +ok() { printf "${DIM}$(ts)${RESET} ${GREEN}✓${RESET} %s\n" "$*"; } +step() { printf "${DIM}$(ts)${RESET} ${CYAN}▶${RESET} %s\n" "$*"; } +warn() { printf "${DIM}$(ts)${RESET} ${YELLOW}⚠${RESET} %s\n" "$*"; } + +printf "\n ${BOLD}iMessage Bridge${RESET}\n\n" + +# Bootstrap sparse clone if it doesn't exist yet +if [ ! -d "$BBCTL_DIR/.git" ] && command -v go >/dev/null 2>&1; then + step "Setting up bbctl sparse checkout..." + EXISTING_BBCTL="" + [ -x "$BBCTL_DIR/bbctl" ] && EXISTING_BBCTL=$(mktemp) && cp "$BBCTL_DIR/bbctl" "$EXISTING_BBCTL" + rm -rf "$BBCTL_DIR" + mkdir -p "$(dirname "$BBCTL_DIR")" + git clone --filter=blob:none --no-checkout --quiet \ + --branch "$BBCTL_BRANCH" "$BBCTL_REPO" "$BBCTL_DIR" + git -C "$BBCTL_DIR" sparse-checkout init --cone + git -C "$BBCTL_DIR" sparse-checkout set cmd/bbctl + git -C "$BBCTL_DIR" checkout --quiet "$BBCTL_BRANCH" + (cd "$BBCTL_DIR" && go build -o bbctl ./cmd/bbctl/ 2>&1) | sed 's/^/ /' + [ -n "$EXISTING_BBCTL" ] && rm -f "$EXISTING_BBCTL" + ok "bbctl ready" +fi + +if [ -d "$BBCTL_DIR/.git" ] && command -v go >/dev/null 2>&1; then + git -C "$BBCTL_DIR" fetch origin --quiet 2>/dev/null || true + LOCAL=$(git -C "$BBCTL_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") + REMOTE=$(git -C "$BBCTL_DIR" rev-parse --short "origin/$BBCTL_BRANCH" 2>/dev/null || echo "unknown") + if [ "$LOCAL" != "$REMOTE" ] && [ "$LOCAL" != "unknown" ] && [ "$REMOTE" != "unknown" ]; then + step "Updating bbctl $LOCAL → $REMOTE" + T0=$(date +%s) + git -C "$BBCTL_DIR" reset --hard "origin/$BBCTL_BRANCH" --quiet + step "Building bbctl..." + (cd "$BBCTL_DIR" && go build -o bbctl ./cmd/bbctl/ 2>&1) | sed 's/^/ /' + T1=$(date +%s) + ok "bbctl updated ($(( T1 - T0 ))s)" + else + ok "bbctl $LOCAL" + fi +elif [ -d "$BBCTL_DIR/.git" ]; then + warn "go not found — skipping bbctl update" +fi + +# Fix permissions before starting — the config upgrader may have replaced +# the user's permissions with example.com defaults on a previous run. +# Detects: empty username (@:, @":), example.com defaults, wildcard relay. +if grep -q '"@:\|"@":\|@.*example\.com\|"\*":.*relay' "$CONFIG" 2>/dev/null; then + BBCTL_BIN="$BBCTL_DIR/bbctl" + if [ -x "$BBCTL_BIN" ]; then + FIX_USER=$("$BBCTL_BIN" whoami 2>/dev/null | head -1 || true) + if [ -n "$FIX_USER" ] && [ "$FIX_USER" != "null" ]; then + FIX_MXID="@${FIX_USER}:beeper.com" + sed -i '' '/permissions:/,/^[^ ]/{ + s/"@[^"]*": admin/"'"$FIX_MXID"'": admin/ + /@.*example\.com/d + /"\*":.*relay/d + /"@":/d + /"@:/d + }' "$CONFIG" + ok "Fixed permissions: $FIX_MXID" + fi + fi +fi + +step "Starting bridge..." +exec "$BINARY" -n -c "$CONFIG" +BODY_EOF +chmod +x "$DATA_ABS/start.sh" + +mkdir -p "$(dirname "$PLIST")" +GUI_DOMAIN="gui/$(id -u)" +launchctl bootout "$GUI_DOMAIN/$BUNDLE_ID" 2>/dev/null || true +launchctl unload "$PLIST" 2>/dev/null || true + +cat > "$PLIST" << PLIST_EOF +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>$BUNDLE_ID</string> + <key>ProgramArguments</key> + <array> + <string>/bin/bash</string> + <string>$DATA_ABS/start.sh</string> + </array> + <key>WorkingDirectory</key> + <string>$DATA_ABS</string> + <key>RunAtLoad</key> + <true/> + <key>KeepAlive</key> + <dict> + <key>Crashed</key> + <true/> + </dict> + <key>StandardOutPath</key> + <string>$LOG_OUT</string> + <key>StandardErrorPath</key> + <string>$LOG_ERR</string> + <key>EnvironmentVariables</key> + <dict> + <key>PATH</key> + <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/go/bin:$HOME/go/bin</string> + <key>CGO_CFLAGS</key> + <string>-I/opt/homebrew/include</string> + <key>CGO_LDFLAGS</key> + <string>-L/opt/homebrew/lib</string> + </dict> +</dict> +</plist> +PLIST_EOF + +if ! launchctl bootstrap "$GUI_DOMAIN" "$PLIST" 2>/dev/null; then + if ! launchctl load "$PLIST" 2>/dev/null; then + echo "" + echo "⚠ LaunchAgent failed to load. You can run the bridge manually:" + echo " $BINARY -c $CONFIG_ABS" + echo "" + echo " This is a known issue on macOS 13 (Ventura). Try:" + echo " 1. Remove and re-add the .app in Full Disk Access" + echo " 2. Re-run: make install-beeper" + echo "" + fi +fi +echo "✓ Bridge started (LaunchAgent installed)" +echo "" + +# ── Wait for bridge to connect ──────────────────────────────── +DOMAIN=$(grep '^\s*domain:' "$CONFIG" | head -1 | awk '{print $2}' || true) +DOMAIN="${DOMAIN:-beeper.local}" + +echo "Waiting for bridge to start..." +for i in $(seq 1 15); do + if grep -q "Bridge started\|UNCONFIGURED\|Backfill queue starting" "$LOG_OUT" 2>/dev/null; then + echo "✓ Bridge is running" + echo "" + echo "═══════════════════════════════════════════════" + echo " Setup Complete" + echo "═══════════════════════════════════════════════" + echo "" + echo " Logs: tail -f $LOG_OUT" + echo " Stop: launchctl bootout $GUI_DOMAIN/$BUNDLE_ID" + echo " Start: launchctl bootstrap $GUI_DOMAIN $PLIST" + echo " Restart: launchctl kickstart -k $GUI_DOMAIN/$BUNDLE_ID" + exit 0 + fi + sleep 1 +done + +echo "" +echo "Bridge is starting up (check logs for status):" +echo " tail -f $LOG_OUT" +echo "" +echo "Once running, DM @${BRIDGE_NAME}bot:$DOMAIN and send: login" diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh new file mode 100755 index 00000000..ef5d10f8 --- /dev/null +++ b/scripts/install-linux.sh @@ -0,0 +1,809 @@ +#!/bin/bash +set -euo pipefail + +BINARY="$1" +DATA_DIR="$2" + +BINARY="$(cd "$(dirname "$BINARY")" && pwd)/$(basename "$BINARY")" +CONFIG="$DATA_DIR/config.yaml" +REGISTRATION="$DATA_DIR/registration.yaml" + +echo "" +echo "═══════════════════════════════════════════════" +echo " iMessage Bridge Setup (Standalone · Linux)" +echo "═══════════════════════════════════════════════" +echo "" + +# ── Generate config ─────────────────────────────────────────── +FIRST_RUN=false +mkdir -p "$DATA_DIR" +if [ -f "$CONFIG" ]; then + echo "✓ Config already exists at $CONFIG" +else + FIRST_RUN=true + + read -p "Homeserver URL [http://localhost:8008]: " HS_ADDRESS + HS_ADDRESS="${HS_ADDRESS:-http://localhost:8008}" + + read -p "Homeserver domain (the server_name, e.g. example.com): " HS_DOMAIN + if [ -z "$HS_DOMAIN" ]; then + echo "ERROR: Domain is required." >&2 + exit 1 + fi + + read -p "Your Matrix ID [@you:$HS_DOMAIN]: " ADMIN_USER + ADMIN_USER="${ADMIN_USER:-@you:$HS_DOMAIN}" + + echo "" + echo "Database:" + echo " 1) PostgreSQL (recommended)" + echo " 2) SQLite" + read -p "Choice [1]: " DB_CHOICE + DB_CHOICE="${DB_CHOICE:-1}" + + if [ "$DB_CHOICE" = "1" ]; then + DB_TYPE="postgres" + read -p "PostgreSQL URI [postgres://localhost/mautrix_imessage?sslmode=disable]: " DB_URI + DB_URI="${DB_URI:-postgres://localhost/mautrix_imessage?sslmode=disable}" + else + DB_TYPE="sqlite3-fk-wal" + DB_URI="file:$DATA_DIR/mautrix-imessage.db?_txlock=immediate" + fi + + echo "" + + # Generate example config, then patch in user values + "$BINARY" -c "$CONFIG" -e 2>/dev/null + echo "✓ Generated config" + + python3 -c " +import re, sys +text = open('$CONFIG').read() + +def patch(text, key, val): + return re.sub( + r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', + r'\1 ' + val, + text, count=1, flags=re.MULTILINE + ) + +text = patch(text, 'address', '$HS_ADDRESS') +text = patch(text, 'domain', '$HS_DOMAIN') +text = patch(text, 'type', '$DB_TYPE') +text = patch(text, 'uri', '$DB_URI') + +lines = text.split('\n') +in_perms = False +for i, line in enumerate(lines): + if 'permissions:' in line and not line.strip().startswith('#'): + in_perms = True + continue + if in_perms and line.strip() and not line.strip().startswith('#'): + indent = len(line) - len(line.lstrip()) + lines[i] = ' ' * indent + '\"$ADMIN_USER\": admin' + break +text = '\n'.join(lines) + +open('$CONFIG', 'w').write(text) +" + # iMessage CloudKit chats can have tens of thousands of messages. + # Deliver all history in one forward batch to avoid DAG fragmentation. + sed -i 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + sed -i 's/max_catchup_messages: [0-9]*/max_catchup_messages: 5000/' "$CONFIG" + sed -i 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + sed -i 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + # Use 1s between batches — fast enough for backfill, prevents idle hot-loop + sed -i 's/batch_delay: [0-9]*/batch_delay: 1/' "$CONFIG" + echo "✓ Configured: $HS_ADDRESS, $HS_DOMAIN, $ADMIN_USER, $DB_TYPE" +fi + +# ── Ensure backfill_source key exists in config ─────────────── +if ! grep -q 'backfill_source:' "$CONFIG" 2>/dev/null; then + sed -i '/cloudkit_backfill:/a\ backfill_source: cloudkit' "$CONFIG" +fi + +# ── CloudKit backfill toggle (runs every time) ──────────────── +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ -t 0 ]; then + echo "" + echo "CloudKit Backfill:" + echo " When enabled, the bridge will sync your iMessage history from iCloud." + echo " This requires entering your device PIN during login to join the iCloud Keychain." + echo " When disabled, only new real-time messages are bridged (no PIN needed)." + echo "" + if [ "$CURRENT_BACKFILL" = "true" ]; then + read -p "Enable CloudKit message history backfill? [Y/n]: " ENABLE_BACKFILL + case "$ENABLE_BACKFILL" in + [nN]*) ENABLE_BACKFILL=false ;; + *) ENABLE_BACKFILL=true ;; + esac + else + read -p "Enable CloudKit message history backfill? [y/N]: " ENABLE_BACKFILL + case "$ENABLE_BACKFILL" in + [yY]*) ENABLE_BACKFILL=true ;; + *) ENABLE_BACKFILL=false ;; + esac + fi + sed -i "s/cloudkit_backfill: .*/cloudkit_backfill: $ENABLE_BACKFILL/" "$CONFIG" + if [ "$ENABLE_BACKFILL" = "true" ]; then + echo "✓ CloudKit backfill enabled — you'll be asked for your device PIN during login" + echo "" + echo "IMPORTANT: Before starting the bridge, sync your latest messages to iCloud" + echo "from an Apple device (iPhone, iPad, or Mac) to ensure all recent messages" + echo "are available for backfill." + echo "" + read -p "Have you synced your Apple device to iCloud? [y/N]: " ICLOUD_SYNCED + case "$ICLOUD_SYNCED" in + [yY]*) echo "✓ Great — backfill will include your latest messages" ;; + *) echo "⚠ Please sync your Apple device to iCloud before starting the bridge" ;; + esac + else + echo "✓ CloudKit backfill disabled — real-time messages only, no PIN needed" + fi +fi + +# ── Max initial messages (new database + CloudKit backfill + interactive) ── +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ "$CURRENT_BACKFILL" = "true" ] && [ -t 0 ]; then + DB_PATH=$(grep 'uri:' "$CONFIG" | head -1 | sed 's/.*uri: file://' | sed 's/?.*//') + if [ -z "$DB_PATH" ] || [ ! -f "$DB_PATH" ]; then + echo "" + echo "By default, all messages per chat will be backfilled." + echo "If you choose to limit, the minimum is 100 messages per chat." + read -p "Would you like to limit the number of messages? [y/N]: " LIMIT_MSGS + case "$LIMIT_MSGS" in + [yY]*) + while true; do + read -p "Max messages per chat (minimum 100): " MAX_MSGS + MAX_MSGS=$(echo "$MAX_MSGS" | tr -dc '0-9') + if [ -n "$MAX_MSGS" ] && [ "$MAX_MSGS" -ge 100 ] 2>/dev/null; then + break + fi + echo "Minimum is 100. Please enter a value of 100 or more." + done + sed -i "s/max_initial_messages: [0-9]*/max_initial_messages: $MAX_MSGS/" "$CONFIG" + # Disable backward backfill so the cap is the final word on message count + sed -i 's/max_batches: -1$/max_batches: 0/' "$CONFIG" + echo "✓ Max initial messages set to $MAX_MSGS per chat" + ;; + *) + echo "✓ Backfilling all messages" + ;; + esac + fi +fi + +# Tune backfill settings when CloudKit backfill is enabled +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ "$CURRENT_BACKFILL" = "true" ]; then + PATCHED_BACKFILL=false + # Only enable unlimited backward backfill when max_initial is uncapped. + # When the user caps max_initial_messages, max_batches stays at 0 so the + # bridge won't backfill beyond the cap. + if grep -q 'max_initial_messages: 2147483647' "$CONFIG" 2>/dev/null; then + if grep -q 'max_batches: 0$' "$CONFIG" 2>/dev/null; then + sed -i 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + PATCHED_BACKFILL=true + fi + fi + if grep -q 'max_initial_messages: [0-9]\{1,2\}$' "$CONFIG" 2>/dev/null; then + sed -i 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'max_catchup_messages: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i 's/max_catchup_messages: [0-9]*/max_catchup_messages: 5000/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'batch_size: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'batch_delay: 0$' "$CONFIG" 2>/dev/null; then + sed -i 's/batch_delay: 0$/batch_delay: 1/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if [ "$PATCHED_BACKFILL" = true ]; then + echo "✓ Updated backfill settings (max_initial=unlimited, batch_size=10000, max_batches=-1)" + fi +fi + +# ── Generate registration ──────────────────────────────────── +if [ -f "$REGISTRATION" ]; then + echo "✓ Registration already exists" +else + "$BINARY" -c "$CONFIG" -g -r "$REGISTRATION" 2>/dev/null + echo "✓ Generated registration" +fi + +# ── Register with homeserver (first run only) ───────────────── +if [ "$FIRST_RUN" = true ]; then + REG_PATH="$(cd "$DATA_DIR" && pwd)/registration.yaml" + echo "" + echo "┌─────────────────────────────────────────────────┐" + echo "│ Register with your homeserver: │" + echo "│ │" + echo "│ Add to homeserver.yaml: │" + echo "│ app_service_config_files: │" + echo "│ - $REG_PATH" + echo "│ │" + echo "│ Then restart your homeserver. │" + echo "└─────────────────────────────────────────────────┘" + echo "" + read -p "Press Enter once your homeserver is restarted..." +fi + +# ── Restore CardDAV config from backup ──────────────────────── +CARDDAV_BACKUP="$DATA_DIR/.carddav-config" +if [ -f "$CARDDAV_BACKUP" ]; then + CHECK_EMAIL=$(grep 'email:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*email: *//;s/['\"]//g" | tr -d ' ' || true) + if [ -z "$CHECK_EMAIL" ]; then + source "$CARDDAV_BACKUP" + if [ -n "${SAVED_CARDDAV_EMAIL:-}" ] && [ -n "${SAVED_CARDDAV_ENC:-}" ]; then + python3 -c " +import re +text = open('$CONFIG').read() +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"$SAVED_CARDDAV_EMAIL\"') +text = patch(text, 'url', '\"$SAVED_CARDDAV_URL\"') +text = patch(text, 'username', '\"$SAVED_CARDDAV_USERNAME\"') +text = patch(text, 'password_encrypted', '\"$SAVED_CARDDAV_ENC\"') +open('$CONFIG', 'w').write(text) +" + echo "✓ Restored CardDAV config: $SAVED_CARDDAV_EMAIL" + fi + fi +fi + +# ── Contact source (runs every time, can reconfigure) ───────── +if [ -t 0 ]; then + CURRENT_CARDDAV_EMAIL=$(grep 'email:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*email: *//;s/['\"]//g" | tr -d ' ' || true) + CONFIGURE_CARDDAV=false + + if [ -n "$CURRENT_CARDDAV_EMAIL" ] && [ "$CURRENT_CARDDAV_EMAIL" != '""' ]; then + echo "" + echo "Contact source: External CardDAV ($CURRENT_CARDDAV_EMAIL)" + read -p "Change contact provider? [y/N]: " CHANGE_CONTACTS + case "$CHANGE_CONTACTS" in + [yY]*) CONFIGURE_CARDDAV=true ;; + esac + else + echo "" + echo "Contact source (for resolving names in chats):" + echo " 1) iCloud (default — uses your Apple ID)" + echo " 2) Google Contacts (requires app password)" + echo " 3) Fastmail" + echo " 4) Nextcloud" + echo " 5) Other CardDAV server" + read -p "Choice [1]: " CONTACT_CHOICE + CONTACT_CHOICE="${CONTACT_CHOICE:-1}" + if [ "$CONTACT_CHOICE" != "1" ]; then + CONFIGURE_CARDDAV=true + fi + fi + + if [ "$CONFIGURE_CARDDAV" = true ]; then + # Show menu if we're changing from an existing provider + if [ -n "$CURRENT_CARDDAV_EMAIL" ] && [ "$CURRENT_CARDDAV_EMAIL" != '""' ]; then + echo "" + echo " 1) iCloud (remove external CardDAV)" + echo " 2) Google Contacts (requires app password)" + echo " 3) Fastmail" + echo " 4) Nextcloud" + echo " 5) Other CardDAV server" + read -p "Choice: " CONTACT_CHOICE + fi + + CARDDAV_EMAIL="" + CARDDAV_PASSWORD="" + CARDDAV_USERNAME="" + CARDDAV_URL="" + + if [ "${CONTACT_CHOICE:-}" = "1" ]; then + # Remove external CardDAV — clear the config fields + python3 -c " +import re +text = open('$CONFIG').read() +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"\"') +text = patch(text, 'url', '\"\"') +text = patch(text, 'username', '\"\"') +text = patch(text, 'password_encrypted', '\"\"') +open('$CONFIG', 'w').write(text) +" + rm -f "$CARDDAV_BACKUP" + echo "✓ Switched to iCloud contacts" + elif [ -n "${CONTACT_CHOICE:-}" ]; then + read -p "Email address: " CARDDAV_EMAIL + if [ -z "$CARDDAV_EMAIL" ]; then + echo "ERROR: Email is required." >&2 + exit 1 + fi + + case "$CONTACT_CHOICE" in + 2) + CARDDAV_URL="https://www.googleapis.com/carddav/v1/principals/$CARDDAV_EMAIL/lists/default/" + echo " Note: Use a Google App Password, without spaces (https://myaccount.google.com/apppasswords)" + ;; + 3) + CARDDAV_URL="https://carddav.fastmail.com/dav/addressbooks/user/$CARDDAV_EMAIL/Default/" + echo " Note: Use a Fastmail App Password (Settings → Privacy & Security → App Passwords)" + ;; + 4) + read -p "Nextcloud server URL (e.g. https://cloud.example.com): " NC_SERVER + NC_SERVER="${NC_SERVER%/}" + CARDDAV_URL="$NC_SERVER/remote.php/dav" + ;; + 5) + read -p "CardDAV server URL: " CARDDAV_URL + if [ -z "$CARDDAV_URL" ]; then + echo "ERROR: URL is required." >&2 + exit 1 + fi + ;; + esac + + read -p "Username (leave empty to use email): " CARDDAV_USERNAME + read -s -p "App password: " CARDDAV_PASSWORD + echo "" + if [ -z "$CARDDAV_PASSWORD" ]; then + echo "ERROR: Password is required." >&2 + exit 1 + fi + + # Encrypt password and patch config + CARDDAV_ARGS="--email $CARDDAV_EMAIL --password $CARDDAV_PASSWORD --url $CARDDAV_URL" + if [ -n "$CARDDAV_USERNAME" ]; then + CARDDAV_ARGS="$CARDDAV_ARGS --username $CARDDAV_USERNAME" + fi + CARDDAV_JSON=$("$BINARY" carddav-setup $CARDDAV_ARGS 2>/dev/null) || CARDDAV_JSON="" + + if [ -z "$CARDDAV_JSON" ]; then + echo "⚠ CardDAV setup failed. You can configure it manually in $CONFIG" + else + CARDDAV_RESOLVED_URL=$(echo "$CARDDAV_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['url'])") + CARDDAV_ENC=$(echo "$CARDDAV_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['password_encrypted'])") + EFFECTIVE_USERNAME="${CARDDAV_USERNAME:-$CARDDAV_EMAIL}" + python3 -c " +import re +text = open('$CONFIG').read() +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"$CARDDAV_EMAIL\"') +text = patch(text, 'url', '\"$CARDDAV_RESOLVED_URL\"') +text = patch(text, 'username', '\"$EFFECTIVE_USERNAME\"') +text = patch(text, 'password_encrypted', '\"$CARDDAV_ENC\"') +open('$CONFIG', 'w').write(text) +" + echo "✓ CardDAV configured: $CARDDAV_EMAIL → $CARDDAV_RESOLVED_URL" + cat > "$CARDDAV_BACKUP" << BKEOF +SAVED_CARDDAV_EMAIL="$CARDDAV_EMAIL" +SAVED_CARDDAV_URL="$CARDDAV_RESOLVED_URL" +SAVED_CARDDAV_USERNAME="$EFFECTIVE_USERNAME" +SAVED_CARDDAV_ENC="$CARDDAV_ENC" +BKEOF + fi + fi + fi +fi + +# ── Check for existing login / prompt if needed ────────────── +DB_URI=$(grep 'uri:' "$CONFIG" | head -1 | sed 's/.*uri: file://' | sed 's/?.*//') +NEEDS_LOGIN=false + +if [ -z "$DB_URI" ] || [ ! -f "$DB_URI" ]; then + NEEDS_LOGIN=true +elif command -v sqlite3 >/dev/null 2>&1; then + LOGIN_COUNT=$(sqlite3 "$DB_URI" "SELECT count(*) FROM user_login;" 2>/dev/null || echo "0") + if [ "$LOGIN_COUNT" = "0" ]; then + NEEDS_LOGIN=true + fi +else + # sqlite3 not available — can't verify DB has logins, assume login needed + NEEDS_LOGIN=true +fi + +# Require re-login if keychain trust-circle state is missing. +# This catches upgrades from pre-keychain versions where the device-passcode +# step was never run. If trustedpeers.plist exists with a user_identity, the +# keychain was joined successfully and any transient PCS errors are harmless. +SESSION_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/mautrix-imessage" +TRUSTEDPEERS_FILE="$SESSION_DIR/trustedpeers.plist" +FORCE_CLEAR_STATE=false +if [ "$NEEDS_LOGIN" = "false" ]; then + HAS_CLIQUE=false + if [ -f "$TRUSTEDPEERS_FILE" ]; then + if grep -q "<key>userIdentity</key>\|<key>user_identity</key>" "$TRUSTEDPEERS_FILE" 2>/dev/null; then + HAS_CLIQUE=true + fi + fi + + if [ "$HAS_CLIQUE" != "true" ]; then + echo "⚠ Existing login found, but keychain trust-circle is not initialized." + echo " Forcing fresh login so device-passcode step can run." + NEEDS_LOGIN=true + FORCE_CLEAR_STATE=true + fi +fi + +if [ "$NEEDS_LOGIN" = "true" ]; then + echo "" + echo "┌─────────────────────────────────────────────────┐" + echo "│ No valid iMessage login found — starting login │" + echo "└─────────────────────────────────────────────────┘" + echo "" + # Stop the bridge if running (otherwise it holds the DB lock) + if systemctl --user is-active mautrix-imessage >/dev/null 2>&1; then + systemctl --user stop mautrix-imessage + elif systemctl is-active mautrix-imessage >/dev/null 2>&1; then + sudo systemctl stop mautrix-imessage + fi + + if [ "${FORCE_CLEAR_STATE:-false}" = "true" ]; then + echo "Clearing stale local state before login..." + rm -f "$DB_URI" "$DB_URI-wal" "$DB_URI-shm" + rm -f "$SESSION_DIR/session.json" "$SESSION_DIR/identity.plist" "$SESSION_DIR/trustedpeers.plist" + fi + + # Run login from DATA_DIR so that relative paths (state/anisette/) + # resolve to the same location as when systemd runs the bridge. + (cd "$DATA_DIR" && "$BINARY" login -c "$CONFIG") + echo "" +fi + +# ── Preferred handle (runs every time, can reconfigure) ──────── +HANDLE_BACKUP="$DATA_DIR/.preferred-handle" +CURRENT_HANDLE=$(grep 'preferred_handle:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*preferred_handle: *//;s/['\"]//g" | tr -d ' ' || true) + +# Try to recover from backups if not set in config +if [ -z "$CURRENT_HANDLE" ]; then + if command -v sqlite3 >/dev/null 2>&1 && [ -n "${DB_URI:-}" ] && [ -f "${DB_URI:-}" ]; then + CURRENT_HANDLE=$(sqlite3 "$DB_URI" "SELECT json_extract(metadata, '$.preferred_handle') FROM user_login LIMIT 1;" 2>/dev/null || true) + fi + if [ -z "$CURRENT_HANDLE" ] && [ -f "$SESSION_DIR/session.json" ] && command -v python3 >/dev/null 2>&1; then + CURRENT_HANDLE=$(python3 -c "import json; print(json.load(open('$SESSION_DIR/session.json')).get('preferred_handle',''))" 2>/dev/null || true) + fi + if [ -z "$CURRENT_HANDLE" ] && [ -f "$HANDLE_BACKUP" ]; then + CURRENT_HANDLE=$(cat "$HANDLE_BACKUP") + fi +fi + +# Skip interactive prompt if login just ran (login flow already asked) +if [ -t 0 ] && [ "$NEEDS_LOGIN" = "false" ]; then + # Get available handles from session state (available after login) + AVAILABLE_HANDLES=$("$BINARY" list-handles 2>/dev/null | grep -E '^(tel:|mailto:)' || true) + if [ -n "$AVAILABLE_HANDLES" ]; then + echo "" + echo "Preferred handle (your iMessage sender address):" + i=1 + declare -a HANDLE_LIST=() + while IFS= read -r h; do + MARKER="" + if [ "$h" = "$CURRENT_HANDLE" ]; then + MARKER=" (current)" + fi + echo " $i) $h$MARKER" + HANDLE_LIST+=("$h") + i=$((i + 1)) + done <<< "$AVAILABLE_HANDLES" + + if [ -n "$CURRENT_HANDLE" ]; then + read -p "Choice [keep current]: " HANDLE_CHOICE + else + read -p "Choice [1]: " HANDLE_CHOICE + fi + + if [ -n "$HANDLE_CHOICE" ]; then + if [ "$HANDLE_CHOICE" -ge 1 ] 2>/dev/null && [ "$HANDLE_CHOICE" -le "${#HANDLE_LIST[@]}" ] 2>/dev/null; then + CURRENT_HANDLE="${HANDLE_LIST[$((HANDLE_CHOICE - 1))]}" + fi + elif [ -z "$CURRENT_HANDLE" ] && [ ${#HANDLE_LIST[@]} -gt 0 ]; then + CURRENT_HANDLE="${HANDLE_LIST[0]}" + fi + elif [ -n "$CURRENT_HANDLE" ]; then + echo "" + echo "Preferred handle: $CURRENT_HANDLE" + read -p "New handle, or Enter to keep current: " NEW_HANDLE + if [ -n "$NEW_HANDLE" ]; then + CURRENT_HANDLE="$NEW_HANDLE" + fi + fi +fi + +# Write preferred handle to config (add key if missing, patch if present) +if [ -n "${CURRENT_HANDLE:-}" ]; then + if grep -q 'preferred_handle:' "$CONFIG" 2>/dev/null; then + sed -i "s|preferred_handle: .*|preferred_handle: '$CURRENT_HANDLE'|" "$CONFIG" + else + sed -i "/^network:/a\\ preferred_handle: '$CURRENT_HANDLE'" "$CONFIG" + fi + echo "✓ Preferred handle: $CURRENT_HANDLE" + echo "$CURRENT_HANDLE" > "$HANDLE_BACKUP" +fi + +# ── Ensure video_transcoding key exists in config ────────────── +if ! grep -q 'video_transcoding:' "$CONFIG" 2>/dev/null; then + sed -i '/cloudkit_backfill:/i\ video_transcoding: false' "$CONFIG" +fi + +# ── Video transcoding (ffmpeg) ───────────────────────────────── +CURRENT_VIDEO_TRANSCODING=$(grep 'video_transcoding:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*video_transcoding: *//' || true) +if [ -t 0 ]; then + echo "" + echo "Video Transcoding:" + echo " When enabled, non-MP4 videos (e.g. QuickTime .mov) are automatically" + echo " converted to MP4 for broad Matrix client compatibility." + echo " Requires ffmpeg." + echo "" + if [ "$CURRENT_VIDEO_TRANSCODING" = "true" ]; then + read -p "Enable video transcoding/remuxing? [Y/n]: " ENABLE_VT + case "$ENABLE_VT" in + [nN]*) + sed -i "s/video_transcoding: .*/video_transcoding: false/" "$CONFIG" + echo "✓ Video transcoding disabled" + ;; + *) + if ! command -v ffmpeg >/dev/null 2>&1; then + echo " ffmpeg not found — installing..." + if command -v apt >/dev/null 2>&1; then + sudo apt install -y ffmpeg + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y ffmpeg + elif command -v pacman >/dev/null 2>&1; then + sudo pacman -S --noconfirm ffmpeg + elif command -v zypper >/dev/null 2>&1; then + sudo zypper install -y ffmpeg + elif command -v apk >/dev/null 2>&1; then + sudo apk add ffmpeg + else + echo " ⚠ Could not detect package manager — please install ffmpeg manually" + fi + fi + echo "✓ Video transcoding enabled" + ;; + esac + else + read -p "Enable video transcoding/remuxing? [y/N]: " ENABLE_VT + case "$ENABLE_VT" in + [yY]*) + sed -i "s/video_transcoding: .*/video_transcoding: true/" "$CONFIG" + if ! command -v ffmpeg >/dev/null 2>&1; then + echo " ffmpeg not found — installing..." + if command -v apt >/dev/null 2>&1; then + sudo apt install -y ffmpeg + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y ffmpeg + elif command -v pacman >/dev/null 2>&1; then + sudo pacman -S --noconfirm ffmpeg + elif command -v zypper >/dev/null 2>&1; then + sudo zypper install -y ffmpeg + elif command -v apk >/dev/null 2>&1; then + sudo apk add ffmpeg + else + echo " ⚠ Could not detect package manager — please install ffmpeg manually" + fi + fi + echo "✓ Video transcoding enabled" + ;; + *) + echo "✓ Video transcoding disabled" + ;; + esac + fi +fi + +# ── Ensure heic_conversion key exists in config ────────────── +if ! grep -q 'heic_conversion:' "$CONFIG" 2>/dev/null; then + sed -i '/video_transcoding:/a\ heic_conversion: false' "$CONFIG" +fi + +# ── HEIC conversion (libheif) ───────────────────────────────── +CURRENT_HEIC_CONVERSION=$(grep 'heic_conversion:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_conversion: *//' || true) +if [ -t 0 ]; then + echo "" + echo "HEIC Conversion:" + echo " When enabled, HEIC/HEIF images are automatically converted to JPEG" + echo " for broad Matrix client compatibility." + echo " Requires libheif." + echo "" + if [ "$CURRENT_HEIC_CONVERSION" = "true" ]; then + read -p "Enable HEIC to JPEG conversion? [Y/n]: " ENABLE_HC + case "$ENABLE_HC" in + [nN]*) + sed -i "s/heic_conversion: .*/heic_conversion: false/" "$CONFIG" + echo "✓ HEIC conversion disabled" + ;; + *) + if command -v apt >/dev/null 2>&1; then + dpkg -s libheif-dev >/dev/null 2>&1 || sudo apt install -y libheif-dev + elif command -v dnf >/dev/null 2>&1; then + rpm -q libheif-devel >/dev/null 2>&1 || sudo dnf install -y libheif-devel + elif command -v pacman >/dev/null 2>&1; then + pacman -Qi libheif >/dev/null 2>&1 || sudo pacman -S --noconfirm libheif + elif command -v zypper >/dev/null 2>&1; then + rpm -q libheif-devel >/dev/null 2>&1 || sudo zypper install -y libheif-devel + elif command -v apk >/dev/null 2>&1; then + apk info -e libheif-dev >/dev/null 2>&1 || sudo apk add libheif-dev + else + echo " ⚠ Could not detect package manager — please install libheif manually" + fi + echo "✓ HEIC conversion enabled" + ;; + esac + else + read -p "Enable HEIC to JPEG conversion? [y/N]: " ENABLE_HC + case "$ENABLE_HC" in + [yY]*) + sed -i "s/heic_conversion: .*/heic_conversion: true/" "$CONFIG" + if command -v apt >/dev/null 2>&1; then + dpkg -s libheif-dev >/dev/null 2>&1 || sudo apt install -y libheif-dev + elif command -v dnf >/dev/null 2>&1; then + rpm -q libheif-devel >/dev/null 2>&1 || sudo dnf install -y libheif-devel + elif command -v pacman >/dev/null 2>&1; then + pacman -Qi libheif >/dev/null 2>&1 || sudo pacman -S --noconfirm libheif + elif command -v zypper >/dev/null 2>&1; then + rpm -q libheif-devel >/dev/null 2>&1 || sudo zypper install -y libheif-devel + elif command -v apk >/dev/null 2>&1; then + apk info -e libheif-dev >/dev/null 2>&1 || sudo apk add libheif-dev + else + echo " ⚠ Could not detect package manager — please install libheif manually" + fi + echo "✓ HEIC conversion enabled" + ;; + *) + echo "✓ HEIC conversion disabled" + ;; + esac + fi +fi + +# ── HEIC JPEG quality (only if HEIC conversion is enabled) ─── +HEIC_ENABLED=$(grep 'heic_conversion:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_conversion: *//' || true) +if [ "$HEIC_ENABLED" = "true" ]; then + if ! grep -q 'heic_jpeg_quality:' "$CONFIG" 2>/dev/null; then + sed -i '/heic_conversion:/a\ heic_jpeg_quality: 95' "$CONFIG" + fi +else + sed -i '/heic_jpeg_quality:/d' "$CONFIG" +fi +if [ "$HEIC_ENABLED" = "true" ] && [ -t 0 ]; then + CURRENT_QUALITY=$(grep 'heic_jpeg_quality:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_jpeg_quality: *//' || echo "95") + [ -z "$CURRENT_QUALITY" ] && CURRENT_QUALITY=95 + echo "" + read -p "JPEG quality for HEIC conversion (1–100) [$CURRENT_QUALITY]: " NEW_QUALITY + if [ -n "$NEW_QUALITY" ]; then + if [ "$NEW_QUALITY" -ge 1 ] 2>/dev/null && [ "$NEW_QUALITY" -le 100 ] 2>/dev/null; then + sed -i "s/heic_jpeg_quality: .*/heic_jpeg_quality: $NEW_QUALITY/" "$CONFIG" + echo "✓ JPEG quality set to $NEW_QUALITY" + else + echo " ⚠ Invalid quality '$NEW_QUALITY' — keeping $CURRENT_QUALITY" + fi + else + echo "✓ JPEG quality: $CURRENT_QUALITY" + fi +fi + +# ── Install systemd service (optional) ──────────────────────── +# Detect whether systemd user sessions work. In containers (LXC) or when +# running as root, the user instance is often unavailable — fall back to a +# system-level service in that case. +USER_SERVICE_FILE="$HOME/.config/systemd/user/mautrix-imessage.service" +SYSTEM_SERVICE_FILE="/etc/systemd/system/mautrix-imessage.service" + +if command -v systemctl >/dev/null 2>&1; then + if systemctl --user status >/dev/null 2>&1; then + SYSTEMD_MODE="user" + SERVICE_FILE="$USER_SERVICE_FILE" + else + SYSTEMD_MODE="system" + SERVICE_FILE="$SYSTEM_SERVICE_FILE" + fi +else + SYSTEMD_MODE="none" + SERVICE_FILE="" +fi + +install_systemd_user() { + # Enable lingering so user services survive SSH session closures + if command -v loginctl >/dev/null 2>&1 && [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" != "yes" ]; then + sudo loginctl enable-linger "$USER" 2>/dev/null || true + fi + mkdir -p "$(dirname "$USER_SERVICE_FILE")" + cat > "$USER_SERVICE_FILE" << EOF +[Unit] +Description=mautrix-imessage bridge +After=network.target + +[Service] +Type=simple +WorkingDirectory=$(dirname "$BINARY") +ExecStart=$BINARY -c $CONFIG +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +EOF + systemctl --user daemon-reload + systemctl --user enable mautrix-imessage +} + +install_systemd_system() { + cat > "$SYSTEM_SERVICE_FILE" << EOF +[Unit] +Description=mautrix-imessage bridge +After=network.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=$(dirname "$BINARY") +ExecStart=$BINARY -c $CONFIG +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + systemctl enable mautrix-imessage +} + +if [ "$SYSTEMD_MODE" = "user" ]; then + if [ -f "$USER_SERVICE_FILE" ]; then + install_systemd_user + systemctl --user restart mautrix-imessage + echo "✓ Bridge restarted" + else + echo "" + read -p "Install as a systemd user service? [Y/n] " answer + case "$answer" in + [nN]*) ;; + *) install_systemd_user + systemctl --user start mautrix-imessage + echo "✓ Bridge started (systemd user service installed)" ;; + esac + fi +elif [ "$SYSTEMD_MODE" = "system" ]; then + if [ -f "$SYSTEM_SERVICE_FILE" ]; then + install_systemd_system + systemctl restart mautrix-imessage + echo "✓ Bridge restarted" + else + echo "" + echo "Note: systemd user session not available (container/root)." + read -p "Install as a system-level systemd service? [Y/n] " answer + case "$answer" in + [nN]*) ;; + *) install_systemd_system + systemctl start mautrix-imessage + echo "✓ Bridge started (system service installed)" ;; + esac + fi +fi + +echo "" +echo "═══════════════════════════════════════════════" +echo " Setup Complete" +echo "═══════════════════════════════════════════════" +echo "" +echo " Binary: $BINARY" +echo " Config: $CONFIG" +echo " Registration: $REGISTRATION" +echo "" +if [ "$SYSTEMD_MODE" = "user" ] && [ -f "$USER_SERVICE_FILE" ]; then + echo " Status: systemctl --user status mautrix-imessage" + echo " Logs: journalctl --user -u mautrix-imessage -f" + echo " Stop: systemctl --user stop mautrix-imessage" + echo " Restart: systemctl --user restart mautrix-imessage" +elif [ "$SYSTEMD_MODE" = "system" ] && [ -f "$SYSTEM_SERVICE_FILE" ]; then + echo " Status: systemctl status mautrix-imessage" + echo " Logs: journalctl -u mautrix-imessage -f" + echo " Stop: systemctl stop mautrix-imessage" + echo " Restart: systemctl restart mautrix-imessage" +else + echo " Run manually:" + echo " cd $(dirname "$CONFIG") && $BINARY -c $CONFIG" +fi +echo "" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..a3cf6334 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,785 @@ +#!/bin/bash +set -euo pipefail + +BINARY="$1" +DATA_DIR="$2" +BUNDLE_ID="$3" + +BINARY="$(cd "$(dirname "$BINARY")" && pwd)/$(basename "$BINARY")" +CONFIG="$DATA_DIR/config.yaml" +REGISTRATION="$DATA_DIR/registration.yaml" +PLIST="$HOME/Library/LaunchAgents/$BUNDLE_ID.plist" + +echo "" +echo "═══════════════════════════════════════════════" +echo " iMessage Bridge Setup" +echo "═══════════════════════════════════════════════" +echo "" + +# ── Prompt for config values ────────────────────────────────── +FIRST_RUN=false +if [ -f "$CONFIG" ]; then + echo "Config already exists at $CONFIG" + echo "Skipping configuration prompts. Delete it to re-configure." + echo "" +else + FIRST_RUN=true + + read -p "Homeserver URL [http://localhost:8008]: " HS_ADDRESS + HS_ADDRESS="${HS_ADDRESS:-http://localhost:8008}" + + read -p "Homeserver domain (the server_name, e.g. example.com): " HS_DOMAIN + if [ -z "$HS_DOMAIN" ]; then + echo "ERROR: Domain is required." >&2 + exit 1 + fi + + read -p "Your Matrix ID [@you:$HS_DOMAIN]: " ADMIN_USER + ADMIN_USER="${ADMIN_USER:-@you:$HS_DOMAIN}" + + echo "" + echo "Database:" + echo " 1) PostgreSQL (recommended)" + echo " 2) SQLite" + read -p "Choice [1]: " DB_CHOICE + DB_CHOICE="${DB_CHOICE:-1}" + + if [ "$DB_CHOICE" = "1" ]; then + DB_TYPE="postgres" + read -p "PostgreSQL URI [postgres://localhost/mautrix_imessage?sslmode=disable]: " DB_URI + DB_URI="${DB_URI:-postgres://localhost/mautrix_imessage?sslmode=disable}" + else + DB_TYPE="sqlite3-fk-wal" + DB_URI="file:$DATA_DIR/mautrix-imessage.db?_txlock=immediate" + fi + + echo "" + + # ── Generate config ─────────────────────────────────────────── + mkdir -p "$DATA_DIR" + "$BINARY" -c "$CONFIG" -e 2>/dev/null + echo "✓ Generated config" + + # Patch values into the generated config + python3 -c " +import re, sys +text = open('$CONFIG').read() + +def patch(text, key, val): + return re.sub( + r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', + r'\1 ' + val, + text, count=1, flags=re.MULTILINE + ) + +text = patch(text, 'address', '$HS_ADDRESS') +text = patch(text, 'domain', '$HS_DOMAIN') +text = patch(text, 'type', '$DB_TYPE') +text = patch(text, 'uri', '$DB_URI') + +lines = text.split('\n') +in_perms = False +for i, line in enumerate(lines): + if 'permissions:' in line and not line.strip().startswith('#'): + in_perms = True + continue + if in_perms and line.strip() and not line.strip().startswith('#'): + indent = len(line) - len(line.lstrip()) + lines[i] = ' ' * indent + '\"$ADMIN_USER\": admin' + break +text = '\n'.join(lines) + +open('$CONFIG', 'w').write(text) +" + # iMessage CloudKit chats can have tens of thousands of messages. + # Deliver all history in one forward batch to avoid DAG fragmentation. + sed -i '' 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + sed -i '' 's/max_catchup_messages: [0-9]*/max_catchup_messages: 5000/' "$CONFIG" + sed -i '' 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + sed -i '' 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + # Use 1s between batches — fast enough for backfill, prevents idle hot-loop + sed -i '' 's/batch_delay: [0-9]*/batch_delay: 1/' "$CONFIG" + echo "✓ Configured: $HS_ADDRESS, $HS_DOMAIN, $ADMIN_USER, $DB_TYPE" +fi + +# ── Ensure backfill_source key exists in config ─────────────── +if ! grep -q 'backfill_source:' "$CONFIG" 2>/dev/null; then + sed -i '' '/cloudkit_backfill:/a\ + backfill_source: cloudkit' "$CONFIG" +fi + +# ── Backfill source selection ───────────────────────────────── +# On first run (fresh DB), show a 3-way prompt. On re-runs, preserve existing. +DB_PATH_CHECK=$(python3 -c " +import re +text = open('$CONFIG').read() +m = re.search(r'^\s+uri:\s*file:([^?]+)', text, re.MULTILINE) +print(m.group(1) if m else '') +") +IS_FRESH_DB=false +if [ -z "$DB_PATH_CHECK" ] || [ ! -f "$DB_PATH_CHECK" ]; then + IS_FRESH_DB=true +fi + +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +CURRENT_SOURCE=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) + +if [ -t 0 ]; then + if [ "$IS_FRESH_DB" = "true" ]; then + echo "" + echo "Message History Backfill:" + echo " 1) iCloud (CloudKit) — sync from iCloud, requires device PIN" + echo " 2) Local chat.db — for legacy systems, read macOS Messages database, requires Full Disk Access" + echo " 3) Disabled — real-time messages only" + echo "" + read -p "Choose [1/2/3]: " BACKFILL_CHOICE + case "$BACKFILL_CHOICE" in + 2) + sed -i '' "s/cloudkit_backfill: .*/cloudkit_backfill: true/" "$CONFIG" + sed -i '' "s/backfill_source: .*/backfill_source: chatdb/" "$CONFIG" + echo "✓ Chat.db backfill enabled — requires Full Disk Access for the bridge binary" + ;; + 3) + sed -i '' "s/cloudkit_backfill: .*/cloudkit_backfill: false/" "$CONFIG" + sed -i '' "s/backfill_source: .*/backfill_source: cloudkit/" "$CONFIG" + echo "✓ Backfill disabled — real-time messages only, no PIN needed" + ;; + *) + sed -i '' "s/cloudkit_backfill: .*/cloudkit_backfill: true/" "$CONFIG" + sed -i '' "s/backfill_source: .*/backfill_source: cloudkit/" "$CONFIG" + echo "✓ CloudKit backfill enabled — you'll be asked for your device PIN during login" + echo "" + echo "IMPORTANT: Before starting the bridge, sync your latest messages to iCloud" + echo "from an Apple device (iPhone, iPad, or Mac) to ensure all recent messages" + echo "are available for backfill." + echo "" + read -p "Have you synced your Apple device to iCloud? [y/N]: " ICLOUD_SYNCED + case "$ICLOUD_SYNCED" in + [yY]*) echo "✓ Great — backfill will include your latest messages" ;; + *) echo "⚠ Please sync your Apple device to iCloud before starting the bridge" ;; + esac + ;; + esac + else + # Re-run: show current setting + if [ "$CURRENT_BACKFILL" = "true" ] && [ "$CURRENT_SOURCE" = "chatdb" ]; then + echo "✓ Backfill source: chat.db (local macOS Messages database)" + elif [ "$CURRENT_BACKFILL" = "true" ]; then + echo "✓ Backfill source: CloudKit (iCloud sync)" + else + echo "✓ Backfill: disabled (real-time messages only)" + fi + fi +fi + +# ── Full Disk Access check for chat.db mode (macOS only) ────── +CURRENT_SOURCE=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) +if [ "$CURRENT_SOURCE" = "chatdb" ] && [ "$(uname -s)" = "Darwin" ]; then + CHATDB_PATH="$HOME/Library/Messages/chat.db" + if [ -f "$CHATDB_PATH" ]; then + if ! sqlite3 "$CHATDB_PATH" "SELECT 1 FROM message LIMIT 1" >/dev/null 2>&1; then + echo "" + echo "⚠ Full Disk Access is required for chat.db backfill." + echo " Opening System Settings → Privacy & Security → Full Disk Access..." + echo " Grant access to the bridge binary, then press Enter to continue." + open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles" 2>/dev/null + read -p "Press Enter when Full Disk Access has been granted..." + if sqlite3 "$CHATDB_PATH" "SELECT 1 FROM message LIMIT 1" >/dev/null 2>&1; then + echo "✓ Full Disk Access confirmed" + else + echo "⚠ chat.db still not accessible — the bridge will prompt again on startup" + fi + else + echo "✓ Full Disk Access: granted" + fi + else + echo "⚠ chat.db not found at $CHATDB_PATH — is Messages set up on this Mac?" + fi +fi + +# ── Max initial messages (new database + CloudKit backfill + interactive) ── +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ "$CURRENT_BACKFILL" = "true" ] && [ -t 0 ]; then + DB_PATH=$(python3 -c " +import re +text = open('$CONFIG').read() +m = re.search(r'^\s+uri:\s*file:([^?]+)', text, re.MULTILINE) +print(m.group(1) if m else '') +") + if [ -z "$DB_PATH" ] || [ ! -f "$DB_PATH" ]; then + echo "" + echo "By default, all messages per chat will be backfilled." + echo "If you choose to limit, the minimum is 100 messages per chat." + read -p "Would you like to limit the number of messages? [y/N]: " LIMIT_MSGS + case "$LIMIT_MSGS" in + [yY]*) + while true; do + read -p "Max messages per chat (minimum 100): " MAX_MSGS + MAX_MSGS=$(echo "$MAX_MSGS" | tr -dc '0-9') + if [ -n "$MAX_MSGS" ] && [ "$MAX_MSGS" -ge 100 ] 2>/dev/null; then + break + fi + echo "Minimum is 100. Please enter a value of 100 or more." + done + sed -i '' "s/max_initial_messages: [0-9]*/max_initial_messages: $MAX_MSGS/" "$CONFIG" + # Disable backward backfill so the cap is the final word on message count + sed -i '' 's/max_batches: -1$/max_batches: 0/' "$CONFIG" + echo "✓ Max initial messages set to $MAX_MSGS per chat" + ;; + *) + echo "✓ Backfilling all messages" + ;; + esac + fi +fi + +# Tune backfill settings when CloudKit backfill is enabled +CURRENT_BACKFILL=$(grep 'cloudkit_backfill:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*cloudkit_backfill: *//' || true) +if [ "$CURRENT_BACKFILL" = "true" ]; then + # iMessage CloudKit chats can have tens of thousands of messages. + # Deliver all history in one forward batch to avoid DAG fragmentation. + PATCHED_BACKFILL=false + # Only enable unlimited backward backfill when max_initial is uncapped. + # When the user caps max_initial_messages, max_batches stays at 0 so the + # bridge won't backfill beyond the cap. + if grep -q 'max_initial_messages: 2147483647' "$CONFIG" 2>/dev/null; then + if grep -q 'max_batches: 0$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/max_batches: 0$/max_batches: -1/' "$CONFIG" + PATCHED_BACKFILL=true + fi + fi + if grep -q 'max_initial_messages: [0-9]\{1,2\}$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/max_initial_messages: [0-9]*/max_initial_messages: 2147483647/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'max_catchup_messages: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/max_catchup_messages: [0-9]*/max_catchup_messages: 5000/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'batch_size: [0-9]\{1,3\}$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/batch_size: [0-9]*/batch_size: 10000/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if grep -q 'batch_delay: 0$' "$CONFIG" 2>/dev/null; then + sed -i '' 's/batch_delay: 0$/batch_delay: 1/' "$CONFIG" + PATCHED_BACKFILL=true + fi + if [ "$PATCHED_BACKFILL" = true ]; then + echo "✓ Updated backfill settings (max_initial=unlimited, batch_size=10000, max_batches=-1)" + fi +fi + +# ── Read domain from config (works on first run and re-runs) ── +HS_DOMAIN=$(python3 -c " +import re +text = open('$CONFIG').read() +m = re.search(r'^\s+domain:\s*(\S+)', text, re.MULTILINE) +print(m.group(1) if m else 'yourserver') +") + +# ── Generate registration ──────────────────────────────────── +if [ -f "$REGISTRATION" ]; then + echo "✓ Registration already exists" +else + "$BINARY" -c "$CONFIG" -g -r "$REGISTRATION" 2>/dev/null + echo "✓ Generated registration" +fi + +# ── Register with homeserver (first run only) ───────────────── +if [ "$FIRST_RUN" = true ]; then + REG_PATH="$(cd "$DATA_DIR" && pwd)/registration.yaml" + echo "" + echo "┌─────────────────────────────────────────────┐" + echo "│ Register with your homeserver: │" + echo "│ │" + echo "│ Add to homeserver.yaml: │" + echo "│ app_service_config_files: │" + echo "│ - $REG_PATH" + echo "│ │" + echo "│ Then restart your homeserver. │" + echo "└─────────────────────────────────────────────┘" + echo "" + read -p "Press Enter once your homeserver is restarted..." +fi + +# ── Restore CardDAV config from backup ──────────────────────── +# Skip when using chat.db — local macOS Contacts are used automatically. +CARDDAV_BACKUP="$DATA_DIR/.carddav-config" +CURRENT_SOURCE_CHECK=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) +if [ "$CURRENT_SOURCE_CHECK" != "chatdb" ] && [ -f "$CARDDAV_BACKUP" ]; then + CHECK_EMAIL=$(grep 'email:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*email: *//;s/['\"]//g" | tr -d ' ' || true) + if [ -z "$CHECK_EMAIL" ]; then + source "$CARDDAV_BACKUP" + if [ -n "${SAVED_CARDDAV_EMAIL:-}" ] && [ -n "${SAVED_CARDDAV_ENC:-}" ]; then + python3 -c " +import re +text = open('$CONFIG').read() +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"$SAVED_CARDDAV_EMAIL\"') +text = patch(text, 'url', '\"$SAVED_CARDDAV_URL\"') +text = patch(text, 'username', '\"$SAVED_CARDDAV_USERNAME\"') +text = patch(text, 'password_encrypted', '\"$SAVED_CARDDAV_ENC\"') +open('$CONFIG', 'w').write(text) +" + echo "✓ Restored CardDAV config: $SAVED_CARDDAV_EMAIL" + fi + fi +fi + +# ── Contact source (runs every time, can reconfigure) ───────── +# Skip when using chat.db — local macOS Contacts are used automatically. +CURRENT_SOURCE=$(grep 'backfill_source:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*backfill_source: *//' || true) +if [ "$CURRENT_SOURCE" = "chatdb" ]; then + echo "✓ Contact source: local macOS Contacts (via chat.db)" +elif [ -t 0 ]; then + CURRENT_CARDDAV_EMAIL=$(grep 'email:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*email: *//;s/['\"]//g" | tr -d ' ' || true) + CONFIGURE_CARDDAV=false + + if [ -n "$CURRENT_CARDDAV_EMAIL" ] && [ "$CURRENT_CARDDAV_EMAIL" != '""' ]; then + echo "" + echo "Contact source: External CardDAV ($CURRENT_CARDDAV_EMAIL)" + read -p "Change contact provider? [y/N]: " CHANGE_CONTACTS + case "$CHANGE_CONTACTS" in + [yY]*) CONFIGURE_CARDDAV=true ;; + esac + else + echo "" + echo "Contact source (for resolving names in chats):" + echo " 1) iCloud (default — uses your Apple ID)" + echo " 2) Google Contacts (requires app password)" + echo " 3) Fastmail" + echo " 4) Nextcloud" + echo " 5) Other CardDAV server" + read -p "Choice [1]: " CONTACT_CHOICE + CONTACT_CHOICE="${CONTACT_CHOICE:-1}" + if [ "$CONTACT_CHOICE" != "1" ]; then + CONFIGURE_CARDDAV=true + fi + fi + + if [ "$CONFIGURE_CARDDAV" = true ]; then + # Show menu if we're changing from an existing provider + if [ -n "$CURRENT_CARDDAV_EMAIL" ] && [ "$CURRENT_CARDDAV_EMAIL" != '""' ]; then + echo "" + echo " 1) iCloud (remove external CardDAV)" + echo " 2) Google Contacts (requires app password)" + echo " 3) Fastmail" + echo " 4) Nextcloud" + echo " 5) Other CardDAV server" + read -p "Choice: " CONTACT_CHOICE + fi + + CARDDAV_EMAIL="" + CARDDAV_PASSWORD="" + CARDDAV_USERNAME="" + CARDDAV_URL="" + + if [ "${CONTACT_CHOICE:-}" = "1" ]; then + # Remove external CardDAV — clear the config fields + python3 -c " +import re +text = open('$CONFIG').read() +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"\"') +text = patch(text, 'url', '\"\"') +text = patch(text, 'username', '\"\"') +text = patch(text, 'password_encrypted', '\"\"') +open('$CONFIG', 'w').write(text) +" + rm -f "$CARDDAV_BACKUP" + echo "✓ Switched to iCloud contacts" + elif [ -n "${CONTACT_CHOICE:-}" ]; then + read -p "Email address: " CARDDAV_EMAIL + if [ -z "$CARDDAV_EMAIL" ]; then + echo "ERROR: Email is required." >&2 + exit 1 + fi + + case "$CONTACT_CHOICE" in + 2) + CARDDAV_URL="https://www.googleapis.com/carddav/v1/principals/$CARDDAV_EMAIL/lists/default/" + echo " Note: Use a Google App Password, without spaces (https://myaccount.google.com/apppasswords)" + ;; + 3) + CARDDAV_URL="https://carddav.fastmail.com/dav/addressbooks/user/$CARDDAV_EMAIL/Default/" + echo " Note: Use a Fastmail App Password (Settings → Privacy & Security → App Passwords)" + ;; + 4) + read -p "Nextcloud server URL (e.g. https://cloud.example.com): " NC_SERVER + NC_SERVER="${NC_SERVER%/}" + CARDDAV_URL="$NC_SERVER/remote.php/dav" + ;; + 5) + read -p "CardDAV server URL: " CARDDAV_URL + if [ -z "$CARDDAV_URL" ]; then + echo "ERROR: URL is required." >&2 + exit 1 + fi + ;; + esac + + read -p "Username (leave empty to use email): " CARDDAV_USERNAME + read -s -p "App password: " CARDDAV_PASSWORD + echo "" + if [ -z "$CARDDAV_PASSWORD" ]; then + echo "ERROR: Password is required." >&2 + exit 1 + fi + + # Encrypt password and patch config + CARDDAV_ARGS="--email $CARDDAV_EMAIL --password $CARDDAV_PASSWORD --url $CARDDAV_URL" + if [ -n "$CARDDAV_USERNAME" ]; then + CARDDAV_ARGS="$CARDDAV_ARGS --username $CARDDAV_USERNAME" + fi + CARDDAV_JSON=$("$BINARY" carddav-setup $CARDDAV_ARGS 2>/dev/null) || CARDDAV_JSON="" + + if [ -z "$CARDDAV_JSON" ]; then + echo "⚠ CardDAV setup failed. You can configure it manually in $CONFIG" + else + CARDDAV_RESOLVED_URL=$(echo "$CARDDAV_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['url'])") + CARDDAV_ENC=$(echo "$CARDDAV_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['password_encrypted'])") + EFFECTIVE_USERNAME="${CARDDAV_USERNAME:-$CARDDAV_EMAIL}" + python3 -c " +import re +text = open('$CONFIG').read() +def patch(text, key, val): + return re.sub(r'^(\s+' + re.escape(key) + r'\s*:)\s*.*$', r'\1 ' + val, text, count=1, flags=re.MULTILINE) +text = patch(text, 'email', '\"$CARDDAV_EMAIL\"') +text = patch(text, 'url', '\"$CARDDAV_RESOLVED_URL\"') +text = patch(text, 'username', '\"$EFFECTIVE_USERNAME\"') +text = patch(text, 'password_encrypted', '\"$CARDDAV_ENC\"') +open('$CONFIG', 'w').write(text) +" + echo "✓ CardDAV configured: $CARDDAV_EMAIL → $CARDDAV_RESOLVED_URL" + cat > "$CARDDAV_BACKUP" << BKEOF +SAVED_CARDDAV_EMAIL="$CARDDAV_EMAIL" +SAVED_CARDDAV_URL="$CARDDAV_RESOLVED_URL" +SAVED_CARDDAV_USERNAME="$EFFECTIVE_USERNAME" +SAVED_CARDDAV_ENC="$CARDDAV_ENC" +BKEOF + fi + fi + fi +fi + +# ── Check for existing login / prompt if needed ────────────── +DB_URI=$(python3 -c " +import re +text = open('$CONFIG').read() +m = re.search(r'^\s+uri:\s*file:([^?]+)', text, re.MULTILINE) +print(m.group(1) if m else '') +") +NEEDS_LOGIN=false + +if [ -z "$DB_URI" ] || [ ! -f "$DB_URI" ]; then + NEEDS_LOGIN=true +elif command -v sqlite3 >/dev/null 2>&1; then + LOGIN_COUNT=$(sqlite3 "$DB_URI" "SELECT count(*) FROM user_login;" 2>/dev/null || echo "0") + if [ "$LOGIN_COUNT" = "0" ]; then + NEEDS_LOGIN=true + fi +else + # sqlite3 not available — can't verify DB has logins, assume login needed + NEEDS_LOGIN=true +fi + +# Require re-login if keychain trust-circle state is missing. +# This catches upgrades from pre-keychain versions where the device-passcode +# step was never run. If trustedpeers.plist exists with a user_identity, the +# keychain was joined successfully and any transient PCS errors are harmless. +SESSION_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/mautrix-imessage" +TRUSTEDPEERS_FILE="$SESSION_DIR/trustedpeers.plist" +FORCE_CLEAR_STATE=false +# Trust-circle only applies to CloudKit backfill — chatdb never creates +# trustedpeers.plist. Match Go's UseCloudKitBackfill(): cloudkit_backfill +# must be true AND backfill_source must not be "chatdb". +CK_ENABLED=$(awk '/cloudkit_backfill:/{print $2; exit}' "$CONFIG" 2>/dev/null) +BF_SOURCE=$(awk '/backfill_source:/{print $2; exit}' "$CONFIG" 2>/dev/null) +if [ "$NEEDS_LOGIN" = "false" ] && [ "$CK_ENABLED" = "true" ] && [ "$BF_SOURCE" != "chatdb" ]; then + HAS_CLIQUE=false + if [ -f "$TRUSTEDPEERS_FILE" ]; then + if grep -q "<key>userIdentity</key>\|<key>user_identity</key>" "$TRUSTEDPEERS_FILE" 2>/dev/null; then + HAS_CLIQUE=true + fi + fi + + if [ "$HAS_CLIQUE" != "true" ]; then + echo "⚠ Existing login found, but keychain trust-circle is not initialized." + echo " Forcing fresh login so device-passcode step can run." + NEEDS_LOGIN=true + FORCE_CLEAR_STATE=true + fi +fi + +if [ "$NEEDS_LOGIN" = "true" ]; then + echo "" + echo "┌─────────────────────────────────────────────────┐" + echo "│ No valid iMessage login found — starting login │" + echo "└─────────────────────────────────────────────────┘" + echo "" + # Stop the bridge if running (otherwise it holds the DB lock) + launchctl unload "$PLIST" 2>/dev/null || true + + if [ "${FORCE_CLEAR_STATE:-false}" = "true" ]; then + echo "Clearing stale local state before login..." + rm -f "$DB_URI" "$DB_URI-wal" "$DB_URI-shm" + rm -f "$SESSION_DIR/session.json" "$SESSION_DIR/identity.plist" "$SESSION_DIR/trustedpeers.plist" + fi + + (cd "$DATA_DIR" && "$BINARY" login -c "$CONFIG") + echo "" +fi + +# ── Preferred handle (runs every time, can reconfigure) ──────── +HANDLE_BACKUP="$DATA_DIR/.preferred-handle" +CURRENT_HANDLE=$(grep 'preferred_handle:' "$CONFIG" 2>/dev/null | head -1 | sed "s/.*preferred_handle: *//;s/['\"]//g" | tr -d ' ' || true) + +# Try to recover from backups if not set in config +if [ -z "$CURRENT_HANDLE" ]; then + if command -v sqlite3 >/dev/null 2>&1 && [ -n "${DB_URI:-}" ] && [ -f "${DB_URI:-}" ]; then + CURRENT_HANDLE=$(sqlite3 "$DB_URI" "SELECT json_extract(metadata, '$.preferred_handle') FROM user_login LIMIT 1;" 2>/dev/null || true) + fi + if [ -z "$CURRENT_HANDLE" ] && [ -f "$SESSION_DIR/session.json" ] && command -v python3 >/dev/null 2>&1; then + CURRENT_HANDLE=$(python3 -c "import json; print(json.load(open('$SESSION_DIR/session.json')).get('preferred_handle',''))" 2>/dev/null || true) + fi + if [ -z "$CURRENT_HANDLE" ] && [ -f "$HANDLE_BACKUP" ]; then + CURRENT_HANDLE=$(cat "$HANDLE_BACKUP") + fi +fi + +# Skip interactive prompt if login just ran (login flow already asked) +if [ -t 0 ] && [ "$NEEDS_LOGIN" = "false" ]; then + # Get available handles from session state (available after login) + AVAILABLE_HANDLES=$("$BINARY" list-handles 2>/dev/null | grep -E '^(tel:|mailto:)' || true) + if [ -n "$AVAILABLE_HANDLES" ]; then + echo "" + echo "Preferred handle (your iMessage sender address):" + i=1 + declare -a HANDLE_LIST=() + while IFS= read -r h; do + MARKER="" + if [ "$h" = "$CURRENT_HANDLE" ]; then + MARKER=" (current)" + fi + echo " $i) $h$MARKER" + HANDLE_LIST+=("$h") + i=$((i + 1)) + done <<< "$AVAILABLE_HANDLES" + + if [ -n "$CURRENT_HANDLE" ]; then + read -p "Choice [keep current]: " HANDLE_CHOICE + else + read -p "Choice [1]: " HANDLE_CHOICE + fi + + if [ -n "$HANDLE_CHOICE" ]; then + if [ "$HANDLE_CHOICE" -ge 1 ] 2>/dev/null && [ "$HANDLE_CHOICE" -le "${#HANDLE_LIST[@]}" ] 2>/dev/null; then + CURRENT_HANDLE="${HANDLE_LIST[$((HANDLE_CHOICE - 1))]}" + fi + elif [ -z "$CURRENT_HANDLE" ] && [ ${#HANDLE_LIST[@]} -gt 0 ]; then + CURRENT_HANDLE="${HANDLE_LIST[0]}" + fi + elif [ -n "$CURRENT_HANDLE" ]; then + echo "" + echo "Preferred handle: $CURRENT_HANDLE" + read -p "New handle, or Enter to keep current: " NEW_HANDLE + if [ -n "$NEW_HANDLE" ]; then + CURRENT_HANDLE="$NEW_HANDLE" + fi + fi +fi + +# Write preferred handle to config (add key if missing, patch if present) +if [ -n "${CURRENT_HANDLE:-}" ]; then + if grep -q 'preferred_handle:' "$CONFIG" 2>/dev/null; then + sed -i '' "s|preferred_handle: .*|preferred_handle: '$CURRENT_HANDLE'|" "$CONFIG" + else + sed -i '' "/^network:/a\\ +\\ preferred_handle: '$CURRENT_HANDLE' +" "$CONFIG" + fi + echo "✓ Preferred handle: $CURRENT_HANDLE" + echo "$CURRENT_HANDLE" > "$HANDLE_BACKUP" +fi + +# ── Ensure video_transcoding key exists in config ────────────── +if ! grep -q 'video_transcoding:' "$CONFIG" 2>/dev/null; then + sed -i '' '/cloudkit_backfill:/i\ + video_transcoding: false' "$CONFIG" +fi + +# ── Video transcoding (ffmpeg) ───────────────────────────────── +CURRENT_VIDEO_TRANSCODING=$(grep 'video_transcoding:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*video_transcoding: *//' || true) +if [ -t 0 ]; then + echo "" + echo "Video Transcoding:" + echo " When enabled, non-MP4 videos (e.g. QuickTime .mov) are automatically" + echo " converted to MP4 for broad Matrix client compatibility." + echo " Requires ffmpeg." + echo "" + if [ "$CURRENT_VIDEO_TRANSCODING" = "true" ]; then + read -p "Enable video transcoding/remuxing? [Y/n]: " ENABLE_VT + case "$ENABLE_VT" in + [nN]*) + sed -i '' "s/video_transcoding: .*/video_transcoding: false/" "$CONFIG" + echo "✓ Video transcoding disabled" + ;; + *) + if ! command -v ffmpeg >/dev/null 2>&1; then + echo " ffmpeg not found — installing via Homebrew..." + brew install ffmpeg + fi + echo "✓ Video transcoding enabled" + ;; + esac + else + read -p "Enable video transcoding/remuxing? [y/N]: " ENABLE_VT + case "$ENABLE_VT" in + [yY]*) + sed -i '' "s/video_transcoding: .*/video_transcoding: true/" "$CONFIG" + if ! command -v ffmpeg >/dev/null 2>&1; then + echo " ffmpeg not found — installing via Homebrew..." + brew install ffmpeg + fi + echo "✓ Video transcoding enabled" + ;; + *) + echo "✓ Video transcoding disabled" + ;; + esac + fi +fi + +# ── Ensure heic_conversion key exists in config ────────────── +if ! grep -q 'heic_conversion:' "$CONFIG" 2>/dev/null; then + sed -i '' '/video_transcoding:/a\ + heic_conversion: false' "$CONFIG" +fi + +# ── HEIC conversion (libheif) ───────────────────────────────── +CURRENT_HEIC_CONVERSION=$(grep 'heic_conversion:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_conversion: *//' || true) +if [ -t 0 ]; then + echo "" + echo "HEIC Conversion:" + echo " When enabled, HEIC/HEIF images are automatically converted to JPEG" + echo " for broad Matrix client compatibility." + echo " Requires libheif." + echo "" + if [ "$CURRENT_HEIC_CONVERSION" = "true" ]; then + read -p "Enable HEIC to JPEG conversion? [Y/n]: " ENABLE_HC + case "$ENABLE_HC" in + [nN]*) + sed -i '' "s/heic_conversion: .*/heic_conversion: false/" "$CONFIG" + echo "✓ HEIC conversion disabled" + ;; + *) + if command -v brew >/dev/null 2>&1; then + brew list libheif >/dev/null 2>&1 || brew install libheif + fi + echo "✓ HEIC conversion enabled" + ;; + esac + else + read -p "Enable HEIC to JPEG conversion? [y/N]: " ENABLE_HC + case "$ENABLE_HC" in + [yY]*) + sed -i '' "s/heic_conversion: .*/heic_conversion: true/" "$CONFIG" + if command -v brew >/dev/null 2>&1; then + brew list libheif >/dev/null 2>&1 || brew install libheif + fi + echo "✓ HEIC conversion enabled" + ;; + *) + echo "✓ HEIC conversion disabled" + ;; + esac + fi +fi + +# ── HEIC JPEG quality (only if HEIC conversion is enabled) ─── +HEIC_ENABLED=$(grep 'heic_conversion:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_conversion: *//' || true) +if [ "$HEIC_ENABLED" = "true" ]; then + if ! grep -q 'heic_jpeg_quality:' "$CONFIG" 2>/dev/null; then + sed -i '' "$(printf '/heic_conversion:/a\\\n heic_jpeg_quality: 95')" "$CONFIG" + fi +else + sed -i '' '/heic_jpeg_quality:/d' "$CONFIG" +fi +if [ "$HEIC_ENABLED" = "true" ] && [ -t 0 ]; then + CURRENT_QUALITY=$(grep 'heic_jpeg_quality:' "$CONFIG" 2>/dev/null | head -1 | sed 's/.*heic_jpeg_quality: *//' || echo "95") + [ -z "$CURRENT_QUALITY" ] && CURRENT_QUALITY=95 + echo "" + read -p "JPEG quality for HEIC conversion (1–100) [$CURRENT_QUALITY]: " NEW_QUALITY + if [ -n "$NEW_QUALITY" ]; then + if [ "$NEW_QUALITY" -ge 1 ] 2>/dev/null && [ "$NEW_QUALITY" -le 100 ] 2>/dev/null; then + sed -i '' "s/heic_jpeg_quality: .*/heic_jpeg_quality: $NEW_QUALITY/" "$CONFIG" + echo "✓ JPEG quality set to $NEW_QUALITY" + else + echo " ⚠ Invalid quality '$NEW_QUALITY' — keeping $CURRENT_QUALITY" + fi + else + echo "✓ JPEG quality: $CURRENT_QUALITY" + fi +fi + +# ── Install LaunchAgent ─────────────────────────────────────── +CONFIG_ABS="$(cd "$DATA_DIR" && pwd)/config.yaml" +DATA_ABS="$(cd "$DATA_DIR" && pwd)" +LOG_OUT="$DATA_ABS/bridge.stdout.log" +LOG_ERR="$DATA_ABS/bridge.stderr.log" + +mkdir -p "$(dirname "$PLIST")" +launchctl unload "$PLIST" 2>/dev/null || true + +cat > "$PLIST" << PLIST_EOF +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>$BUNDLE_ID</string> + <key>ProgramArguments</key> + <array> + <string>$BINARY</string> + <string>-c</string> + <string>$CONFIG_ABS</string> + </array> + <key>WorkingDirectory</key> + <string>$DATA_ABS</string> + <key>RunAtLoad</key> + <true/> + <key>KeepAlive</key> + <true/> + <key>StandardOutPath</key> + <string>$LOG_OUT</string> + <key>StandardErrorPath</key> + <string>$LOG_ERR</string> +</dict> +</plist> +PLIST_EOF + +launchctl load "$PLIST" +echo "✓ Bridge started (LaunchAgent installed)" +echo "" + +# ── Wait for bridge to connect ──────────────────────────────── +echo "Waiting for bridge to start..." +for i in $(seq 1 15); do + if grep -q "Bridge started" "$LOG_OUT" 2>/dev/null; then + echo "✓ Bridge is running" + break + fi + sleep 1 +done + +echo "" +echo "═══════════════════════════════════════════════" +echo " Setup Complete" +echo "═══════════════════════════════════════════════" +echo "" +echo " Logs: tail -f $LOG_OUT" +echo " Restart: launchctl kickstart -k gui/$(id -u)/$BUNDLE_ID" +echo " Stop: launchctl bootout gui/$(id -u)/$BUNDLE_ID" +echo "" diff --git a/scripts/patch_bindings.py b/scripts/patch_bindings.py new file mode 100644 index 00000000..30c9e0e5 --- /dev/null +++ b/scripts/patch_bindings.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Patch uniffi-generated Go bindings for Go 1.24+ compatibility.""" +import re +import sys + +def patch(content: str) -> str: + # 1. Add CGO LDFLAGS (platform-specific) + content = content.replace( + '// #include <rustpushgo.h>\nimport "C"', + '// #include <rustpushgo.h>\n' + '// #cgo LDFLAGS: -L${SRCDIR}/../../ -lrustpushgo -ldl -lm -lz\n' + '// #cgo darwin LDFLAGS: -framework Security -framework SystemConfiguration -framework CoreFoundation -framework Foundation -framework CoreServices -lresolv\n' + '// #cgo linux LDFLAGS: -lpthread -lssl -lcrypto -lresolv\n' + 'import "C"' + ) + + # 2. Replace type alias with named struct + conversion functions + content = content.replace( + 'type RustBuffer = C.RustBuffer', + '''type RustBuffer struct { +\tcapacity C.int32_t +\tlen C.int32_t +\tdata *C.uint8_t +} + +func rustBufferToC(rb RustBuffer) C.RustBuffer { +\treturn *(*C.RustBuffer)(unsafe.Pointer(&rb)) +} + +func rustBufferFromC(crb C.RustBuffer) RustBuffer { +\treturn *(*RustBuffer)(unsafe.Pointer(&crb)) +}''', + 1 + ) + + # 3. Fix specific known patterns + + # Free: cb is RustBuffer, needs C.RustBuffer + content = content.replace( + 'C.ffi_rustpushgo_rustbuffer_free(cb, status)', + 'C.ffi_rustpushgo_rustbuffer_free(rustBufferToC(cb), status)' + ) + + # Alloc/from_bytes: returns C.RustBuffer, need RustBuffer + content = content.replace( + 'return C.ffi_rustpushgo_rustbuffer_from_bytes(foreign, status)', + 'return rustBufferFromC(C.ffi_rustpushgo_rustbuffer_from_bytes(foreign, status))' + ) + + # status.errorBuf: C.RustBuffer → RustBufferI + content = content.replace( + 'converter.Lift(status.errorBuf)', + 'converter.Lift(rustBufferFromC(status.errorBuf))' + ) + content = content.replace( + 'FfiConverterStringINSTANCE.Lift(status.errorBuf)', + 'FfiConverterStringINSTANCE.Lift(rustBufferFromC(status.errorBuf))' + ) + + # rust_future_complete_rust_buffer: returns C.RustBuffer, need RustBufferI + content = content.replace( + 'return C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status)', + 'return rustBufferFromC(C.ffi_rustpushgo_rust_future_complete_rust_buffer(unsafe.Pointer(handle), status))' + ) + + # 4. Now handle .Lower() → rustBufferToC and C function returns → rustBufferFromC + # We need to identify which FfiConverters return RustBuffer (not scalars/pointers) + + # These converters return RustBuffer from .Lower(): + rb_converter_prefixes = [ + 'FfiConverterString', + 'FfiConverterBytes', + 'FfiConverterType', # FfiConverterTypeWrappedConversation, etc. + 'FfiConverterOptional', # FfiConverterOptionalString, etc. + 'FfiConverterSequence', # FfiConverterSequenceString, etc. + ] + + # Build a regex pattern for RustBuffer-returning converters + rb_pattern = '|'.join(rb_converter_prefixes) + + # Wrap .Lower() calls from RustBuffer converters with rustBufferToC() + # But ONLY when they appear as arguments to C function calls (inside C.uniffi_ or C.ffi_ calls) + # To handle multi-line calls, we'll process the content as a whole + + # Strategy: Find all `C.uniffi_rustpushgo_fn_` and `C.uniffi_rustpushgo_checksum_` call sites + # and within their argument lists, wrap RustBuffer .Lower() calls + + # Actually simpler: just wrap ALL RustBuffer .Lower() calls that are NOT inside method definitions + # The .Lower() method definitions look like: `func (c FfiConverterXxx) Lower(value Xxx) RustBuffer {` + # The call sites look like: `FfiConverterXxxINSTANCE.Lower(val)` or `FfiConverterXxx{}.Lower(val)` + + lines = content.split('\n') + result = [] + in_lower_method_def = False + + for line in lines: + stripped = line.strip() + + # Skip method definitions + if stripped.startswith('func (') and ') Lower(' in stripped: + in_lower_method_def = True + + if not in_lower_method_def: + # Wrap RustBuffer .Lower() calls in non-definition contexts + for prefix in rb_converter_prefixes: + # Match INSTANCE.Lower(xxx) pattern + pattern = rf'({prefix}\w*INSTANCE\.Lower\([^)]*\))' + if re.search(pattern, line): + line = re.sub(pattern, r'rustBufferToC(\1)', line) + # Match {}.Lower(xxx) pattern + pattern2 = rf'({prefix}\w*' + r'\{\}\.Lower\([^)]*\))' + if re.search(pattern2, line): + line = re.sub(pattern2, r'rustBufferToC(\1)', line) + + if in_lower_method_def and stripped == '}': + in_lower_method_def = False + + result.append(line) + + content = '\n'.join(result) + + # 5. Fix C function returns that return C.RustBuffer in RustBuffer-returning contexts + # These are inside `func(status *C.RustCallStatus) RustBuffer {` lambdas + # The return statements call C.uniffi_... functions + + lines = content.split('\n') + result = [] + expect_rustbuffer_return = False + brace_depth = 0 + multi_line_wrap_pending = False + paren_depth_for_wrap = 0 + + for line in lines: + stripped = line.strip() + + # Handle pending multi-line rustBufferFromC wrap + if multi_line_wrap_pending: + open_parens = line.count('(') - line.count(')') + paren_depth_for_wrap += open_parens + if paren_depth_for_wrap <= 0: + # Close the rustBufferFromC paren after the last closing paren of the C call + line = line.rstrip() + # Find the last ')' and add our closing ')' after it + last_paren = line.rfind(')') + if last_paren >= 0: + line = line[:last_paren+1] + ')' + line[last_paren+1:] + multi_line_wrap_pending = False + paren_depth_for_wrap = 0 + + # Detect RustBuffer-returning lambda start (RustBuffer or RustBufferI) + if ('func(status *C.RustCallStatus) RustBuffer {' in stripped or + 'func(_uniffiStatus *C.RustCallStatus) RustBuffer {' in stripped or + 'func(status *C.RustCallStatus) RustBufferI {' in stripped or + 'func(_uniffiStatus *C.RustCallStatus) RustBufferI {' in stripped or + 'func(handle *C.void, status *C.RustCallStatus) RustBufferI {' in stripped): + expect_rustbuffer_return = True + brace_depth = 1 + elif expect_rustbuffer_return: + brace_depth += stripped.count('{') - stripped.count('}') + if brace_depth <= 0: + expect_rustbuffer_return = False + + if expect_rustbuffer_return and stripped.startswith('return C.') and 'rustBufferFromC' not in stripped: + # This C function call returns C.RustBuffer but we need RustBuffer + # Mark this line for wrapping - we need to find where the call ends + # (could be multi-line) + indent = line[:len(line) - len(line.lstrip())] + new_return = 'return rustBufferFromC(' + stripped[len('return '):] + # Count parens in the ORIGINAL return statement (without our wrapper) + orig_open = stripped[len('return '):].count('(') - stripped[len('return '):].count(')') + if orig_open <= 0: + # Single-line call, close our wrapper + line = indent + new_return + ')' + else: + # Multi-line call - track remaining parens to know when to close wrapper + line = indent + new_return + multi_line_wrap_pending = True + paren_depth_for_wrap = orig_open + + result.append(line) + + content = '\n'.join(result) + + # 6. Fix *outBuf assignments where RustBuffer needs to be C.RustBuffer + # Pattern: `*outBuf = FfiConverter...INSTANCE.drop(...)` + content = re.sub( + r'\*outBuf = (FfiConverter\w+INSTANCE\.drop\([^)]*\))', + r'*outBuf = rustBufferToC(\1)', + content + ) + + # 7. Fix double-wrapping + while 'rustBufferToC(rustBufferToC(' in content: + content = content.replace('rustBufferToC(rustBufferToC(', 'rustBufferToC(') + while 'rustBufferFromC(rustBufferFromC(' in content: + content = content.replace('rustBufferFromC(rustBufferFromC(', 'rustBufferFromC(') + + return content + +if __name__ == '__main__': + path = sys.argv[1] if len(sys.argv) > 1 else 'pkg/rustpushgo/rustpushgo.go' + with open(path) as f: + content = f.read() + result = patch(content) + with open(path, 'w') as f: + f.write(result) + print(f"Successfully patched {path}") diff --git a/scripts/patch_bindings.sh b/scripts/patch_bindings.sh new file mode 100755 index 00000000..0ef6ac9c --- /dev/null +++ b/scripts/patch_bindings.sh @@ -0,0 +1,213 @@ +#!/bin/bash +# Patch uniffi-generated Go bindings for Go 1.24+ compatibility. +# +# Go 1.24+ disallows methods on type aliases of cgo types. +# This script converts `type RustBuffer = C.RustBuffer` to a named struct +# with unsafe.Pointer-based zero-copy conversion functions. +set -e + +FILE="$1" +if [ -z "$FILE" ]; then + FILE="pkg/rustpushgo/rustpushgo.go" +fi + +echo "Patching $FILE for Go 1.24+ compatibility..." + +# 1. Add CGO LDFLAGS +sed -i '' 's|// #include <rustpushgo.h>|// #include <rustpushgo.h>\n// #cgo LDFLAGS: -L${SRCDIR}/../../ -lrustpushgo -ldl -lm -framework Security -framework SystemConfiguration -framework CoreFoundation -framework Foundation -lz -lresolv|' "$FILE" + +# 2. Replace type alias with a compatible named struct + conversion functions +python3 << 'PYEOF' +import sys, re + +FILE = sys.argv[1] +with open(FILE) as f: + content = f.read() + +# Replace the type alias with a named struct +old = 'type RustBuffer = C.RustBuffer' +new = '''type RustBuffer struct { + capacity C.int32_t + len C.int32_t + data *C.uint8_t +} + +// Zero-copy conversion between RustBuffer and C.RustBuffer. +// These structs have identical memory layout. +func rustBufferToC(rb RustBuffer) C.RustBuffer { + return *(*C.RustBuffer)(unsafe.Pointer(&rb)) +} + +func rustBufferFromC(crb C.RustBuffer) RustBuffer { + return *(*RustBuffer)(unsafe.Pointer(&crb)) +}''' +content = content.replace(old, new, 1) + +# Fix RustBuffer.Free() - cb passed to C function +content = content.replace( + 'C.ffi_rustpushgo_rustbuffer_free(cb, status)', + 'C.ffi_rustpushgo_rustbuffer_free(rustBufferToC(cb), status)' +) + +# Fix bytesToRustBuffer/stringToRustBuffer - C alloc returns C.RustBuffer +content = content.replace( + 'return C.ffi_rustpushgo_rustbuffer_from_bytes(', + 'return rustBufferFromC(C.ffi_rustpushgo_rustbuffer_from_bytes(' +) +# Close the extra paren - find the full statement +content = re.sub( + r'return rustBufferFromC\(C\.ffi_rustpushgo_rustbuffer_from_bytes\(foreign, status\)\)', + 'return rustBufferFromC(C.ffi_rustpushgo_rustbuffer_from_bytes(foreign, status))', + content +) + +# Fix status.errorBuf - it's C.RustBuffer, needs to be RustBufferI +content = content.replace( + 'converter.Lift(status.errorBuf)', + 'converter.Lift(rustBufferFromC(status.errorBuf))' +) +content = content.replace( + 'FfiConverterStringINSTANCE.Lift(status.errorBuf)', + 'FfiConverterStringINSTANCE.Lift(rustBufferFromC(status.errorBuf))' +) + +# Fix all C.uniffi_rustpushgo_ function calls +# Pattern: These C functions may take C.RustBuffer args (from .Lower() which returns RustBuffer) +# and return C.RustBuffer (which needs to become RustBuffer) + +# Strategy: +# 1. All .Lower() calls that are passed as arguments to C functions need rustBufferToC() +# 2. All C function calls that are inside rustCall[RustBuffer] or rustCallWithError[RustBuffer] +# lambdas need their return value wrapped in rustBufferFromC() + +# For (1): Find C function calls and wrap RustBuffer arguments +# The generated patterns look like: +# C.uniffi_rustpushgo_fn_xxx(arg1, FfiConverterXxx.Lower(val), arg2, ...) +# where .Lower() returns RustBuffer but C function wants C.RustBuffer + +# For (2): Functions called in `rustCall[RustBuffer]` or `rustCallWithError[RustBuffer]` +# lambdas that return the result of a C function call + +# Let's do this line-by-line +lines = content.split('\n') +result = [] +in_rustbuffer_lambda = False + +for i, line in enumerate(lines): + stripped = line.strip() + + # Track if we're inside a lambda that returns RustBuffer + if 'func(status *C.RustCallStatus) RustBuffer {' in stripped: + in_rustbuffer_lambda = True + + # Fix: C function calls inside RustBuffer-returning lambdas + if in_rustbuffer_lambda and stripped.startswith('return C.uniffi_'): + # Wrap return value with rustBufferFromC + line = line.replace('return C.uniffi_', 'return rustBufferFromC(C.uniffi_') + # Add closing paren before the status arg end + # The line ends with `, status)` — we need `), status)` -> no, we need to close after the whole C call + # Pattern: return C.uniffi_xxx(args, status) + # We need: return rustBufferFromC(C.uniffi_xxx(args, status)) + # Find last `)` and add another `)` before it? No — the line has a single closing. + # Actually: `return C.uniffi_xxx(arg1, arg2, status)` + # becomes: `return rustBufferFromC(C.uniffi_xxx(arg1, arg2, status))` + # We already added the opening paren. Need to add closing paren at end. + line = line.rstrip() + if line.endswith(')'): + line = line + ')' + + if in_rustbuffer_lambda and stripped == '}': + # Check if this closes the lambda (rough heuristic) + if stripped == '}' and not stripped.startswith('//'): + in_rustbuffer_lambda = False + + # Fix: RustBuffer args passed to C functions + # These are typically .Lower() results in C function call arguments + if 'C.uniffi_rustpushgo_fn_' in stripped or 'C.uniffi_rustpushgo_checksum_' in stripped: + # Find all INSTANCE.Lower(...) calls that produce RustBuffer values + # and wrap them with rustBufferToC() + # Patterns: FfiConverterXxxINSTANCE.Lower(xxx) or FfiConverterXxx{}.Lower(xxx) + line = re.sub( + r'(FfiConverter\w+(?:INSTANCE|{}))\.Lower\(([^)]+)\)', + lambda m: f'rustBufferToC({m.group(1)}.Lower({m.group(2)}))', + line + ) + # Also handle FfiConverterBytes specifically + line = re.sub( + r'(FfiConverterBytesINSTANCE)\.Lower\(([^)]+)\)', + lambda m: f'rustBufferToC({m.group(1)}.Lower({m.group(2)}))', + line + ) + + # Fix: Callback return values + if 'outBuf *C.RustBuffer' in stripped: + pass # These are pointer params, no conversion needed + + # Fix: FfiConverterCallbackInterface register/lower/etc that uses RustBuffer + if 'func() C.RustBuffer {' in stripped and 'rustCall' not in stripped: + pass # These are fine as they produce C.RustBuffer directly + + result.append(line) + +content = '\n'.join(result) + +# Also need to handle: in rustCallWithError[RustBuffer] lambdas +# that call return C.uniffi_ -- same pattern +lines = content.split('\n') +result = [] +in_error_lambda = False + +for i, line in enumerate(lines): + stripped = line.strip() + + if 'func(status *C.RustCallStatus) RustBuffer {' in stripped: + in_error_lambda = True + if in_error_lambda and stripped.startswith('return rustBufferFromC('): + pass # Already fixed + elif in_error_lambda and stripped.startswith('return C.uniffi_'): + line = line.replace('return C.uniffi_', 'return rustBufferFromC(C.uniffi_') + line = line.rstrip() + if line.endswith(')'): + line = line + ')' + if in_error_lambda and stripped == '}': + in_error_lambda = False + + result.append(line) + +content = '\n'.join(result) + +# Fix remaining: callback interface's handleMap.remove returns RustBuffer{} +# which is fine with the new struct definition + +# Fix: The FfiConverterCallbackInterface Lower method which calls +# rustCall[RustBuffer](...) but the inner function returns C.RustBuffer +# The lambda is `func(status *C.RustCallStatus) C.RustBuffer {` +# This returns C.RustBuffer to rustCall, but rustCall expects RustBuffer +# Wait -- if rustCall[RustBuffer] is generic, the lambda should return RustBuffer +# Let me check... actually in the generated code the lambda signature IS +# `func(status *C.RustCallStatus) RustBuffer {` with alias, they're the same type. +# With our change, we need these lambdas to return RustBuffer. + +# Fix callback interface Lower methods where C function returns C.RustBuffer +# in a RustBuffer context +lines = content.split('\n') +result = [] +for i, line in enumerate(lines): + # Lines like: `return C.uniffi_rustpushgo_fn_xxx(...)` + # where the enclosing func returns RustBuffer (our type) + # These should already be caught by the lambda detection above + # But let's also catch the FfiConverterCallbackInterface.Lower method + if 'handleMap.remove(handle)' in line: + pass # Returns RustBuffer{}, this is fine + + result.append(line) + +content = '\n'.join(result) + +with open(FILE, 'w') as f: + f.write(content) + +print("Python patch complete") +PYEOF "$FILE" + +echo "Patch complete!" diff --git a/scripts/reset-bridge.sh b/scripts/reset-bridge.sh new file mode 100755 index 00000000..3f45de95 --- /dev/null +++ b/scripts/reset-bridge.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Full bridge reset: delete Beeper registration, wipe ALL local state. +# You will need to re-login (2FA) after reset. +# +# Usage: make reset (run interactively — prompts for confirmation) +# +set -euo pipefail + +STATE_DIR="$HOME/.local/share/mautrix-imessage" +BRIDGE_NAME="sh-imessage" +UNAME_S=$(uname -s) +BBCTL="$STATE_DIR/bridge-manager/bbctl" + +# ── Preflight checks ──────────────────────────────────────── +if [ ! -x "$BBCTL" ]; then + echo "ERROR: bbctl not found at $BBCTL" + exit 1 +fi + +# ── Stop the bridge ────────────────────────────────────────── +echo "Stopping bridge..." +if [ "$UNAME_S" = "Darwin" ]; then + BUNDLE_ID="${1:-com.lrhodin.mautrix-imessage}" + launchctl unload "$HOME/Library/LaunchAgents/$BUNDLE_ID.plist" 2>/dev/null || true +else + systemctl --user stop mautrix-imessage 2>/dev/null || true +fi + +sleep 1 +if pgrep -f mautrix-imessage-v2 >/dev/null 2>&1; then + echo "ERROR: bridge process still running after stop" + exit 1 +fi + +# ── Delete server-side registration (cleans up Matrix rooms) ── +echo "" +echo "Deleting bridge registration from Beeper..." +echo "(Answer the confirmation prompt below)" +echo "" +"$BBCTL" delete "$BRIDGE_NAME" + +# ── Clear journal logs ─────────────────────────────────────── +echo "" +echo "Clearing bridge journal logs..." +if [ "$UNAME_S" != "Darwin" ]; then + journalctl --user --unit=mautrix-imessage --rotate 2>/dev/null || true + journalctl --user --unit=mautrix-imessage --vacuum-time=1s 2>/dev/null || true + echo "✓ Logs cleared" +else + echo " (macOS — logs managed by launchd, skipping)" +fi + +# ── Wipe EVERYTHING ───────────────────────────────────────── +echo "" +echo "Wiping all state in $STATE_DIR/ ..." +find "$STATE_DIR" -maxdepth 1 -not -name bridge-manager -not -path "$STATE_DIR" -exec rm -rf {} + + +# Verify +REMAINING=$(find "$STATE_DIR" -maxdepth 1 -not -name bridge-manager -not -path "$STATE_DIR" | wc -l) +if [ "$REMAINING" -ne 0 ]; then + echo "ERROR: state directory not fully cleaned:" + ls -la "$STATE_DIR/" + exit 1 +fi + +echo "" +echo "✓ Bridge fully reset." +echo " All state wiped — you will need to re-login (2FA)." +echo "" +echo " Run 'make install-beeper' to re-register, login, and start the bridge." diff --git a/segment.go b/segment.go deleted file mode 100644 index ec70543a..00000000 --- a/segment.go +++ /dev/null @@ -1,112 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2023 Tulir Asokan, Sumner Evans -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <https://www.gnu.org/licenses/>. - -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - - log "maunium.net/go/maulogger/v2" -) - -const SegmentURL = "https://api.segment.io/v1/track" - -type SegmentClient struct { - key string - userID string - log log.Logger - client http.Client -} - -var Segment SegmentClient - -func (sc *SegmentClient) trackSync(event string, properties map[string]any, userIDOverride string) error { - var buf bytes.Buffer - if userIDOverride == "" { - userIDOverride = sc.userID - } - err := json.NewEncoder(&buf).Encode(map[string]interface{}{ - "userId": userIDOverride, - "event": event, - "properties": properties, - }) - if err != nil { - return err - } - - req, err := http.NewRequest("POST", SegmentURL, &buf) - if err != nil { - return err - } - req.SetBasicAuth(sc.key, "") - resp, err := sc.client.Do(req) - if err != nil { - return err - } - _ = resp.Body.Close() - if resp.StatusCode != 200 { - return fmt.Errorf("unexpected status code %d", resp.StatusCode) - } - return nil -} - -func (sc *SegmentClient) IsEnabled() bool { - return len(sc.key) > 0 -} - -func (sc *SegmentClient) Track(event string, properties ...map[string]any) { - sc.TrackUser(event, "", properties...) -} - -func (sc *SegmentClient) TrackUser(event, userID string, properties ...map[string]any) { - if !sc.IsEnabled() { - return - } else if len(properties) > 1 { - panic("Track should be called with at most one property map") - } - - go func() { - props := map[string]interface{}{} - if len(properties) > 0 { - props = properties[0] - } - props["bridge"] = "imessagecloud" - err := sc.trackSync(event, props, userID) - if err != nil { - sc.log.Errorfln("Error tracking %s: %v", event, err) - } else { - sc.log.Debugln("Tracked", event) - } - }() -} - -func (br *IMBridge) initSegment() { - Segment.log = br.Log.Sub("Segment") - Segment.key = br.Config.Segment.Key - Segment.userID = br.Config.Segment.UserID - if Segment.userID == "" { - Segment.userID = br.Config.Bridge.User.String() - } - if Segment.IsEnabled() { - Segment.log.Infoln("Segment metrics are enabled") - if Segment.userID != "" { - Segment.log.Infofln("Overriding Segment user_id with %v", Segment.userID) - } - } -} diff --git a/third_party/rustpush-upstream.sha b/third_party/rustpush-upstream.sha new file mode 100644 index 00000000..9443d581 --- /dev/null +++ b/third_party/rustpush-upstream.sha @@ -0,0 +1 @@ +a7fab473e7a33325a760635285db2860de8e1cb0 diff --git a/tiff.go b/tiff.go deleted file mode 100644 index c4938486..00000000 --- a/tiff.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "fmt" - "image/jpeg" - - "golang.org/x/image/tiff" -) - -func ConvertTIFF(data []byte) ([]byte, error) { - img, err := tiff.Decode(bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("failed to decode tiff: %v", err) - } - - var output bytes.Buffer - - err = jpeg.Encode(bufio.NewWriter(&output), img, nil) - if err != nil { - return nil, fmt.Errorf("failed to encode tiff to jpeg: %v", err) - } - - return output.Bytes(), nil -} diff --git a/tools/extract-key-app/Package.swift b/tools/extract-key-app/Package.swift new file mode 100644 index 00000000..566af905 --- /dev/null +++ b/tools/extract-key-app/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 +import PackageDescription +import Foundation + +// Resolve the library path relative to Package.swift's location. +// build.sh places libxnu_encrypt.a in .build/lib/ before invoking swift build. +let packageDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path +let libDir = packageDir + "/.build/lib" + +let package = Package( + name: "ExtractKeyApp", + platforms: [ + .macOS(.v10_15) + ], + targets: [ + .target( + name: "CEncrypt", + path: "Sources/CEncrypt", + publicHeadersPath: "include" + ), + .executableTarget( + name: "ExtractKeyApp", + dependencies: ["CEncrypt"], + path: "Sources/ExtractKeyApp", + linkerSettings: [ + .linkedFramework("IOKit"), + .linkedFramework("DiskArbitration"), + .unsafeFlags(["-L\(libDir)", "-lxnu_encrypt"]), + ] + ) + ] +) diff --git a/tools/extract-key-app/Sources/CEncrypt/include/xnu_encrypt.h b/tools/extract-key-app/Sources/CEncrypt/include/xnu_encrypt.h new file mode 100644 index 00000000..e1136742 --- /dev/null +++ b/tools/extract-key-app/Sources/CEncrypt/include/xnu_encrypt.h @@ -0,0 +1,11 @@ +#ifndef XNU_ENCRYPT_H +#define XNU_ENCRYPT_H + +#include <stdint.h> + +/// Encrypt a plaintext IOKit property value using the XNU kernel function. +/// Writes exactly 17 bytes to `output`. +/// Extracted from the macOS XNU kernel (open-absinthe). +void sub_ffffff8000ec7320(const uint8_t *data, uint64_t size, uint8_t *output); + +#endif diff --git a/tools/extract-key-app/Sources/CEncrypt/shim.c b/tools/extract-key-app/Sources/CEncrypt/shim.c new file mode 100644 index 00000000..18c196d5 --- /dev/null +++ b/tools/extract-key-app/Sources/CEncrypt/shim.c @@ -0,0 +1,2 @@ +// Empty shim — SPM requires at least one .c file in a C target. +// The actual implementation comes from libxnu_encrypt.a (assembled from encrypt.s). diff --git a/tools/extract-key-app/Sources/ExtractKeyApp/Compat.swift b/tools/extract-key-app/Sources/ExtractKeyApp/Compat.swift new file mode 100644 index 00000000..92dad57e --- /dev/null +++ b/tools/extract-key-app/Sources/ExtractKeyApp/Compat.swift @@ -0,0 +1,15 @@ +import SwiftUI + +/// SF Symbols require macOS 11+. On 10.15, fall back to a text label. +struct SymbolOrText: View { + let systemName: String + let fallback: String + + var body: some View { + if #available(macOS 11.0, *) { + Image(systemName: systemName) + } else { + Text(fallback) + } + } +} diff --git a/tools/extract-key-app/Sources/ExtractKeyApp/ContentView.swift b/tools/extract-key-app/Sources/ExtractKeyApp/ContentView.swift new file mode 100644 index 00000000..3c7fa0a0 --- /dev/null +++ b/tools/extract-key-app/Sources/ExtractKeyApp/ContentView.swift @@ -0,0 +1,324 @@ +import SwiftUI +#if canImport(AppKit) +import AppKit +#endif +#if canImport(UniformTypeIdentifiers) +import UniformTypeIdentifiers +#endif + +struct ContentView: View { + @ObservedObject var extractor: HardwareExtractor + @State private var copied = false + @State private var saved = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + headerSection + + if isRunningOnAppleSilicon() { + appleSiliconWarning + } + + if extractor.isExtracting { + loadingSection + } else if let error = extractor.errorMessage { + errorSection(error) + } else if let result = extractor.result { + hardwareInfoSection(result) + if !result.hasEncFields && !result.isAppleSilicon { + enrichSection + } + if !result.warnings.isEmpty { + warningsSection(result.warnings) + } + keyOutputSection(result.base64Key) + actionsSection(result.base64Key) + } + + Spacer() + } + .padding(24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + extractor.extract() + } + } + + // MARK: - Header + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Hardware Key Extractor") + .font(.title) + .fontWeight(.bold) + Text("Reads hardware identifiers for the iMessage bridge.") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + // MARK: - Apple Silicon Warning + + private var appleSiliconWarning: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + SymbolOrText(systemName: "exclamationmark.triangle.fill", fallback: "⚠") + .foregroundColor(.orange) + Text("Apple Silicon Mac Detected") + .fontWeight(.semibold) + } + Text("This tool is designed for Intel Macs. On Apple Silicon, the extracted key will be missing encrypted fields required by Apple. Use the NAC relay approach instead.") + .font(.callout) + .foregroundColor(.secondary) + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(8) + } + + // MARK: - Enrich + + private var enrichSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + SymbolOrText(systemName: "lock.shield", fallback: "🔒") + .foregroundColor(.blue) + Text("Encrypted Fields Missing") + .fontWeight(.semibold) + } + Text("This Mac's IOKit doesn't expose encrypted hardware properties (_enc fields). " + + "You can compute them now to produce a complete key that looks identical to a newer Mac.") + .font(.callout) + .foregroundColor(.secondary) + Button(action: { extractor.enrich() }) { + HStack(spacing: 4) { + SymbolOrText(systemName: "wand.and.stars", fallback: "✦") + Text("Enrich Key") + } + } + .padding(.top, 2) + } + .padding() + .background(Color.blue.opacity(0.08)) + .cornerRadius(8) + } + + // MARK: - Loading + + private var loadingSection: some View { + HStack(spacing: 12) { + Text("⏳") + Text("Reading hardware identifiers...") + .foregroundColor(.secondary) + } + .padding(.vertical, 20) + } + + // MARK: - Error + + private func errorSection(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + SymbolOrText(systemName: "xmark.circle.fill", fallback: "✗") + .foregroundColor(.red) + Text("Extraction Failed") + .fontWeight(.semibold) + } + Text(message) + .font(.callout) + .foregroundColor(.secondary) + Button("Retry") { + extractor.extract() + } + } + .padding() + .background(Color.red.opacity(0.08)) + .cornerRadius(8) + } + + // MARK: - Hardware Info + + private func hardwareInfoSection(_ result: ExtractionResult) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Hardware Info") + .font(.headline) + + let hw = result.config.inner + infoGrid([ + ("Model", hw.productName), + ("Serial", hw.platformSerialNumber), + ("Build", "\(hw.osBuildNum) (\(result.config.version))"), + ("UUID", hw.platformUUID), + ("Board ID", hw.boardID), + ("MLB", hw.mlb), + ("ROM", "\(hw.rom.count) bytes"), + ("MAC", formatMAC(hw.ioMacAddress)), + ("Disk UUID", hw.rootDiskUUID), + ("Chip", chipDescription(result)), + ("Enc Fields", encFieldsDescription(result)), + ]) + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(8) + } + + private func infoGrid(_ items: [(String, String)]) -> some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(items, id: \.0) { label, value in + HStack(alignment: .top, spacing: 0) { + Text(label) + .fontWeight(.medium) + .frame(width: 90, alignment: .trailing) + Text(" ") + Text(value.isEmpty ? "(empty)" : value) + .foregroundColor(value.isEmpty ? .red : .primary) + .font(.system(.body, design: .monospaced)) + } + .font(.callout) + } + } + } + + private func formatMAC(_ mac: ByteArray) -> String { + guard mac.count == 6 else { return "(missing)" } + return mac.bytes.map { String(format: "%02x", $0) }.joined(separator: ":") + } + + private func chipDescription(_ result: ExtractionResult) -> String { + if result.isAppleSilicon { + return "Apple Silicon" + } else if result.hasEncFields { + return "Intel (has _enc fields)" + } else { + return "Intel (no _enc fields)" + } + } + + private func encFieldsDescription(_ result: ExtractionResult) -> String { + let hw = result.config.inner + let fields = [ + hw.platformSerialNumberEnc, + hw.platformUUIDEnc, + hw.rootDiskUUIDEnc, + hw.romEnc, + hw.mlbEnc, + ] + let present = fields.filter { !$0.isEmpty }.count + return "\(present)/5 present" + } + + // MARK: - Warnings + + private func warningsSection(_ warnings: [String]) -> some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(warnings, id: \.self) { warning in + HStack(alignment: .top, spacing: 6) { + SymbolOrText(systemName: "exclamationmark.triangle.fill", fallback: "⚠") + .foregroundColor(.yellow) + .font(.callout) + Text(warning) + .font(.callout) + .foregroundColor(.secondary) + } + } + } + .padding() + .background(Color.yellow.opacity(0.08)) + .cornerRadius(8) + } + + // MARK: - Key Output + + private func keyOutputSection(_ key: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Hardware Key") + .font(.headline) + Text("Paste this into the bridge login flow.") + .font(.caption) + .foregroundColor(.secondary) + + ScrollView(.vertical) { + Text(key) + .font(.system(size: 11, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .frame(height: 80) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + } + + // MARK: - Actions + + private func actionsSection(_ key: String) -> some View { + HStack(spacing: 12) { + Button(action: { copyToClipboard(key) }) { + HStack(spacing: 4) { + Text(copied ? "✓ Copied" : "Copy to Clipboard") + } + } + + Button(action: { saveToFile(key) }) { + HStack(spacing: 4) { + Text(saved ? "✓ Saved" : "Save to File") + } + } + + Spacer() + + Button(action: { + copied = false + saved = false + extractor.extract() + }) { + HStack(spacing: 4) { + Text("Re-extract") + } + } + } + .padding(.top, 4) + } + + // MARK: - Clipboard & File + + private func copyToClipboard(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copied = false + } + } + + private func saveToFile(_ text: String) { + let panel = NSSavePanel() + panel.title = "Save Hardware Key" + panel.nameFieldStringValue = "hardware-key.txt" + if #available(macOS 12.0, *) { + panel.allowedContentTypes = [.plainText] + } else { + panel.allowedFileTypes = ["txt"] + } + panel.begin { response in + if response == .OK, let url = panel.url { + do { + try text.write(to: url, atomically: true, encoding: .utf8) + saved = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + saved = false + } + } catch { + // Best-effort save + } + } + } + } +} diff --git a/tools/extract-key-app/Sources/ExtractKeyApp/Enrichment.swift b/tools/extract-key-app/Sources/ExtractKeyApp/Enrichment.swift new file mode 100644 index 00000000..c3b33868 --- /dev/null +++ b/tools/extract-key-app/Sources/ExtractKeyApp/Enrichment.swift @@ -0,0 +1,60 @@ +import Foundation +import CEncrypt + +/// Encrypt a plaintext IOKit property value using the XNU kernel function. +/// Returns 17 bytes of encrypted output. +func encryptIOProperty(_ data: [UInt8]) -> [UInt8] { + var output = [UInt8](repeating: 0, count: 17) + data.withUnsafeBufferPointer { buf in + sub_ffffff8000ec7320(buf.baseAddress, UInt64(buf.count), &output) + } + return output +} + +/// Parse a UUID string like "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" into 16 raw bytes. +func uuidStringToBytes(_ uuid: String) -> [UInt8]? { + let hex = uuid.replacingOccurrences(of: "-", with: "") + guard hex.count == 32 else { return nil } + var bytes = [UInt8]() + var i = hex.startIndex + while i < hex.endIndex { + let end = hex.index(i, offsetBy: 2) + guard let byte = UInt8(hex[i..<end], radix: 16) else { return nil } + bytes.append(byte) + i = end + } + return bytes +} + +/// Enrich a HardwareConfig by computing any missing _enc fields from plaintext values. +/// Uses the XNU kernel encryption function linked from encrypt.s. +func enrichMissingEncFields(_ hw: inout HardwareConfig) { + // platform_serial_number → Gq3489ugfi + if hw.platformSerialNumberEnc.isEmpty && !hw.platformSerialNumber.isEmpty { + hw.platformSerialNumberEnc = ByteArray(encryptIOProperty(Array(hw.platformSerialNumber.utf8))) + } + + // platform_uuid → Fyp98tpgj (UUID as 16 raw bytes) + if hw.platformUUIDEnc.isEmpty && !hw.platformUUID.isEmpty { + if let uuidBytes = uuidStringToBytes(hw.platformUUID) { + hw.platformUUIDEnc = ByteArray(encryptIOProperty(uuidBytes)) + } + } + + // root_disk_uuid → kbjfrfpoJU (UUID as 16 raw bytes) + if hw.rootDiskUUIDEnc.isEmpty && !hw.rootDiskUUID.isEmpty { + if let uuidBytes = uuidStringToBytes(hw.rootDiskUUID) { + hw.rootDiskUUIDEnc = ByteArray(encryptIOProperty(uuidBytes)) + } + } + + // mlb → abKPld1EcMni + if hw.mlbEnc.isEmpty && !hw.mlb.isEmpty { + hw.mlbEnc = ByteArray(encryptIOProperty(Array(hw.mlb.utf8))) + } + + // rom → oycqAZloTNDm (6 raw bytes) + if hw.romEnc.isEmpty && !hw.rom.isEmpty { + hw.romEnc = ByteArray(encryptIOProperty(hw.rom.bytes)) + } +} diff --git a/tools/extract-key-app/Sources/ExtractKeyApp/ExtractKeyApp.swift b/tools/extract-key-app/Sources/ExtractKeyApp/ExtractKeyApp.swift new file mode 100644 index 00000000..d9783fd6 --- /dev/null +++ b/tools/extract-key-app/Sources/ExtractKeyApp/ExtractKeyApp.swift @@ -0,0 +1,33 @@ +import SwiftUI +import AppKit + +@main +class AppDelegate: NSObject, NSApplicationDelegate { + var window: NSWindow! + let extractor = HardwareExtractor() + + func applicationDidFinishLaunching(_ notification: Notification) { + let contentView = ContentView(extractor: extractor) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 850), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "Hardware Key Extractor" + window.contentView = NSHostingView(rootView: contentView) + window.center() + window.makeKeyAndOrderFront(nil) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + static func main() { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() + } +} diff --git a/tools/extract-key-app/Sources/ExtractKeyApp/HardwareExtractor.swift b/tools/extract-key-app/Sources/ExtractKeyApp/HardwareExtractor.swift new file mode 100644 index 00000000..4bb4a215 --- /dev/null +++ b/tools/extract-key-app/Sources/ExtractKeyApp/HardwareExtractor.swift @@ -0,0 +1,217 @@ +import Foundation +import IOKit +import Combine + +/// Reads hardware identifiers from IOKit and produces a base64-encoded +/// hardware key matching the format expected by the iMessage bridge. +/// +/// This is a pure-Swift port of tools/extract-key/main.go. +class HardwareExtractor: ObservableObject { + @Published var result: ExtractionResult? + @Published var isExtracting = false + @Published var errorMessage: String? + + func extract() { + isExtracting = true + errorMessage = nil + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + do { + let result = try Self.performExtraction() + DispatchQueue.main.async { + self?.result = result + self?.isExtracting = false + } + } catch { + DispatchQueue.main.async { + self?.errorMessage = error.localizedDescription + self?.isExtracting = false + } + } + } + } + + /// Enrich the current result by computing missing _enc fields. + func enrich() { + guard var result = result else { return } + var hw = result.config.inner + enrichMissingEncFields(&hw) + result.config.inner = hw + result.hasEncFields = !hw.platformSerialNumberEnc.isEmpty + + // Regenerate base64 key + let jsonString = result.config.toOrderedJSON() + let jsonData = Data(jsonString.utf8) + result.base64Key = jsonData.base64EncodedString() + + // Update warnings + result.warnings = result.warnings.filter { + !$0.contains("Enrich Key") + } + + self.result = result + } + + // MARK: - Core extraction logic + + static func performExtraction() throws -> ExtractionResult { + let nvramPrefix = "4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14" + var warnings: [String] = [] + + // 1. Open IOPlatformExpertDevice + // Use 0 (MACH_PORT_NULL) for compatibility back to macOS 10.15 + guard let matching = IOServiceMatching("IOPlatformExpertDevice") else { + throw ExtractionError.noIOPlatformExpertDevice + } + let platform = IOServiceGetMatchingService(0, matching) + guard platform != IO_OBJECT_NULL else { + throw ExtractionError.noIOPlatformExpertDevice + } + defer { IOObjectRelease(platform) } + + // 2. Plain-text identifiers + let serial = ioString(platform, "IOPlatformSerialNumber") ?? "" + let platformUUID = ioString(platform, "IOPlatformUUID") ?? "" + var boardID = ioStringOrData(platform, "board-id") + var productName = ioStringOrData(platform, "product-name") + + // Fallback: hw.model sysctl + if productName == nil { + productName = sysctlString("hw.model") + } + + // 3. ROM and MLB from NVRAM + var rom = ioData(platform, "\(nvramPrefix):ROM") + var mlb: String? = ioStringOrData(platform, "\(nvramPrefix):MLB") + + // 4. Encrypted IOKit properties (present on Intel Macs) + let serialEnc = ioData(platform, "Gq3489ugfi") + let uuidEnc = ioData(platform, "Fyp98tpgj") + let diskUUIDEnc = ioData(platform, "kbjfrfpoJU") + let romEnc = ioData(platform, "oycqAZloTNDm") + let mlbEnc = ioData(platform, "abKPld1EcMni") + + // 5. Detect Apple Silicon at runtime (the binary is x86_64 but may + // run under Rosetta on Apple Silicon hardware). + let isAppleSilicon = isRunningOnAppleSilicon() + + if isAppleSilicon { + // ROM: derive from en0 MAC address on Apple Silicon + if rom == nil || rom?.isEmpty == true { + rom = getEN0MACAddress() + } + } + + // 6. Apple Silicon: MLB from mlb-serial-number + if mlb == nil { + if let data = ioData(platform, "mlb-serial-number"), !data.isEmpty { + var trimmed = Array(data) + while trimmed.last == 0 { trimmed.removeLast() } + if !trimmed.isEmpty { + mlb = String(bytes: trimmed, encoding: .utf8) + } + } + } + + // 7. Fallback: IODeviceTree:/options + if mlb == nil || rom == nil || rom?.isEmpty == true { + let options = IORegistryEntryFromPath(0, "IODeviceTree:/options") + if options != IO_OBJECT_NULL { + defer { IOObjectRelease(options) } + if mlb == nil { + mlb = ioStringOrData(options, "\(nvramPrefix):MLB") + } + if rom == nil || rom?.isEmpty == true { + rom = ioData(options, "\(nvramPrefix):ROM") + } + } + } + + // 8. Fallback: nvram CLI + if rom == nil || rom?.isEmpty == true { + rom = nvramROM() + } + if mlb == nil { + mlb = nvramMLB() + } + + // 9. Other system info + let osBuild = sysctlString("kern.osversion") ?? "unknown" + let rootDiskUUID = getRootDiskUUID() + let macAddress = getEN0MACAddress() + + // On Apple Silicon, board-id may be empty; use product name as fallback. + if boardID == nil || boardID?.isEmpty == true { + boardID = productName + } + + let macOSVersion = getMacOSVersion() + let darwinVersion = getDarwinVersion() + + // 10. Validate critical fields + if serial.isEmpty { warnings.append("serial_number") } + if platformUUID.isEmpty { warnings.append("platform_uuid") } + if rom == nil || rom?.isEmpty == true { warnings.append("ROM") } + if mlb == nil || mlb?.isEmpty == true { warnings.append("MLB") } + if macAddress == nil || macAddress?.count != 6 { warnings.append("mac_address") } + + let hasEncFields = serialEnc != nil && !(serialEnc?.isEmpty ?? true) + + if isAppleSilicon { + warnings.append( + "Apple Silicon detected \u{2014} encrypted IOKit properties are absent. " + + "You must run the NAC relay on this Mac and use -relay when extracting." + ) + } else if !hasEncFields { + warnings.append( + "Encrypted IOKit properties (_enc fields) not present in IOKit on this Mac. " + + "Click \"Enrich Key\" to compute them." + ) + } + + // 11. Build structs + let hw = HardwareConfig( + productName: productName ?? "", + ioMacAddress: ByteArray(macAddress.map { Array($0) } ?? []), + platformSerialNumber: serial, + platformUUID: platformUUID, + rootDiskUUID: rootDiskUUID, + boardID: boardID ?? "", + osBuildNum: osBuild, + platformSerialNumberEnc: ByteArray(serialEnc.map { Array($0) } ?? []), + platformUUIDEnc: ByteArray(uuidEnc.map { Array($0) } ?? []), + rootDiskUUIDEnc: ByteArray(diskUUIDEnc.map { Array($0) } ?? []), + rom: ByteArray(rom.map { Array($0) } ?? []), + romEnc: ByteArray(romEnc.map { Array($0) } ?? []), + mlb: mlb ?? "", + mlbEnc: ByteArray(mlbEnc.map { Array($0) } ?? []) + ) + + let icloudUA = "com.apple.iCloudHelper/282 CFNetwork/1568.100.1 Darwin/\(darwinVersion)" + + let config = MacOSConfig( + inner: hw, + version: macOSVersion, + protocolVersion: 1660, + deviceID: platformUUID.uppercased(), + icloudUA: icloudUA, + aoskitVersion: "com.apple.AOSKit/282 (com.apple.accountsd/113)" + ) + + // 12. JSON → base64 + // Use manual ordered JSON serialization instead of JSONEncoder, + // because JSONEncoder's keyed container uses NSDictionary internally + // which does not preserve key insertion order. + let jsonString = config.toOrderedJSON() + let jsonData = Data(jsonString.utf8) + let base64Key = jsonData.base64EncodedString() + + return ExtractionResult( + config: config, + base64Key: base64Key, + warnings: warnings, + isAppleSilicon: isAppleSilicon, + hasEncFields: hasEncFields + ) + } +} diff --git a/tools/extract-key-app/Sources/ExtractKeyApp/IOKitHelpers.swift b/tools/extract-key-app/Sources/ExtractKeyApp/IOKitHelpers.swift new file mode 100644 index 00000000..09f6e80b --- /dev/null +++ b/tools/extract-key-app/Sources/ExtractKeyApp/IOKitHelpers.swift @@ -0,0 +1,43 @@ +import Foundation +import IOKit + +/// Read a string property from an IOKit registry entry. +func ioString(_ entry: io_service_t, _ key: String) -> String? { + guard let ref = IORegistryEntryCreateCFProperty( + entry, key as CFString, kCFAllocatorDefault, 0 + ) else { return nil } + + let value = ref.takeRetainedValue() + if CFGetTypeID(value) == CFStringGetTypeID() { + return (value as! CFString) as String + } + return nil +} + +/// Read a data property from an IOKit registry entry. +func ioData(_ entry: io_service_t, _ key: String) -> Data? { + guard let ref = IORegistryEntryCreateCFProperty( + entry, key as CFString, kCFAllocatorDefault, 0 + ) else { return nil } + + let value = ref.takeRetainedValue() + if CFGetTypeID(value) == CFDataGetTypeID() { + return (value as! CFData) as Data + } + return nil +} + +/// Read a property that could be either a string or null-terminated data. +/// Tries string first, then data (stripping trailing null bytes). +func ioStringOrData(_ entry: io_service_t, _ key: String) -> String? { + if let s = ioString(entry, key) { return s } + if let data = ioData(entry, key), !data.isEmpty { + // Strip trailing null bytes + var trimmed = data + while let last = trimmed.last, last == 0 { + trimmed = trimmed.dropLast() + } + return String(data: Data(trimmed), encoding: .utf8) + } + return nil +} diff --git a/tools/extract-key-app/Sources/ExtractKeyApp/Models.swift b/tools/extract-key-app/Sources/ExtractKeyApp/Models.swift new file mode 100644 index 00000000..49eaa424 --- /dev/null +++ b/tools/extract-key-app/Sources/ExtractKeyApp/Models.swift @@ -0,0 +1,196 @@ +import Foundation + +// MARK: - ByteArray + +/// A wrapper around [UInt8] that encodes to/from a JSON array of integers +/// (e.g. [10, 20, 30]) matching Rust's serde serialize_bytes format. +/// Empty values serialize as [] (not null). +struct ByteArray: Codable, Equatable { + var bytes: [UInt8] + + init(_ bytes: [UInt8] = []) { + self.bytes = bytes + } + + init(_ data: Data) { + self.bytes = Array(data) + } + + var isEmpty: Bool { bytes.isEmpty } + var count: Int { bytes.count } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + for byte in bytes { + try container.encode(Int(byte)) + } + } + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + var result: [UInt8] = [] + while !container.isAtEnd { + let val = try container.decode(UInt8.self) + result.append(val) + } + self.bytes = result + } + + /// Hex representation for display. + var hexString: String { + bytes.map { String(format: "%02x", $0) }.joined(separator: ":") + } + + /// JSON array string like [10,20,30] — matches Go/Rust output. + var jsonArray: String { + "[\(bytes.map { String($0) }.joined(separator: ","))]" + } +} + +// MARK: - HardwareConfig + +/// Matches rustpush/open-absinthe/src/nac.rs HardwareConfig exactly. +struct HardwareConfig: Codable { + var productName: String + var ioMacAddress: ByteArray + var platformSerialNumber: String + var platformUUID: String + var rootDiskUUID: String + var boardID: String + var osBuildNum: String + var platformSerialNumberEnc: ByteArray + var platformUUIDEnc: ByteArray + var rootDiskUUIDEnc: ByteArray + var rom: ByteArray + var romEnc: ByteArray + var mlb: String + var mlbEnc: ByteArray + + enum CodingKeys: String, CodingKey { + case productName = "product_name" + case ioMacAddress = "io_mac_address" + case platformSerialNumber = "platform_serial_number" + case platformUUID = "platform_uuid" + case rootDiskUUID = "root_disk_uuid" + case boardID = "board_id" + case osBuildNum = "os_build_num" + case platformSerialNumberEnc = "platform_serial_number_enc" + case platformUUIDEnc = "platform_uuid_enc" + case rootDiskUUIDEnc = "root_disk_uuid_enc" + case rom + case romEnc = "rom_enc" + case mlb + case mlbEnc = "mlb_enc" + } +} + +// MARK: - MacOSConfig + +/// Matches rustpush/src/macos.rs MacOSConfig. +struct MacOSConfig: Codable { + var inner: HardwareConfig + var version: String + var protocolVersion: UInt32 + var deviceID: String + var icloudUA: String + var aoskitVersion: String + + enum CodingKeys: String, CodingKey { + case inner + case version + case protocolVersion = "protocol_version" + case deviceID = "device_id" + case icloudUA = "icloud_ua" + case aoskitVersion = "aoskit_version" + } +} + +// MARK: - Ordered JSON Serialization + +/// Escape a string for JSON output (handles quotes, backslashes, control chars). +/// Does NOT escape forward slashes (matching Go's json.Marshal). +private func jsonEscape(_ s: String) -> String { + var result = "" + for c in s { + switch c { + case "\"": result += "\\\"" + case "\\": result += "\\\\" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + default: + if c.asciiValue != nil && c.asciiValue! < 0x20 { + result += String(format: "\\u%04x", c.asciiValue!) + } else { + result.append(c) + } + } + } + return result +} + +extension HardwareConfig { + /// Serialize to JSON with key order matching the Go extract-key tool output. + /// This exact ordering is important — Apple's servers are sensitive to it. + func toOrderedJSON() -> String { + let pairs: [(String, String)] = [ + ("root_disk_uuid", "\"\(jsonEscape(rootDiskUUID))\""), + ("mlb", "\"\(jsonEscape(mlb))\""), + ("product_name", "\"\(jsonEscape(productName))\""), + ("platform_uuid_enc", platformUUIDEnc.jsonArray), + ("rom", rom.jsonArray), + ("platform_serial_number", "\"\(jsonEscape(platformSerialNumber))\""), + ("io_mac_address", ioMacAddress.jsonArray), + ("platform_uuid", "\"\(jsonEscape(platformUUID))\""), + ("os_build_num", "\"\(jsonEscape(osBuildNum))\""), + ("platform_serial_number_enc", platformSerialNumberEnc.jsonArray), + ("board_id", "\"\(jsonEscape(boardID))\""), + ("root_disk_uuid_enc", rootDiskUUIDEnc.jsonArray), + ("mlb_enc", mlbEnc.jsonArray), + ("rom_enc", romEnc.jsonArray), + ] + let body = pairs.map { "\"\($0.0)\":\($0.1)" }.joined(separator: ",") + return "{\(body)}" + } +} + +extension MacOSConfig { + /// Serialize to JSON with key order matching the Go extract-key tool output. + /// This exact ordering is important — Apple's servers are sensitive to it. + func toOrderedJSON() -> String { + let pairs: [(String, String)] = [ + ("aoskit_version", "\"\(jsonEscape(aoskitVersion))\""), + ("inner", inner.toOrderedJSON()), + ("protocol_version", "\(protocolVersion)"), + ("device_id", "\"\(jsonEscape(deviceID))\""), + ("icloud_ua", "\"\(jsonEscape(icloudUA))\""), + ("version", "\"\(jsonEscape(version))\""), + ] + let body = pairs.map { "\"\($0.0)\":\($0.1)" }.joined(separator: ",") + return "{\(body)}" + } +} + +// MARK: - ExtractionResult + +/// Result returned by the hardware extractor for the UI. +struct ExtractionResult { + var config: MacOSConfig + var base64Key: String + var warnings: [String] + var isAppleSilicon: Bool + var hasEncFields: Bool +} + +// MARK: - ExtractionError + +enum ExtractionError: Error, LocalizedError { + case noIOPlatformExpertDevice + + var errorDescription: String? { + switch self { + case .noIOPlatformExpertDevice: + return "Failed to find IOPlatformExpertDevice in IOKit registry" + } + } +} diff --git a/tools/extract-key-app/Sources/ExtractKeyApp/SystemInfo.swift b/tools/extract-key-app/Sources/ExtractKeyApp/SystemInfo.swift new file mode 100644 index 00000000..9c02851d --- /dev/null +++ b/tools/extract-key-app/Sources/ExtractKeyApp/SystemInfo.swift @@ -0,0 +1,183 @@ +import Foundation +import DiskArbitration + +// MARK: - MAC Address + +/// Get the en0 MAC address (6 bytes) via getifaddrs. +func getEN0MACAddress() -> Data? { + var ifaddrsPtr: UnsafeMutablePointer<ifaddrs>? + guard getifaddrs(&ifaddrsPtr) == 0, let firstAddr = ifaddrsPtr else { return nil } + defer { freeifaddrs(firstAddr) } + + var cursor: UnsafeMutablePointer<ifaddrs>? = firstAddr + while let ifa = cursor { + defer { cursor = ifa.pointee.ifa_next } + let name = String(cString: ifa.pointee.ifa_name) + guard name == "en0", + let addr = ifa.pointee.ifa_addr, + addr.pointee.sa_family == UInt8(AF_LINK) else { continue } + + return addr.withMemoryRebound(to: sockaddr_dl.self, capacity: 1) { sdl in + guard sdl.pointee.sdl_alen == 6 else { return nil } + // Link-layer address starts at sdl_data + sdl_nlen + return withUnsafePointer(to: sdl.pointee.sdl_data) { dataPtr in + let rawPtr = UnsafeRawPointer(dataPtr) + .advanced(by: Int(sdl.pointee.sdl_nlen)) + return Data(bytes: rawPtr, count: 6) + } + } + } + return nil +} + +// MARK: - Root Disk UUID + +/// Get the UUID of the root filesystem volume via DiskArbitration. +func getRootDiskUUID() -> String { + guard let session = DASessionCreate(kCFAllocatorDefault) else { return "unknown" } + + var sfs = statfs() + guard statfs("/", &sfs) == 0 else { return "unknown" } + + let bsdName: String = withUnsafePointer(to: &sfs.f_mntfromname) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { + String(cString: $0) + } + } + + guard let disk = DADiskCreateFromBSDName(kCFAllocatorDefault, session, bsdName) else { + return "unknown" + } + guard let desc = DADiskCopyDescription(disk) as? [CFString: Any] else { + return "unknown" + } + + // kDADiskDescriptionVolumeUUIDKey returns a CFUUID + let key = kDADiskDescriptionVolumeUUIDKey as String + for (k, v) in desc { + if (k as String) == key { + let uuid = v as! CFUUID + if let str = CFUUIDCreateString(kCFAllocatorDefault, uuid) { + return str as String + } + } + } + return "unknown" +} + +// MARK: - Architecture Detection + +/// Detect Apple Silicon at runtime. Works even when running an x86_64 +/// binary under Rosetta on Apple Silicon hardware. +func isRunningOnAppleSilicon() -> Bool { + // sysctl "sysctl.proc_translated" returns 1 when running under Rosetta. + var ret: Int32 = 0 + var size = MemoryLayout<Int32>.size + if sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0) == 0 && ret == 1 { + return true // x86_64 binary running under Rosetta on Apple Silicon + } + // Native arm64 binary on Apple Silicon + #if arch(arm64) + return true + #else + return false + #endif +} + +// MARK: - Sysctl + +/// Read a sysctl string value by name. +func sysctlString(_ name: String) -> String? { + var size: Int = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buf = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buf, &size, nil, 0) == 0 else { return nil } + return String(cString: buf) +} + +// MARK: - Process Helpers + +/// Run a subprocess and return its stdout as a string. +func runProcess(_ path: String, args: [String]) -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: path) + proc.arguments = args + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = FileHandle.nullDevice + do { + try proc.run() + proc.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } catch { + return nil + } +} + +// MARK: - NVRAM Fallbacks + +/// Decode nvram percent-encoded binary output (e.g. "%1e%eb%08n%ae"). +private func decodeNVRAMPercent(_ input: String) -> Data? { + var bytes: [UInt8] = [] + var i = input.startIndex + while i < input.endIndex { + if input[i] == "%" { + let hexStart = input.index(after: i) + guard hexStart < input.endIndex else { break } + let hexEnd = input.index(hexStart, offsetBy: 2, limitedBy: input.endIndex) + ?? input.endIndex + let hex = String(input[hexStart..<hexEnd]) + if let byte = UInt8(hex, radix: 16) { + bytes.append(byte) + } + i = hexEnd + } else { + bytes.append(UInt8(input[i].asciiValue ?? 0)) + i = input.index(after: i) + } + } + return bytes.isEmpty ? nil : Data(bytes) +} + +/// Read ROM from nvram CLI (fallback when IOKit doesn't expose it). +func nvramROM() -> Data? { + guard let output = runProcess( + "/usr/sbin/nvram", + args: ["4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:ROM"] + ) else { return nil } + guard let tabIdx = output.firstIndex(of: "\t") else { return nil } + let value = String(output[output.index(after: tabIdx)...]) + .trimmingCharacters(in: .newlines) + return decodeNVRAMPercent(value) +} + +/// Read MLB from nvram CLI (plain text after tab). +func nvramMLB() -> String? { + guard let output = runProcess( + "/usr/sbin/nvram", + args: ["4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:MLB"] + ) else { return nil } + guard let tabIdx = output.firstIndex(of: "\t") else { return nil } + let value = String(output[output.index(after: tabIdx)...]) + .trimmingCharacters(in: .newlines) + return value.isEmpty ? nil : value +} + +// MARK: - Version Info + +/// Get the macOS version string (e.g. "14.3.1"). +func getMacOSVersion() -> String { + if let output = runProcess("/usr/bin/sw_vers", args: ["-productVersion"]) { + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + return "14.0" +} + +/// Get the Darwin kernel version string (e.g. "23.3.0"). +func getDarwinVersion() -> String { + if let output = runProcess("/usr/bin/uname", args: ["-r"]) { + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + return "22.5.0" +} diff --git a/tools/extract-key-app/build.sh b/tools/extract-key-app/build.sh new file mode 100755 index 00000000..a95afcb0 --- /dev/null +++ b/tools/extract-key-app/build.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# build.sh — Build the Extract Key SwiftUI app for Intel Macs. +# +# On an Apple Silicon Mac (M1/M2/M3/M4), this cross-compiles for x86_64. +# The resulting binary runs natively on Intel Macs. +# +# Usage: +# cd tools/extract-key-app +# ./build.sh +# # Binary: .build/release/ExtractKeyApp +# # App: ExtractKey.app/ + +set -euo pipefail +cd "$(dirname "$0")" + +# 1. Assemble the XNU encrypt function for x86_64 macOS +echo "Assembling XNU encrypt function..." +ENCRYPT_S="../../rustpush/open-absinthe/src/asm/encrypt.s" +mkdir -p .build/lib + +# Patch the assembly for Mach-O: +# - Underscore prefix for global symbol (Mach-O convention) +# - Section directives (ELF → Mach-O) +# - Alignment directives (absolute → power-of-2) +sed \ + -e 's/\.global sub_ffffff8000ec7320/.globl _sub_ffffff8000ec7320/' \ + -e 's/sub_ffffff8000ec7320:/_sub_ffffff8000ec7320:/' \ + -e 's/^\.section \.data/.data/' \ + -e 's/^\.section \.text/.text/' \ + -e 's/\.align 0x100/.p2align 8/' \ + -e 's/\.align 0x10$/.p2align 4/' \ + "$ENCRYPT_S" > .build/encrypt_macos.s + +# Assemble for x86_64 (target 11.0 to match deployment target) +clang -c -arch x86_64 -mmacosx-version-min=10.15 -o .build/encrypt.o .build/encrypt_macos.s + +# Create static library +ar rcs .build/lib/libxnu_encrypt.a .build/encrypt.o +echo " Built .build/lib/libxnu_encrypt.a" + +# 2. Build the Swift app +echo "" +# Build for x86_64 regardless of host architecture +echo "Building ExtractKeyApp for x86_64 (Intel)..." +ARCH=$(uname -m) +if [ "$ARCH" = "x86_64" ]; then + swift build -c release +else + swift build -c release --arch x86_64 +fi + +BINARY=".build/release/ExtractKeyApp" +if [ ! -f "$BINARY" ]; then + # SPM may place it in an arch-specific subdirectory + BINARY=$(find .build -name ExtractKeyApp -type f -perm +111 2>/dev/null | head -1) +fi + +if [ ! -f "$BINARY" ]; then + echo "ERROR: Build succeeded but binary not found" + exit 1 +fi + +echo "" +echo "Binary: $BINARY" +file "$BINARY" + +# 3. Wrap in .app bundle +APP="ExtractKey.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" +cp "$BINARY" "$APP/Contents/MacOS/ExtractKeyApp" + +cat > "$APP/Contents/Info.plist" << 'PLIST' +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleIdentifier</key> + <string>com.lrhodin.extract-key</string> + <key>CFBundleName</key> + <string>Extract Key</string> + <key>CFBundleDisplayName</key> + <string>Hardware Key Extractor</string> + <key>CFBundleExecutable</key> + <string>ExtractKeyApp</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleVersion</key> + <string>1.0</string> + <key>CFBundleShortVersionString</key> + <string>1.0</string> + <key>LSMinimumSystemVersion</key> + <string>10.15</string> + <key>NSHighResolutionCapable</key> + <true/> +</dict> +</plist> +PLIST + +# Ad-hoc sign (required for Gatekeeper on recent macOS) +codesign --force --sign - "$APP" 2>/dev/null || true + +echo "" +echo "App bundle: $(pwd)/$APP" +echo "" +echo "To run on this Mac (under Rosetta if Apple Silicon):" +echo " open $APP" +echo "" +echo "To run on an Intel Mac:" +echo " Copy $APP to the Intel Mac and double-click it." diff --git a/tools/extract-key/build.sh b/tools/extract-key/build.sh new file mode 100755 index 00000000..f47ecef8 --- /dev/null +++ b/tools/extract-key/build.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# build.sh — Build extract-key on macOS (including High Sierra 10.13+). +# Downloads Go 1.20 if needed. No root access required. +# +# Usage: +# cd tools/extract-key +# ./build.sh +# ./extract-key + +set -euo pipefail +cd "$(dirname "$0")" + +GO_VERSION="1.20.14" +GO_TARBALL="go${GO_VERSION}.darwin-amd64.tar.gz" +GO_URL="https://go.dev/dl/${GO_TARBALL}" +LOCAL_GO="./.go-${GO_VERSION}" + +# Try system Go first +if command -v go >/dev/null 2>&1 && go version >/dev/null 2>&1; then + GO_CMD="go" + echo "Using system Go: $(go version)" +else + # Download Go locally + if [ ! -x "${LOCAL_GO}/bin/go" ]; then + echo "Go not found — downloading Go ${GO_VERSION}..." + curl -fSL -o "${GO_TARBALL}" "${GO_URL}" + mkdir -p "${LOCAL_GO}" + tar -xzf "${GO_TARBALL}" --strip-components=1 -C "${LOCAL_GO}" + rm -f "${GO_TARBALL}" + fi + GO_CMD="${LOCAL_GO}/bin/go" + echo "Using local Go: $("${GO_CMD}" version)" +fi + +echo "Building extract-key..." +CGO_ENABLED=1 "${GO_CMD}" build -trimpath -o extract-key . + +echo "" +echo "✓ Built: $(pwd)/extract-key" +echo " Run: ./extract-key" diff --git a/tools/extract-key/go.mod b/tools/extract-key/go.mod new file mode 100644 index 00000000..85fda721 --- /dev/null +++ b/tools/extract-key/go.mod @@ -0,0 +1,3 @@ +module github.com/lrhodin/imessage/tools/extract-key + +go 1.20 diff --git a/tools/extract-key/main.go b/tools/extract-key/main.go new file mode 100644 index 00000000..73ea1a21 --- /dev/null +++ b/tools/extract-key/main.go @@ -0,0 +1,658 @@ +// extract-key: Reads hardware identifiers from this Mac and outputs +// a base64-encoded JSON hardware key for the iMessage bridge. +// +// Usage: +// +// cd tools/extract-key && go run main.go +// # or +// go run tools/extract-key/main.go +// +// This only READS data from IOKit — nothing is modified on the Mac. +// The Mac can continue to be used normally, including for iMessage. +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework IOKit -framework DiskArbitration + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#import <Foundation/Foundation.h> +#import <IOKit/IOKitLib.h> +#import <DiskArbitration/DiskArbitration.h> + +// Both kIOMainPortDefault (macOS 12+) and kIOMasterPortDefault (deprecated in 12) +// are MACH_PORT_NULL. Use 0 directly for compatibility back to 10.13 High Sierra. +#define IO_PORT_DEFAULT MACH_PORT_NULL +#include <sys/sysctl.h> +#include <sys/mount.h> +#include <net/if.h> +#include <net/if_dl.h> +#include <ifaddrs.h> + +// Read a string property from an IOKit registry entry +static char* io_string(io_service_t svc, CFStringRef key) { + CFTypeRef ref = IORegistryEntryCreateCFProperty(svc, key, kCFAllocatorDefault, 0); + if (!ref) return NULL; + if (CFGetTypeID(ref) == CFStringGetTypeID()) { + char buf[256]; + if (CFStringGetCString((CFStringRef)ref, buf, sizeof(buf), kCFStringEncodingUTF8)) { + CFRelease(ref); + return strdup(buf); + } + } + CFRelease(ref); + return NULL; +} + +// Read a data property from an IOKit registry entry, return as bytes +static void io_data(io_service_t svc, CFStringRef key, unsigned char **out, int *out_len) { + *out = NULL; + *out_len = 0; + CFTypeRef ref = IORegistryEntryCreateCFProperty(svc, key, kCFAllocatorDefault, 0); + if (!ref) return; + if (CFGetTypeID(ref) == CFDataGetTypeID()) { + CFDataRef data = (CFDataRef)ref; + int len = (int)CFDataGetLength(data); + *out = (unsigned char*)malloc(len); + memcpy(*out, CFDataGetBytePtr(data), len); + *out_len = len; + } + CFRelease(ref); +} + +// Get the en0 MAC address +static void get_mac_address(unsigned char **out, int *out_len) { + *out = NULL; + *out_len = 0; + struct ifaddrs *ifas; + if (getifaddrs(&ifas) != 0) return; + for (struct ifaddrs *ifa = ifas; ifa; ifa = ifa->ifa_next) { + if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_LINK && strcmp(ifa->ifa_name, "en0") == 0) { + struct sockaddr_dl *sdl = (struct sockaddr_dl *)ifa->ifa_addr; + if (sdl->sdl_alen == 6) { + *out = (unsigned char*)malloc(6); + memcpy(*out, LLADDR(sdl), 6); + *out_len = 6; + break; + } + } + } + freeifaddrs(ifas); +} + +// Get root disk UUID +static char* get_root_disk_uuid() { + DASessionRef session = DASessionCreate(kCFAllocatorDefault); + if (!session) return strdup("unknown"); + + struct statfs sfs; + if (statfs("/", &sfs) != 0) { + CFRelease(session); + return strdup("unknown"); + } + + DADiskRef disk = DADiskCreateFromBSDName(kCFAllocatorDefault, session, sfs.f_mntfromname); + if (!disk) { + CFRelease(session); + return strdup("unknown"); + } + + CFDictionaryRef desc = DADiskCopyDescription(disk); + char *result = strdup("unknown"); + if (desc) { + CFUUIDRef uuid = CFDictionaryGetValue(desc, kDADiskDescriptionVolumeUUIDKey); + if (uuid) { + CFStringRef str = CFUUIDCreateString(kCFAllocatorDefault, uuid); + if (str) { + char buf[128]; + if (CFStringGetCString(str, buf, sizeof(buf), kCFStringEncodingUTF8)) { + free(result); + result = strdup(buf); + } + CFRelease(str); + } + } + CFRelease(desc); + } + CFRelease(disk); + CFRelease(session); + return result; +} + +struct hw_result { + // Plain-text identifiers + char *serial_number; + char *platform_uuid; + char *board_id; + char *product_name; + char *os_build_num; + char *root_disk_uuid; + char *mlb; + unsigned char *rom; + int rom_len; + unsigned char *mac_address; + int mac_address_len; + + // Encrypted/obfuscated IOKit properties (already stored by macOS) + unsigned char *serial_enc; // Gq3489ugfi + int serial_enc_len; + unsigned char *uuid_enc; // Fyp98tpgj + int uuid_enc_len; + unsigned char *disk_uuid_enc; // kbjfrfpoJU + int disk_uuid_enc_len; + unsigned char *rom_enc; // oycqAZloTNDm + int rom_enc_len; + unsigned char *mlb_enc; // abKPld1EcMni + int mlb_enc_len; + + char *error; +}; + +static struct hw_result read_hardware() { + struct hw_result r; + memset(&r, 0, sizeof(r)); + + io_service_t platform = IOServiceGetMatchingService(IO_PORT_DEFAULT, + IOServiceMatching("IOPlatformExpertDevice")); + if (!platform) { + r.error = strdup("failed to find IOPlatformExpertDevice"); + return r; + } + + // Plain-text identifiers + r.serial_number = io_string(platform, CFSTR("IOPlatformSerialNumber")); + r.platform_uuid = io_string(platform, CFSTR("IOPlatformUUID")); + + // board-id: try string first, then data (null-terminated) + r.board_id = io_string(platform, CFSTR("board-id")); + if (!r.board_id) { + unsigned char *bdata; int blen; + io_data(platform, CFSTR("board-id"), &bdata, &blen); + if (bdata && blen > 0) { + r.board_id = strndup((char*)bdata, blen); + free(bdata); + } + } + // Same for product-name + r.product_name = io_string(platform, CFSTR("product-name")); + if (!r.product_name) { + unsigned char *pdata; int plen; + io_data(platform, CFSTR("product-name"), &pdata, &plen); + if (pdata && plen > 0) { + r.product_name = strndup((char*)pdata, plen); + free(pdata); + } + } + // Fallback to hw.model for product_name + if (!r.product_name) { + char buf[64]; + size_t len = sizeof(buf); + if (sysctlbyname("hw.model", buf, &len, NULL, 0) == 0) { + r.product_name = strdup(buf); + } + } + + // ROM and MLB (EFI NVRAM) + io_data(platform, CFSTR("4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:ROM"), &r.rom, &r.rom_len); + r.mlb = io_string(platform, CFSTR("4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:MLB")); + if (!r.mlb) { + // Try as data + unsigned char *mdata; int mlen; + io_data(platform, CFSTR("4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:MLB"), &mdata, &mlen); + if (mdata && mlen > 0) { + r.mlb = strndup((char*)mdata, mlen); + free(mdata); + } + } + + // Encrypted/obfuscated properties — present on Intel Macs in IOKit. + // On Apple Silicon these don't exist in the registry. + io_data(platform, CFSTR("Gq3489ugfi"), &r.serial_enc, &r.serial_enc_len); + io_data(platform, CFSTR("Fyp98tpgj"), &r.uuid_enc, &r.uuid_enc_len); + io_data(platform, CFSTR("kbjfrfpoJU"), &r.disk_uuid_enc, &r.disk_uuid_enc_len); + io_data(platform, CFSTR("oycqAZloTNDm"), &r.rom_enc, &r.rom_enc_len); + io_data(platform, CFSTR("abKPld1EcMni"), &r.mlb_enc, &r.mlb_enc_len); + + // On Apple Silicon, ROM is not in NVRAM — derive from en0 MAC address. + // Only do this on ARM; on Intel we fall through to nvram CLI below. +#if defined(__arm64__) + if (r.rom == NULL || r.rom_len == 0) { + get_mac_address(&r.rom, &r.rom_len); + } +#endif + + // On Apple Silicon, MLB is under "mlb-serial-number" as padded data + if (!r.mlb) { + unsigned char *mdata; int mlen; + io_data(platform, CFSTR("mlb-serial-number"), &mdata, &mlen); + if (mdata && mlen > 0) { + // Strip trailing null padding + while (mlen > 0 && mdata[mlen-1] == 0) mlen--; + if (mlen > 0) { + r.mlb = strndup((char*)mdata, mlen); + } + free(mdata); + } + } + + IOObjectRelease(platform); + + // Fallback: read ROM and MLB from IODeviceTree:/options (NVRAM node). + // On macOS 10.13 High Sierra, the NVRAM GUID-prefixed properties may not + // be exposed on IOPlatformExpertDevice but are available on the options node. + if (!r.mlb || (r.rom == NULL || r.rom_len == 0)) { + io_registry_entry_t options = IORegistryEntryFromPath(IO_PORT_DEFAULT, "IODeviceTree:/options"); + if (options) { + if (!r.mlb) { + r.mlb = io_string(options, CFSTR("4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:MLB")); + if (!r.mlb) { + unsigned char *mdata; int mlen; + io_data(options, CFSTR("4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:MLB"), &mdata, &mlen); + if (mdata && mlen > 0) { + // Strip trailing null padding + while (mlen > 0 && mdata[mlen-1] == 0) mlen--; + if (mlen > 0) r.mlb = strndup((char*)mdata, mlen); + free(mdata); + } + } + } + if (r.rom == NULL || r.rom_len == 0) { + io_data(options, CFSTR("4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:ROM"), &r.rom, &r.rom_len); + } + IOObjectRelease(options); + } + } + + // Fallback: shell out to `nvram` CLI which uses a different code path + // and works on Macs where the IODeviceTree properties above aren't found + // (e.g. MacBookAir7,2). + if (r.rom == NULL || r.rom_len == 0) { + FILE *fp = popen("nvram '4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:ROM' 2>/dev/null", "r"); + if (fp) { + char buf[512]; + if (fgets(buf, sizeof(buf), fp)) { + // nvram output: "4D1EDE05-...:ROM\t<value>" + // Value is percent-encoded binary: $%1e%eb%08n%ae + char *tab = strchr(buf, '\t'); + if (tab) { + tab++; // skip tab + char *nl = strchr(tab, '\n'); + if (nl) *nl = '\0'; + // Decode percent-encoded binary + int alloc = (int)strlen(tab); + unsigned char *decoded = (unsigned char*)malloc(alloc); + int dlen = 0; + for (int i = 0; tab[i]; i++) { + if (tab[i] == '%' && tab[i+1] && tab[i+2]) { + char hex[3] = {tab[i+1], tab[i+2], 0}; + decoded[dlen++] = (unsigned char)strtol(hex, NULL, 16); + i += 2; + } else { + decoded[dlen++] = (unsigned char)tab[i]; + } + } + if (dlen > 0) { + r.rom = decoded; + r.rom_len = dlen; + } else { + free(decoded); + } + } + } + pclose(fp); + } + } + if (!r.mlb) { + FILE *fp = popen("nvram '4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:MLB' 2>/dev/null", "r"); + if (fp) { + char buf[512]; + if (fgets(buf, sizeof(buf), fp)) { + char *tab = strchr(buf, '\t'); + if (tab) { + tab++; + char *nl = strchr(tab, '\n'); + if (nl) *nl = '\0'; + if (strlen(tab) > 0) { + r.mlb = strdup(tab); + } + } + } + pclose(fp); + } + } + + // OS build number + { + char buf[64]; + size_t len = sizeof(buf); + if (sysctlbyname("kern.osversion", buf, &len, NULL, 0) == 0) + r.os_build_num = strdup(buf); + else + r.os_build_num = strdup("unknown"); + } + + // Root disk UUID + r.root_disk_uuid = get_root_disk_uuid(); + + // en0 MAC address + get_mac_address(&r.mac_address, &r.mac_address_len); + + return r; +} +*/ +import "C" + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "unsafe" +) + +// Bytes is a []byte that marshals to a JSON array of ints (matching Rust's serde +// bin_serialize/bin_deserialize) instead of Go's default base64 string. +// Empty/nil serializes as [] instead of null. +type Bytes []byte + +func (b Bytes) MarshalJSON() ([]byte, error) { + if len(b) == 0 { + return []byte("[]"), nil + } + arr := make([]int, len(b)) + for i, v := range b { + arr[i] = int(v) + } + return json.Marshal(arr) +} + +// HardwareConfig matches rustpush/open-absinthe/src/nac.rs HardwareConfig exactly +type HardwareConfig struct { + ProductName string `json:"product_name"` + IOMacAddress Bytes `json:"io_mac_address"` + PlatformSerialNumber string `json:"platform_serial_number"` + PlatformUUID string `json:"platform_uuid"` + RootDiskUUID string `json:"root_disk_uuid"` + BoardID string `json:"board_id"` + OSBuildNum string `json:"os_build_num"` + PlatformSerialNumberEnc Bytes `json:"platform_serial_number_enc"` + PlatformUUIDEnc Bytes `json:"platform_uuid_enc"` + RootDiskUUIDEnc Bytes `json:"root_disk_uuid_enc"` + ROM Bytes `json:"rom"` + ROMEnc Bytes `json:"rom_enc"` + MLB string `json:"mlb"` + MLBEnc Bytes `json:"mlb_enc"` +} + +// MacOSConfig matches rustpush/src/macos.rs MacOSConfig +type MacOSConfig struct { + Inner HardwareConfig `json:"inner"` + Version string `json:"version"` + ProtocolVersion uint32 `json:"protocol_version"` + DeviceID string `json:"device_id"` + ICloudUA string `json:"icloud_ua"` + AOSKitVersion string `json:"aoskit_version"` + NACRelayURL string `json:"nac_relay_url,omitempty"` + RelayToken string `json:"relay_token,omitempty"` + RelayCertFP string `json:"relay_cert_fp,omitempty"` +} + +// relayInfo matches the relay-info.json written by nac-relay. +type relayInfo struct { + Token string `json:"token"` + CertFingerprint string `json:"cert_fingerprint"` +} + +// readRelayInfo reads ~/Library/Application Support/nac-relay/relay-info.json. +func readRelayInfo() (*relayInfo, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + path := filepath.Join(home, "Library", "Application Support", "nac-relay", "relay-info.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var info relayInfo + if err := json.Unmarshal(data, &info); err != nil { + return nil, err + } + if info.Token == "" || info.CertFingerprint == "" { + return nil, fmt.Errorf("relay-info.json is incomplete (missing token or cert fingerprint)") + } + return &info, nil +} + +func goBytes(p *C.uchar, n C.int) []byte { + if p == nil || n <= 0 { + return nil + } + return C.GoBytes(unsafe.Pointer(p), n) +} + +func goString(p *C.char) string { + if p == nil { + return "" + } + return C.GoString(p) +} + +func getMacOSVersion() string { + out, err := exec.Command("sw_vers", "-productVersion").Output() + if err != nil { + return "14.0" + } + return strings.TrimSpace(string(out)) +} + +func getDarwinVersion() string { + out, err := exec.Command("uname", "-r").Output() + if err != nil { + return "22.5.0" + } + return strings.TrimSpace(string(out)) +} + +func main() { + if runtime.GOOS != "darwin" { + fmt.Fprintln(os.Stderr, "This tool must be run on macOS.") + os.Exit(1) + } + + relayURL := "" + for i, arg := range os.Args[1:] { + if arg == "-relay" && i+1 < len(os.Args)-1 { + relayURL = os.Args[i+2] + } + } + + // If -relay is specified, read the auth credentials from relay-info.json. + // The nac-relay must have been started at least once to generate these. + var relayAuth *relayInfo + if relayURL != "" { + var err error + relayAuth, err = readRelayInfo() + if err != nil { + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " ❌ Cannot read NAC relay credentials.\n") + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " The NAC relay must be started before running extract-key so that\n") + fmt.Fprintf(os.Stderr, " TLS certificates and auth tokens are generated.\n") + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " Start the relay first:\n") + fmt.Fprintf(os.Stderr, " go run tools/nac-relay/main.go\n") + fmt.Fprintf(os.Stderr, " Or install it as a service:\n") + fmt.Fprintf(os.Stderr, " go run tools/nac-relay/main.go --setup\n") + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " Then re-run extract-key.\n") + fmt.Fprintf(os.Stderr, " (Error: %v)\n", err) + fmt.Fprintf(os.Stderr, "\n") + os.Exit(1) + } + } + + r := C.read_hardware() + if r.error != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", C.GoString(r.error)) + C.free(unsafe.Pointer(r.error)) + os.Exit(1) + } + + serial := goString(r.serial_number) + platformUUID := goString(r.platform_uuid) + rootDiskUUID := goString(r.root_disk_uuid) + boardID := goString(r.board_id) + osBuild := goString(r.os_build_num) + productName := goString(r.product_name) + + // On Apple Silicon, board-id from IOKit may be empty. + // Use the model identifier (e.g. "Mac14,14") as fallback. + if boardID == "" && productName != "" { + boardID = productName + } + mlb := goString(r.mlb) + rom := goBytes(r.rom, C.int(r.rom_len)) + macAddr := goBytes(r.mac_address, C.int(r.mac_address_len)) + + // Encrypted IOKit properties (read directly from the Mac's IOKit registry). + // Present on Intel Macs; absent on Apple Silicon. + serialEnc := Bytes(goBytes(r.serial_enc, C.int(r.serial_enc_len))) + uuidEnc := Bytes(goBytes(r.uuid_enc, C.int(r.uuid_enc_len))) + diskEnc := Bytes(goBytes(r.disk_uuid_enc, C.int(r.disk_uuid_enc_len))) + romEnc := Bytes(goBytes(r.rom_enc, C.int(r.rom_enc_len))) + mlbEnc := Bytes(goBytes(r.mlb_enc, C.int(r.mlb_enc_len))) + + version := getMacOSVersion() + + // Validate we got the critical fields + missing := []string{} + if serial == "" { + missing = append(missing, "serial_number") + } + if platformUUID == "" { + missing = append(missing, "platform_uuid") + } + if len(rom) == 0 { + missing = append(missing, "ROM") + } + if mlb == "" { + missing = append(missing, "MLB") + } + if len(macAddr) != 6 { + missing = append(missing, "mac_address") + } + // Detect actual chip architecture (don't use _enc presence — High Sierra + // Intel Macs also lack _enc fields). + isAppleSilicon := runtime.GOARCH == "arm64" + hasEncFields := len(serialEnc) > 0 + + if isAppleSilicon && relayURL == "" { + fmt.Fprintf(os.Stderr, " ⚠️ Apple Silicon detected — encrypted IOKit properties are absent.\n") + fmt.Fprintf(os.Stderr, " The x86_64 NAC emulator on Linux will fail without them.\n") + fmt.Fprintf(os.Stderr, " You MUST run the NAC relay on this Mac and re-extract with:\n") + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " 1. Start the relay: go run tools/nac-relay/main.go\n") + fmt.Fprintf(os.Stderr, " 2. Re-extract: go run tools/extract-key/main.go -relay https://<this-ip>:5001/validation-data\n") + fmt.Fprintf(os.Stderr, "\n") + } else if isAppleSilicon && relayURL != "" { + // Apple Silicon with relay — all good, suppress _enc warnings + } else if !isAppleSilicon && !hasEncFields { + // Intel Mac without _enc fields. This can happen on older macOS versions, + // but seeing this on modern Intel builds is unusual and should be verified. + fmt.Fprintf(os.Stderr, " ⚠️ Encrypted IOKit properties not found on this Intel Mac.\n") + fmt.Fprintf(os.Stderr, " If registration fails (e.g. status 6004), capture native traffic on this Mac\n") + fmt.Fprintf(os.Stderr, " to confirm required fields/headers before relying on derived values.\n") + fmt.Fprintf(os.Stderr, "\n") + } + + if len(missing) > 0 { + fmt.Fprintf(os.Stderr, " ⚠️ Could not read: %s\n", strings.Join(missing, ", ")) + fmt.Fprintf(os.Stderr, " The key may not work for iMessage registration.\n\n") + } + + hw := HardwareConfig{ + ProductName: productName, + IOMacAddress: Bytes(macAddr), + PlatformSerialNumber: serial, + PlatformUUID: platformUUID, + RootDiskUUID: rootDiskUUID, + BoardID: boardID, + OSBuildNum: osBuild, + PlatformSerialNumberEnc: serialEnc, + PlatformUUIDEnc: uuidEnc, + RootDiskUUIDEnc: diskEnc, + ROM: Bytes(rom), + ROMEnc: romEnc, + MLB: mlb, + MLBEnc: mlbEnc, + } + + darwin := getDarwinVersion() + icloudUA := fmt.Sprintf("com.apple.iCloudHelper/282 CFNetwork/1568.100.1 Darwin/%s", darwin) + + config := MacOSConfig{ + Inner: hw, + Version: version, + ProtocolVersion: 1660, + DeviceID: strings.ToUpper(platformUUID), + ICloudUA: icloudUA, + AOSKitVersion: "com.apple.AOSKit/282 (com.apple.accountsd/113)", + NACRelayURL: relayURL, + } + if relayAuth != nil { + config.RelayToken = relayAuth.Token + config.RelayCertFP = relayAuth.CertFingerprint + } + + jsonBytes, err := json.Marshal(config) + if err != nil { + fmt.Fprintf(os.Stderr, "JSON marshal error: %v\n", err) + os.Exit(1) + } + + b64 := base64.StdEncoding.EncodeToString(jsonBytes) + + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " ✓ Hardware Key Extracted\n") + fmt.Fprintf(os.Stderr, " ───────────────────────\n") + fmt.Fprintf(os.Stderr, " Model: %s\n", productName) + fmt.Fprintf(os.Stderr, " Serial: %s\n", serial) + fmt.Fprintf(os.Stderr, " Build: %s (%s)\n", osBuild, version) + fmt.Fprintf(os.Stderr, " UUID: %s\n", platformUUID) + fmt.Fprintf(os.Stderr, " MLB: %s\n", mlb) + fmt.Fprintf(os.Stderr, " ROM: %d bytes\n", len(rom)) + fmt.Fprintf(os.Stderr, " MAC: %02x:%02x:%02x:%02x:%02x:%02x\n", + macAddr[0], macAddr[1], macAddr[2], macAddr[3], macAddr[4], macAddr[5]) + if isAppleSilicon { + fmt.Fprintf(os.Stderr, " Chip: Apple Silicon\n") + } else if hasEncFields { + fmt.Fprintf(os.Stderr, " Chip: Intel (has _enc fields)\n") + } else { + fmt.Fprintf(os.Stderr, " Chip: Intel (no _enc fields — will be computed on Linux)\n") + } + if relayURL != "" { + fmt.Fprintf(os.Stderr, " Relay: %s\n", relayURL) + } + if relayAuth != nil { + fmt.Fprintf(os.Stderr, " Auth: token + TLS cert pinning\n") + fmt.Fprintf(os.Stderr, " CertFP: %s...%s\n", relayAuth.CertFingerprint[:8], relayAuth.CertFingerprint[len(relayAuth.CertFingerprint)-8:]) + } + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " This Mac can continue to be used normally.\n") + fmt.Fprintf(os.Stderr, " Paste the key below into the bridge login flow.\n") + if relayURL != "" { + fmt.Fprintf(os.Stderr, " Keep the NAC relay running on this Mac.\n") + } + fmt.Fprintf(os.Stderr, "\n") + + // Print base64 key to stdout (for easy copy/pipe) + fmt.Println(b64) +} diff --git a/tools/nac-relay-app/Package.swift b/tools/nac-relay-app/Package.swift new file mode 100644 index 00000000..90138ad0 --- /dev/null +++ b/tools/nac-relay-app/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "NACRelayApp", + platforms: [ + .macOS(.v13) + ], + targets: [ + .executableTarget( + name: "NACRelayApp", + path: "Sources/NACRelayApp", + linkerSettings: [ + .linkedFramework("IOKit"), + .linkedFramework("DiskArbitration"), + .linkedFramework("Security"), + ] + ) + ] +) diff --git a/tools/nac-relay-app/Sources/NACRelayApp/AppDelegate.swift b/tools/nac-relay-app/Sources/NACRelayApp/AppDelegate.swift new file mode 100644 index 00000000..7756e3a5 --- /dev/null +++ b/tools/nac-relay-app/Sources/NACRelayApp/AppDelegate.swift @@ -0,0 +1,59 @@ +import SwiftUI +import AppKit +import ServiceManagement + +@main +class AppDelegate: NSObject, NSApplicationDelegate { + var statusItem: NSStatusItem! + var popover: NSPopover! + let relay = RelayManager() + let extractor = KeyExtractor() + let loginItem = LoginItemManager() + + func applicationDidFinishLaunching(_ notification: Notification) { + // Create the status bar item + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + button.image = NSImage(systemSymbolName: "antenna.radiowaves.left.and.right", + accessibilityDescription: "NAC Relay") + button.action = #selector(togglePopover) + button.target = self + } + + // Create the popover with SwiftUI content + popover = NSPopover() + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: PopoverView(relay: relay, extractor: extractor, loginItem: loginItem) + ) + + // Auto-start the relay + relay.start() + } + + @objc func togglePopover() { + guard let button = statusItem.button else { return } + if popover.isShown { + popover.performClose(nil) + } else { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + // Bring app to front + NSApplication.shared.activate(ignoringOtherApps: true) + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return false // menubar app stays alive + } + + func applicationWillTerminate(_ notification: Notification) { + relay.stop() + } + + static func main() { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() + } +} diff --git a/tools/nac-relay-app/Sources/NACRelayApp/IOKitHelpers.swift b/tools/nac-relay-app/Sources/NACRelayApp/IOKitHelpers.swift new file mode 100644 index 00000000..09f6e80b --- /dev/null +++ b/tools/nac-relay-app/Sources/NACRelayApp/IOKitHelpers.swift @@ -0,0 +1,43 @@ +import Foundation +import IOKit + +/// Read a string property from an IOKit registry entry. +func ioString(_ entry: io_service_t, _ key: String) -> String? { + guard let ref = IORegistryEntryCreateCFProperty( + entry, key as CFString, kCFAllocatorDefault, 0 + ) else { return nil } + + let value = ref.takeRetainedValue() + if CFGetTypeID(value) == CFStringGetTypeID() { + return (value as! CFString) as String + } + return nil +} + +/// Read a data property from an IOKit registry entry. +func ioData(_ entry: io_service_t, _ key: String) -> Data? { + guard let ref = IORegistryEntryCreateCFProperty( + entry, key as CFString, kCFAllocatorDefault, 0 + ) else { return nil } + + let value = ref.takeRetainedValue() + if CFGetTypeID(value) == CFDataGetTypeID() { + return (value as! CFData) as Data + } + return nil +} + +/// Read a property that could be either a string or null-terminated data. +/// Tries string first, then data (stripping trailing null bytes). +func ioStringOrData(_ entry: io_service_t, _ key: String) -> String? { + if let s = ioString(entry, key) { return s } + if let data = ioData(entry, key), !data.isEmpty { + // Strip trailing null bytes + var trimmed = data + while let last = trimmed.last, last == 0 { + trimmed = trimmed.dropLast() + } + return String(data: Data(trimmed), encoding: .utf8) + } + return nil +} diff --git a/tools/nac-relay-app/Sources/NACRelayApp/KeyExtractor.swift b/tools/nac-relay-app/Sources/NACRelayApp/KeyExtractor.swift new file mode 100644 index 00000000..93677507 --- /dev/null +++ b/tools/nac-relay-app/Sources/NACRelayApp/KeyExtractor.swift @@ -0,0 +1,184 @@ +import Foundation +import IOKit +import Combine + +/// Reads hardware identifiers from IOKit and produces a base64-encoded +/// hardware key matching the format expected by the iMessage bridge. +/// Embeds relay URL/token/cert fingerprint when relay info is available. +class KeyExtractor: ObservableObject { + @Published var result: ExtractionResult? + @Published var isExtracting = false + @Published var errorMessage: String? + + func extract(relayURL: String?, relayInfo: RelayInfo?) { + isExtracting = true + errorMessage = nil + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + do { + let result = try Self.performExtraction( + relayURL: relayURL, + relayInfo: relayInfo + ) + DispatchQueue.main.async { + self?.result = result + self?.isExtracting = false + } + } catch { + DispatchQueue.main.async { + self?.errorMessage = error.localizedDescription + self?.isExtracting = false + } + } + } + } + + // MARK: - Core extraction logic + + static func performExtraction( + relayURL: String?, + relayInfo: RelayInfo? + ) throws -> ExtractionResult { + let nvramPrefix = "4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14" + var warnings: [String] = [] + + // 1. Open IOPlatformExpertDevice + guard let matching = IOServiceMatching("IOPlatformExpertDevice") else { + throw ExtractionError.noIOPlatformExpertDevice + } + let platform = IOServiceGetMatchingService(0, matching) + guard platform != IO_OBJECT_NULL else { + throw ExtractionError.noIOPlatformExpertDevice + } + defer { IOObjectRelease(platform) } + + // 2. Plain-text identifiers + let serial = ioString(platform, "IOPlatformSerialNumber") ?? "" + let platformUUID = ioString(platform, "IOPlatformUUID") ?? "" + var boardID = ioStringOrData(platform, "board-id") + var productName = ioStringOrData(platform, "product-name") + + // Fallback: hw.model sysctl + if productName == nil { + productName = sysctlString("hw.model") + } + + // 3. ROM and MLB from NVRAM + var rom = ioData(platform, "\(nvramPrefix):ROM") + var mlb: String? = ioStringOrData(platform, "\(nvramPrefix):MLB") + + // 4. Encrypted IOKit properties (present on Intel Macs) + let serialEnc = ioData(platform, "Gq3489ugfi") + let uuidEnc = ioData(platform, "Fyp98tpgj") + let diskUUIDEnc = ioData(platform, "kbjfrfpoJU") + let romEnc = ioData(platform, "oycqAZloTNDm") + let mlbEnc = ioData(platform, "abKPld1EcMni") + + // 5. Apple Silicon: ROM from en0 MAC address + if rom == nil || rom?.isEmpty == true { + rom = getEN0MACAddress() + } + + // 6. Apple Silicon: MLB from mlb-serial-number + if mlb == nil { + if let data = ioData(platform, "mlb-serial-number"), !data.isEmpty { + var trimmed = Array(data) + while trimmed.last == 0 { trimmed.removeLast() } + if !trimmed.isEmpty { + mlb = String(bytes: trimmed, encoding: .utf8) + } + } + } + + // 7. Fallback: IODeviceTree:/options + if mlb == nil || rom == nil || rom?.isEmpty == true { + let options = IORegistryEntryFromPath(0, "IODeviceTree:/options") + if options != IO_OBJECT_NULL { + defer { IOObjectRelease(options) } + if mlb == nil { + mlb = ioStringOrData(options, "\(nvramPrefix):MLB") + } + if rom == nil || rom?.isEmpty == true { + rom = ioData(options, "\(nvramPrefix):ROM") + } + } + } + + // 8. Fallback: nvram CLI + if rom == nil || rom?.isEmpty == true { + rom = nvramROM() + } + if mlb == nil { + mlb = nvramMLB() + } + + // 9. Other system info + let osBuild = sysctlString("kern.osversion") ?? "unknown" + let rootDiskUUID = getRootDiskUUID() + let macAddress = getEN0MACAddress() + + // On Apple Silicon, board-id may be empty; use product name as fallback. + if boardID == nil || boardID?.isEmpty == true { + boardID = productName + } + + let macOSVersion = getMacOSVersion() + let darwinVersion = getDarwinVersion() + + // 10. Validate critical fields + if serial.isEmpty { warnings.append("serial_number") } + if platformUUID.isEmpty { warnings.append("platform_uuid") } + if rom == nil || rom?.isEmpty == true { warnings.append("ROM") } + if mlb == nil || mlb?.isEmpty == true { warnings.append("MLB") } + if macAddress == nil || macAddress?.count != 6 { warnings.append("mac_address") } + + if relayURL == nil || relayURL?.isEmpty == true { + warnings.append( + "No relay URL -- start the relay before extracting the key." + ) + } + + // 11. Build structs + let hw = HardwareConfig( + productName: productName ?? "", + ioMacAddress: ByteArray(macAddress.map { Array($0) } ?? []), + platformSerialNumber: serial, + platformUUID: platformUUID, + rootDiskUUID: rootDiskUUID, + boardID: boardID ?? "", + osBuildNum: osBuild, + platformSerialNumberEnc: ByteArray(serialEnc.map { Array($0) } ?? []), + platformUUIDEnc: ByteArray(uuidEnc.map { Array($0) } ?? []), + rootDiskUUIDEnc: ByteArray(diskUUIDEnc.map { Array($0) } ?? []), + rom: ByteArray(rom.map { Array($0) } ?? []), + romEnc: ByteArray(romEnc.map { Array($0) } ?? []), + mlb: mlb ?? "", + mlbEnc: ByteArray(mlbEnc.map { Array($0) } ?? []) + ) + + let icloudUA = "com.apple.iCloudHelper/282 CFNetwork/1568.100.1 Darwin/\(darwinVersion)" + + let config = MacOSConfig( + inner: hw, + version: macOSVersion, + protocolVersion: 1660, + deviceID: platformUUID.uppercased(), + icloudUA: icloudUA, + aoskitVersion: "com.apple.AOSKit/282 (com.apple.accountsd/113)", + nacRelayURL: relayURL, + relayToken: relayInfo?.token, + relayCertFP: relayInfo?.certFingerprint + ) + + // 12. JSON -> base64 + let jsonString = config.toOrderedJSON() + let jsonData = Data(jsonString.utf8) + let base64Key = jsonData.base64EncodedString() + + return ExtractionResult( + config: config, + base64Key: base64Key, + warnings: warnings + ) + } +} diff --git a/tools/nac-relay-app/Sources/NACRelayApp/LoginItemManager.swift b/tools/nac-relay-app/Sources/NACRelayApp/LoginItemManager.swift new file mode 100644 index 00000000..43557d00 --- /dev/null +++ b/tools/nac-relay-app/Sources/NACRelayApp/LoginItemManager.swift @@ -0,0 +1,27 @@ +import Foundation +import ServiceManagement + +/// Manages "Start at Login" via SMAppService (macOS 13+). +class LoginItemManager: ObservableObject { + @Published var isEnabled: Bool + + private let service = SMAppService.mainApp + + init() { + isEnabled = SMAppService.mainApp.status == .enabled + } + + func toggle() { + do { + if isEnabled { + try service.unregister() + } else { + try service.register() + } + isEnabled = service.status == .enabled + } catch { + // Refresh state even on error + isEnabled = service.status == .enabled + } + } +} diff --git a/tools/nac-relay-app/Sources/NACRelayApp/Models.swift b/tools/nac-relay-app/Sources/NACRelayApp/Models.swift new file mode 100644 index 00000000..c8eaf50b --- /dev/null +++ b/tools/nac-relay-app/Sources/NACRelayApp/Models.swift @@ -0,0 +1,223 @@ +import Foundation + +// MARK: - ByteArray + +/// A wrapper around [UInt8] that encodes to/from a JSON array of integers +/// (e.g. [10, 20, 30]) matching Rust's serde serialize_bytes format. +/// Empty values serialize as [] (not null). +struct ByteArray: Codable, Equatable { + var bytes: [UInt8] + + init(_ bytes: [UInt8] = []) { + self.bytes = bytes + } + + init(_ data: Data) { + self.bytes = Array(data) + } + + var isEmpty: Bool { bytes.isEmpty } + var count: Int { bytes.count } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + for byte in bytes { + try container.encode(Int(byte)) + } + } + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + var result: [UInt8] = [] + while !container.isAtEnd { + let val = try container.decode(UInt8.self) + result.append(val) + } + self.bytes = result + } + + /// Hex representation for display. + var hexString: String { + bytes.map { String(format: "%02x", $0) }.joined(separator: ":") + } + + /// JSON array string like [10,20,30] -- matches Go/Rust output. + var jsonArray: String { + "[\(bytes.map { String($0) }.joined(separator: ","))]" + } +} + +// MARK: - HardwareConfig + +/// Matches rustpush/open-absinthe/src/nac.rs HardwareConfig exactly. +struct HardwareConfig: Codable { + var productName: String + var ioMacAddress: ByteArray + var platformSerialNumber: String + var platformUUID: String + var rootDiskUUID: String + var boardID: String + var osBuildNum: String + var platformSerialNumberEnc: ByteArray + var platformUUIDEnc: ByteArray + var rootDiskUUIDEnc: ByteArray + var rom: ByteArray + var romEnc: ByteArray + var mlb: String + var mlbEnc: ByteArray + + enum CodingKeys: String, CodingKey { + case productName = "product_name" + case ioMacAddress = "io_mac_address" + case platformSerialNumber = "platform_serial_number" + case platformUUID = "platform_uuid" + case rootDiskUUID = "root_disk_uuid" + case boardID = "board_id" + case osBuildNum = "os_build_num" + case platformSerialNumberEnc = "platform_serial_number_enc" + case platformUUIDEnc = "platform_uuid_enc" + case rootDiskUUIDEnc = "root_disk_uuid_enc" + case rom + case romEnc = "rom_enc" + case mlb + case mlbEnc = "mlb_enc" + } +} + +// MARK: - MacOSConfig + +/// Matches rustpush/src/macos.rs MacOSConfig, with optional relay fields. +struct MacOSConfig: Codable { + var inner: HardwareConfig + var version: String + var protocolVersion: UInt32 + var deviceID: String + var icloudUA: String + var aoskitVersion: String + var nacRelayURL: String? + var relayToken: String? + var relayCertFP: String? + + enum CodingKeys: String, CodingKey { + case inner + case version + case protocolVersion = "protocol_version" + case deviceID = "device_id" + case icloudUA = "icloud_ua" + case aoskitVersion = "aoskit_version" + case nacRelayURL = "nac_relay_url" + case relayToken = "relay_token" + case relayCertFP = "relay_cert_fp" + } +} + +// MARK: - RelayInfo + +/// Matches the relay-info.json written by the Go nac-relay. +struct RelayInfo: Codable { + var token: String + var certFingerprint: String + + enum CodingKeys: String, CodingKey { + case token + case certFingerprint = "cert_fingerprint" + } +} + +// MARK: - Ordered JSON Serialization + +/// Escape a string for JSON output (handles quotes, backslashes, control chars). +/// Does NOT escape forward slashes (matching Go's json.Marshal). +private func jsonEscape(_ s: String) -> String { + var result = "" + for c in s { + switch c { + case "\"": result += "\\\"" + case "\\": result += "\\\\" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + default: + if c.asciiValue != nil && c.asciiValue! < 0x20 { + result += String(format: "\\u%04x", c.asciiValue!) + } else { + result.append(c) + } + } + } + return result +} + +extension HardwareConfig { + /// Serialize to JSON with key order matching the Go extract-key tool output. + /// Go's encoding/json marshals struct fields in declaration order. + func toOrderedJSON() -> String { + let pairs: [(String, String)] = [ + ("product_name", "\"\(jsonEscape(productName))\""), + ("io_mac_address", ioMacAddress.jsonArray), + ("platform_serial_number", "\"\(jsonEscape(platformSerialNumber))\""), + ("platform_uuid", "\"\(jsonEscape(platformUUID))\""), + ("root_disk_uuid", "\"\(jsonEscape(rootDiskUUID))\""), + ("board_id", "\"\(jsonEscape(boardID))\""), + ("os_build_num", "\"\(jsonEscape(osBuildNum))\""), + ("platform_serial_number_enc", platformSerialNumberEnc.jsonArray), + ("platform_uuid_enc", platformUUIDEnc.jsonArray), + ("root_disk_uuid_enc", rootDiskUUIDEnc.jsonArray), + ("rom", rom.jsonArray), + ("rom_enc", romEnc.jsonArray), + ("mlb", "\"\(jsonEscape(mlb))\""), + ("mlb_enc", mlbEnc.jsonArray), + ] + let body = pairs.map { "\"\($0.0)\":\($0.1)" }.joined(separator: ",") + return "{\(body)}" + } +} + +extension MacOSConfig { + /// Serialize to JSON with key order matching the Go extract-key tool output. + /// Go's encoding/json marshals struct fields in declaration order. + /// Relay fields are omitted when nil (matching Go's omitempty). + func toOrderedJSON() -> String { + var pairs: [(String, String)] = [ + ("inner", inner.toOrderedJSON()), + ("version", "\"\(jsonEscape(version))\""), + ("protocol_version", "\(protocolVersion)"), + ("device_id", "\"\(jsonEscape(deviceID))\""), + ("icloud_ua", "\"\(jsonEscape(icloudUA))\""), + ("aoskit_version", "\"\(jsonEscape(aoskitVersion))\""), + ] + if let url = nacRelayURL, !url.isEmpty { + pairs.append(("nac_relay_url", "\"\(jsonEscape(url))\"")) + } + if let token = relayToken, !token.isEmpty { + pairs.append(("relay_token", "\"\(jsonEscape(token))\"")) + } + if let fp = relayCertFP, !fp.isEmpty { + pairs.append(("relay_cert_fp", "\"\(jsonEscape(fp))\"")) + } + let body = pairs.map { "\"\($0.0)\":\($0.1)" }.joined(separator: ",") + return "{\(body)}" + } +} + +// MARK: - ExtractionResult + +/// Result returned by the hardware extractor for the UI. +struct ExtractionResult { + var config: MacOSConfig + var base64Key: String + var warnings: [String] +} + +// MARK: - ExtractionError + +enum ExtractionError: Error, LocalizedError { + case noIOPlatformExpertDevice + + var errorDescription: String? { + switch self { + case .noIOPlatformExpertDevice: + return "Failed to find IOPlatformExpertDevice in IOKit registry" + } + } +} diff --git a/tools/nac-relay-app/Sources/NACRelayApp/PopoverView.swift b/tools/nac-relay-app/Sources/NACRelayApp/PopoverView.swift new file mode 100644 index 00000000..e264e16a --- /dev/null +++ b/tools/nac-relay-app/Sources/NACRelayApp/PopoverView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct PopoverView: View { + @ObservedObject var relay: RelayManager + @ObservedObject var extractor: KeyExtractor + @ObservedObject var loginItem: LoginItemManager + @State private var copied = false + @State private var showLog = false + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 10) { + // Header + HStack { + Text("NAC Relay") + .font(.headline) + Spacer() + Circle() + .fill(relay.isRunning ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text(relay.isRunning ? "Running" : "Stopped") + .font(.caption) + .foregroundColor(.secondary) + } + + Divider() + + // Relay address + if let addr = relay.relayAddress, relay.isRunning { + VStack(alignment: .leading, spacing: 2) { + Text("Relay Address") + .font(.caption) + .foregroundColor(.secondary) + Text(addr) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + // Relay token info + if let info = relay.relayInfo, relay.isRunning { + VStack(alignment: .leading, spacing: 2) { + Text("Auth Token") + .font(.caption) + .foregroundColor(.secondary) + let t = info.token + Text("\(t.prefix(8))...\(t.suffix(8))") + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + } + } + + // Error message + if let err = relay.errorMessage { + Text(err) + .font(.caption) + .foregroundColor(.red) + } + + // Start/Stop button + Button(action: { + if relay.isRunning { + relay.stop() + } else { + relay.start() + } + }) { + HStack { + Image(systemName: relay.isRunning ? "stop.fill" : "play.fill") + Text(relay.isRunning ? "Stop Relay" : "Start Relay") + } + .frame(maxWidth: .infinity) + } + .controlSize(.large) + + Divider() + + // Hardware Key section + Text("Hardware Key") + .font(.headline) + + if let result = extractor.result { + // Warnings + ForEach(result.warnings, id: \.self) { warning in + HStack(alignment: .top, spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + .font(.caption) + Text(warning) + .font(.caption) + .foregroundColor(.secondary) + } + } + + // Key output + VStack(alignment: .leading, spacing: 4) { + Text(result.base64Key.prefix(60) + "...") + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(2) + + HStack { + Button(action: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result.base64Key, forType: .string) + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copied = false + } + }) { + HStack { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + Text(copied ? "Copied!" : "Copy Key") + } + } + + Spacer() + + Button("Re-extract") { + extractor.extract( + relayURL: relay.relayAddress, + relayInfo: relay.relayInfo + ) + } + } + } + } else if extractor.isExtracting { + HStack { + ProgressView() + .controlSize(.small) + Text("Extracting...") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + if let err = extractor.errorMessage { + Text(err) + .font(.caption) + .foregroundColor(.red) + } + Button(action: { + extractor.extract( + relayURL: relay.relayAddress, + relayInfo: relay.relayInfo + ) + }) { + HStack { + Image(systemName: "key") + Text("Extract Hardware Key") + } + .frame(maxWidth: .infinity) + } + .controlSize(.large) + } + + Divider() + + // Log toggle + if relay.isRunning { + DisclosureGroup("Relay Log", isExpanded: $showLog) { + ScrollView { + Text(relay.logOutput.isEmpty ? "(waiting for output...)" : relay.logOutput) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 120) + } + .font(.caption) + } + + // Footer: Start at Login + Quit + HStack { + Toggle("Start at Login", isOn: Binding( + get: { loginItem.isEnabled }, + set: { _ in loginItem.toggle() } + )) + .toggleStyle(.checkbox) + .font(.caption) + + Spacer() + + Button("Quit") { + relay.stop() + NSApplication.shared.terminate(nil) + } + .controlSize(.small) + } + .id("footer") + } + .padding() + } + } + .frame(width: 320) + .frame(maxHeight: 500) + } +} diff --git a/tools/nac-relay-app/Sources/NACRelayApp/RelayManager.swift b/tools/nac-relay-app/Sources/NACRelayApp/RelayManager.swift new file mode 100644 index 00000000..dc7d81f6 --- /dev/null +++ b/tools/nac-relay-app/Sources/NACRelayApp/RelayManager.swift @@ -0,0 +1,114 @@ +import Foundation +import Combine + +/// Manages the lifecycle of the bundled Go nac-relay binary. +class RelayManager: ObservableObject { + @Published var isRunning = false + @Published var relayAddress: String? + @Published var logOutput: String = "" + @Published var relayInfo: RelayInfo? + @Published var errorMessage: String? + + private var process: Process? + private var outputPipe: Pipe? + private let port = 5001 + + /// Path to the bundled nac-relay binary inside the .app bundle. + private var binaryPath: String? { + Bundle.main.path(forResource: "nac-relay", ofType: nil) + } + + /// Directory where the Go relay stores its auth files. + var relayDataDir: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/Library/Application Support/nac-relay" + } + + private var relayInfoPath: String { + "\(relayDataDir)/relay-info.json" + } + + func start() { + guard !isRunning else { return } + guard let binary = binaryPath else { + errorMessage = "nac-relay binary not found in app bundle" + return + } + + errorMessage = nil + logOutput = "" + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: binary) + proc.arguments = ["-port", "\(port)", "-addr", "0.0.0.0"] + + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = pipe + self.outputPipe = pipe + + proc.terminationHandler = { [weak self] p in + DispatchQueue.main.async { + self?.isRunning = false + self?.relayAddress = nil + if p.terminationStatus != 0 && p.terminationStatus != 15 { + self?.errorMessage = "Relay exited with status \(p.terminationStatus)" + } + } + } + + // Read output asynchronously + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return } + DispatchQueue.main.async { + self?.logOutput += line + // Keep log buffer bounded + if let log = self?.logOutput, log.count > 10000 { + self?.logOutput = String(log.suffix(8000)) + } + } + } + + do { + try proc.run() + self.process = proc + self.isRunning = true + + // Wait briefly for the relay to start and write relay-info.json + DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.loadRelayInfo() + self?.detectAddress() + } + } catch { + errorMessage = "Failed to start relay: \(error.localizedDescription)" + } + } + + func stop() { + guard let proc = process, proc.isRunning else { return } + proc.terminate() + outputPipe?.fileHandleForReading.readabilityHandler = nil + process = nil + } + + func loadRelayInfo() { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: relayInfoPath)), + let info = try? JSONDecoder().decode(RelayInfo.self, from: data), + !info.token.isEmpty, !info.certFingerprint.isEmpty else { + return + } + DispatchQueue.main.async { + self.relayInfo = info + } + } + + private func detectAddress() { + let ips = getLocalIPAddresses() + let addr = ips.first.map { "https://\($0):\(port)/validation-data" } + ?? "https://localhost:\(port)/validation-data" + DispatchQueue.main.async { + self.relayAddress = addr + } + } +} diff --git a/tools/nac-relay-app/Sources/NACRelayApp/SystemInfo.swift b/tools/nac-relay-app/Sources/NACRelayApp/SystemInfo.swift new file mode 100644 index 00000000..d4019cd4 --- /dev/null +++ b/tools/nac-relay-app/Sources/NACRelayApp/SystemInfo.swift @@ -0,0 +1,190 @@ +import Foundation +import DiskArbitration + +// MARK: - MAC Address + +/// Get the en0 MAC address (6 bytes) via getifaddrs. +func getEN0MACAddress() -> Data? { + var ifaddrsPtr: UnsafeMutablePointer<ifaddrs>? + guard getifaddrs(&ifaddrsPtr) == 0, let firstAddr = ifaddrsPtr else { return nil } + defer { freeifaddrs(firstAddr) } + + var cursor: UnsafeMutablePointer<ifaddrs>? = firstAddr + while let ifa = cursor { + defer { cursor = ifa.pointee.ifa_next } + let name = String(cString: ifa.pointee.ifa_name) + guard name == "en0", + let addr = ifa.pointee.ifa_addr, + addr.pointee.sa_family == UInt8(AF_LINK) else { continue } + + return addr.withMemoryRebound(to: sockaddr_dl.self, capacity: 1) { sdl in + guard sdl.pointee.sdl_alen == 6 else { return nil } + return withUnsafePointer(to: sdl.pointee.sdl_data) { dataPtr in + let rawPtr = UnsafeRawPointer(dataPtr) + .advanced(by: Int(sdl.pointee.sdl_nlen)) + return Data(bytes: rawPtr, count: 6) + } + } + } + return nil +} + +// MARK: - Root Disk UUID + +/// Get the UUID of the root filesystem volume via DiskArbitration. +func getRootDiskUUID() -> String { + guard let session = DASessionCreate(kCFAllocatorDefault) else { return "unknown" } + + var sfs = statfs() + guard statfs("/", &sfs) == 0 else { return "unknown" } + + let bsdName: String = withUnsafePointer(to: &sfs.f_mntfromname) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { + String(cString: $0) + } + } + + guard let disk = DADiskCreateFromBSDName(kCFAllocatorDefault, session, bsdName) else { + return "unknown" + } + guard let desc = DADiskCopyDescription(disk) as? [CFString: Any] else { + return "unknown" + } + + let key = kDADiskDescriptionVolumeUUIDKey as String + for (k, v) in desc { + if (k as String) == key { + let uuid = v as! CFUUID + if let str = CFUUIDCreateString(kCFAllocatorDefault, uuid) { + return str as String + } + } + } + return "unknown" +} + +// MARK: - Sysctl + +/// Read a sysctl string value by name. +func sysctlString(_ name: String) -> String? { + var size: Int = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buf = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buf, &size, nil, 0) == 0 else { return nil } + return String(cString: buf) +} + +// MARK: - Process Helpers + +/// Run a subprocess and return its stdout as a string. +func runProcess(_ path: String, args: [String]) -> String? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: path) + proc.arguments = args + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = FileHandle.nullDevice + do { + try proc.run() + proc.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } catch { + return nil + } +} + +// MARK: - NVRAM Fallbacks + +/// Decode nvram percent-encoded binary output (e.g. "%1e%eb%08n%ae"). +private func decodeNVRAMPercent(_ input: String) -> Data? { + var bytes: [UInt8] = [] + var i = input.startIndex + while i < input.endIndex { + if input[i] == "%" { + let hexStart = input.index(after: i) + guard hexStart < input.endIndex else { break } + let hexEnd = input.index(hexStart, offsetBy: 2, limitedBy: input.endIndex) + ?? input.endIndex + let hex = String(input[hexStart..<hexEnd]) + if let byte = UInt8(hex, radix: 16) { + bytes.append(byte) + } + i = hexEnd + } else { + bytes.append(UInt8(input[i].asciiValue ?? 0)) + i = input.index(after: i) + } + } + return bytes.isEmpty ? nil : Data(bytes) +} + +/// Read ROM from nvram CLI (fallback when IOKit doesn't expose it). +func nvramROM() -> Data? { + guard let output = runProcess( + "/usr/sbin/nvram", + args: ["4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:ROM"] + ) else { return nil } + guard let tabIdx = output.firstIndex(of: "\t") else { return nil } + let value = String(output[output.index(after: tabIdx)...]) + .trimmingCharacters(in: .newlines) + return decodeNVRAMPercent(value) +} + +/// Read MLB from nvram CLI (plain text after tab). +func nvramMLB() -> String? { + guard let output = runProcess( + "/usr/sbin/nvram", + args: ["4D1EDE05-38C7-4A6A-9CC6-4BCCA8B38C14:MLB"] + ) else { return nil } + guard let tabIdx = output.firstIndex(of: "\t") else { return nil } + let value = String(output[output.index(after: tabIdx)...]) + .trimmingCharacters(in: .newlines) + return value.isEmpty ? nil : value +} + +// MARK: - Version Info + +/// Get the macOS version string (e.g. "14.3.1"). +func getMacOSVersion() -> String { + if let output = runProcess("/usr/bin/sw_vers", args: ["-productVersion"]) { + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + return "14.0" +} + +/// Get the Darwin kernel version string (e.g. "23.3.0"). +func getDarwinVersion() -> String { + if let output = runProcess("/usr/bin/uname", args: ["-r"]) { + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + return "22.5.0" +} + +// MARK: - Network Addresses + +/// Get all non-loopback IPv4 addresses on this machine. +func getLocalIPAddresses() -> [String] { + var addresses: [String] = [] + var ifaddrsPtr: UnsafeMutablePointer<ifaddrs>? + guard getifaddrs(&ifaddrsPtr) == 0, let firstAddr = ifaddrsPtr else { return [] } + defer { freeifaddrs(firstAddr) } + + var cursor: UnsafeMutablePointer<ifaddrs>? = firstAddr + while let ifa = cursor { + defer { cursor = ifa.pointee.ifa_next } + guard let addr = ifa.pointee.ifa_addr, + addr.pointee.sa_family == UInt8(AF_INET) else { continue } + + addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { sin in + var ip = sin.pointee.sin_addr + var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + inet_ntop(AF_INET, &ip, &buf, socklen_t(INET_ADDRSTRLEN)) + let ipStr = String(cString: buf) + if ipStr != "127.0.0.1" { + addresses.append(ipStr) + } + } + } + return addresses +} diff --git a/tools/nac-relay-app/build.sh b/tools/nac-relay-app/build.sh new file mode 100755 index 00000000..bfaec717 --- /dev/null +++ b/tools/nac-relay-app/build.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# build.sh — Build the NAC Relay menubar app for Apple Silicon Macs. +# +# Compiles the Go nac-relay binary (arm64), then builds the SwiftUI +# wrapper app and bundles them together. +# +# Usage: +# cd tools/nac-relay-app +# ./build.sh + +set -euo pipefail +cd "$(dirname "$0")" + +# 1. Build the Go nac-relay binary for arm64 +echo "Building Go nac-relay binary for arm64..." +RELAY_DIR="../nac-relay" +RELAY_BIN=".build/nac-relay" +mkdir -p .build + +# Build with CGO for the ObjC NAC code +CGO_ENABLED=1 GOARCH=arm64 GOOS=darwin \ + go build -o "$RELAY_BIN" "$RELAY_DIR" + +echo " Built $RELAY_BIN" +file "$RELAY_BIN" + +# 2. Build the Swift menubar app +echo "" +echo "Building NACRelayApp for arm64 (Apple Silicon)..." +swift build -c release --arch arm64 + +BINARY=".build/release/NACRelayApp" +if [ ! -f "$BINARY" ]; then + BINARY=$(find .build -name NACRelayApp -type f -perm +111 2>/dev/null | grep -v nac-relay | head -1) +fi + +if [ ! -f "$BINARY" ]; then + echo "ERROR: Build succeeded but binary not found" + exit 1 +fi + +echo "" +echo "Binary: $BINARY" +file "$BINARY" + +# 3. Create .app bundle +APP="NACRelay.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" +mkdir -p "$APP/Contents/Resources" + +cp "$BINARY" "$APP/Contents/MacOS/NACRelayApp" +cp "$RELAY_BIN" "$APP/Contents/Resources/nac-relay" + +cat > "$APP/Contents/Info.plist" << 'PLIST' +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleIdentifier</key> + <string>com.imessage.nac-relay-app</string> + <key>CFBundleName</key> + <string>NAC Relay</string> + <key>CFBundleDisplayName</key> + <string>iMessage NAC Relay</string> + <key>CFBundleExecutable</key> + <string>NACRelayApp</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleVersion</key> + <string>1.0</string> + <key>CFBundleShortVersionString</key> + <string>1.0</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>LSMinimumSystemVersion</key> + <string>13.0</string> + <key>LSUIElement</key> + <true/> + <key>NSHighResolutionCapable</key> + <true/> +</dict> +</plist> +PLIST + +# Ad-hoc sign (required for Gatekeeper on recent macOS) +codesign --force --sign - "$APP" 2>/dev/null || true + +echo "" +echo "App bundle: $(pwd)/$APP" +echo "" +echo "To run:" +echo " open $APP" +echo "" +echo "The app appears as a menubar icon (antenna). No dock icon." +echo "It auto-starts the NAC relay on launch." diff --git a/tools/nac-relay/auth.go b/tools/nac-relay/auth.go new file mode 100644 index 00000000..e328d00d --- /dev/null +++ b/tools/nac-relay/auth.go @@ -0,0 +1,327 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "log" + "math/big" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// RelayInfo is written to relay-info.json so extract-key can read it. +type RelayInfo struct { + Token string `json:"token"` + CertFingerprint string `json:"cert_fingerprint"` // SHA-256 hex of DER cert +} + +const relayDirName = "nac-relay" + +// relayDataDir returns ~/Library/Application Support/nac-relay/, +// creating it if necessary. +func relayDataDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, "Library", "Application Support", relayDirName) + if err := os.MkdirAll(dir, 0700); err != nil { + return "", err + } + return dir, nil +} + +// ensureRelayAuth loads or generates the TLS cert and bearer token. +// Returns the tls.Config, the bearer token, and the relay-info path. +func ensureRelayAuth() (*tls.Config, string, error) { + dir, err := relayDataDir() + if err != nil { + return nil, "", fmt.Errorf("relay data dir: %w", err) + } + + certPath := filepath.Join(dir, "cert.pem") + keyPath := filepath.Join(dir, "key.pem") + tokenPath := filepath.Join(dir, "token") + infoPath := filepath.Join(dir, "relay-info.json") + + // Generate token if missing + token, err := loadOrGenerateToken(tokenPath) + if err != nil { + return nil, "", fmt.Errorf("token: %w", err) + } + + // Generate self-signed cert if missing + if !fileExists(certPath) || !fileExists(keyPath) { + if err := generateSelfSignedCert(certPath, keyPath); err != nil { + return nil, "", fmt.Errorf("generate cert: %w", err) + } + } + + // Load the cert + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, "", fmt.Errorf("load cert: %w", err) + } + + // Compute fingerprint + fingerprint := certFingerprint(cert) + + // Write relay-info.json for extract-key to read + info := RelayInfo{ + Token: token, + CertFingerprint: fingerprint, + } + infoJSON, _ := json.MarshalIndent(info, "", " ") + if err := os.WriteFile(infoPath, infoJSON, 0600); err != nil { + return nil, "", fmt.Errorf("write relay-info.json: %w", err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } + + log.Printf("TLS cert fingerprint: %s", fingerprint) + log.Printf("Auth token: %s...%s", token[:4], token[len(token)-4:]) + log.Printf("Relay info: %s", infoPath) + + return tlsConfig, token, nil +} + +func loadOrGenerateToken(path string) (string, error) { + if data, err := os.ReadFile(path); err == nil { + t := strings.TrimSpace(string(data)) + if len(t) >= 32 { + return t, nil + } + } + // Generate a 32-byte random token as hex + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + token := hex.EncodeToString(buf) + if err := os.WriteFile(path, []byte(token), 0600); err != nil { + return "", err + } + return token, nil +} + +func generateSelfSignedCert(certPath, keyPath string) error { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + + // Collect all local IPs for SAN + var ips []net.IP + ips = append(ips, net.IPv4(127, 0, 0, 1)) + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { + ips = append(ips, ipnet.IP) + } + } + } + + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "nac-relay"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: ips, + DNSNames: []string{"localhost", "nac-relay"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + if err := os.WriteFile(certPath, certPEM, 0600); err != nil { + return err + } + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + return err + } + + log.Println("Generated self-signed TLS certificate (valid 10 years)") + return nil +} + +func certFingerprint(cert tls.Certificate) string { + if len(cert.Certificate) == 0 { + return "" + } + h := sha256.Sum256(cert.Certificate[0]) + return hex.EncodeToString(h[:]) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// installAppBundle creates a minimal .app bundle in ~/Applications so macOS +// shows the app name in TCC prompts and LaunchAgent references work properly. +func installAppBundle() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + appDir := filepath.Join(home, "Applications", "nac-relay.app") + macosDir := filepath.Join(appDir, "Contents", "MacOS") + if err := os.MkdirAll(macosDir, 0755); err != nil { + return "", fmt.Errorf("failed to create .app bundle: %w", err) + } + + infoPlist := `<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleIdentifier</key> + <string>com.imessage.nac-relay</string> + <key>CFBundleName</key> + <string>nac-relay</string> + <key>CFBundleDisplayName</key> + <string>iMessage Bridge Relay</string> + <key>CFBundleExecutable</key> + <string>nac-relay</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleVersion</key> + <string>1.0</string> + <key>CFBundleShortVersionString</key> + <string>1.0</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>LSMinimumSystemVersion</key> + <string>13.0</string> + <key>LSUIElement</key> + <true/> +</dict> +</plist>` + + if err := os.WriteFile(filepath.Join(appDir, "Contents", "Info.plist"), []byte(infoPlist), 0644); err != nil { + return "", fmt.Errorf("failed to write Info.plist: %w", err) + } + + selfPath, err := os.Executable() + if err != nil { + return "", fmt.Errorf("failed to find own executable: %w", err) + } + selfPath, _ = filepath.EvalSymlinks(selfPath) + + destPath := filepath.Join(macosDir, "nac-relay") + srcData, err := os.ReadFile(selfPath) + if err != nil { + return "", fmt.Errorf("failed to read own binary: %w", err) + } + if err := os.WriteFile(destPath, srcData, 0755); err != nil { + return "", fmt.Errorf("failed to write binary to .app bundle: %w", err) + } + + // Codesign so macOS recognizes it + exec.Command("codesign", "--force", "--sign", "-", appDir).Run() + + return destPath, nil +} + +// runSetup installs the .app bundle and LaunchAgent plist, then starts the service. +func runSetup() { + log.Println("=== nac-relay setup ===") + log.Println() + + log.Println("Installing .app bundle...") + binPath, err := installAppBundle() + if err != nil { + log.Fatalf("Failed to install .app bundle: %v", err) + } + log.Printf("✓ Installed: %s", binPath) + log.Println() + + home, _ := os.UserHomeDir() + plistPath := filepath.Join(home, "Library", "LaunchAgents", "com.imessage.nac-relay.plist") + plistContent := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>com.imessage.nac-relay</string> + <key>ProgramArguments</key> + <array> + <string>%s</string> + </array> + <key>RunAtLoad</key> + <true/> + <key>KeepAlive</key> + <true/> + <key>StandardOutPath</key> + <string>/tmp/nac-relay.log</string> + <key>StandardErrorPath</key> + <string>/tmp/nac-relay.log</string> +</dict> +</plist>`, binPath) + + exec.Command("launchctl", "unload", plistPath).Run() + os.MkdirAll(filepath.Dir(plistPath), 0755) + if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil { + log.Fatalf("Failed to write LaunchAgent: %v", err) + } + log.Printf("✓ LaunchAgent: %s", plistPath) + + if err := exec.Command("launchctl", "load", plistPath).Run(); err != nil { + log.Printf("WARNING: failed to start service: %v", err) + } else { + log.Println("✓ Service started") + } + + log.Println() + log.Println("=== Setup complete! ===") + log.Println("Logs: tail -f /tmp/nac-relay.log") +} + +// authMiddleware wraps an http.Handler and requires a bearer token on all +// endpoints except /health. +func authMiddleware(token string, next http.Handler) http.Handler { + expected := "Bearer " + token + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Allow /health without auth for monitoring/probing + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + auth := r.Header.Get("Authorization") + if auth != expected { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/tools/nac-relay/main.go b/tools/nac-relay/main.go new file mode 100644 index 00000000..d4444dcf --- /dev/null +++ b/tools/nac-relay/main.go @@ -0,0 +1,139 @@ +// nac-relay: Runs on a Mac and serves NAC validation data over HTTPS with +// bearer-token auth. The Linux bridge calls this instead of running the +// x86_64 NAC emulator locally. +// +// On first run, a self-signed TLS certificate and random bearer token are +// generated and stored in ~/Library/Application Support/nac-relay/. +// extract-key reads relay-info.json from the same directory and embeds +// the token + cert fingerprint into the hardware key. +// +// Usage: +// go run tools/nac-relay/main.go [-port 5001] [-addr 0.0.0.0] +// +// Endpoints (all require Authorization: Bearer <token> except /health): +// POST /validation-data → base64-encoded validation data +// GET /health → "ok" (no auth required) +package main + +/* +#cgo CFLAGS: -x objective-c -DNAC_NO_MAIN -fobjc-arc +#cgo LDFLAGS: -framework Foundation + +// Inline the validation_data.m source +#include "../../nac-validation/src/validation_data.m" +*/ +import "C" + +import ( + "encoding/base64" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "sync" + "time" + "unsafe" +) + +var nacMu sync.Mutex // serialize NAC calls (framework may not be thread-safe) + +func generateValidationData() ([]byte, error) { + nacMu.Lock() + defer nacMu.Unlock() + + var buf *C.uint8_t + var bufLen C.size_t + var errBuf *C.char + + result := C.nac_generate_validation_data(&buf, &bufLen, &errBuf) + if result != 0 { + errMsg := "unknown error" + if errBuf != nil { + errMsg = C.GoString(errBuf) + C.free(unsafe.Pointer(errBuf)) + } + return nil, fmt.Errorf("NAC error %d: %s", result, errMsg) + } + + data := C.GoBytes(unsafe.Pointer(buf), C.int(bufLen)) + C.free(unsafe.Pointer(buf)) + return data, nil +} + +func main() { + if runtime.GOOS != "darwin" { + fmt.Fprintln(os.Stderr, "nac-relay must run on macOS") + os.Exit(1) + } + + addr := flag.String("addr", "0.0.0.0", "Address to bind to") + port := flag.Int("port", 5001, "Port to listen on") + setup := flag.Bool("setup", false, "Install .app bundle and LaunchAgent, then start service") + flag.Parse() + + if *setup { + runSetup() + return + } + + // Test that NAC works on startup + log.Println("Testing NAC validation data generation...") + start := time.Now() + vd, err := generateValidationData() + if err != nil { + log.Fatalf("NAC test failed: %v", err) + } + log.Printf("NAC test OK: %d bytes in %v", len(vd), time.Since(start)) + + http.HandleFunc("/validation-data", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + start := time.Now() + data, err := generateValidationData() + if err != nil { + log.Printf("ERROR: NAC generation failed: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + b64 := base64.StdEncoding.EncodeToString(data) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(b64)) + log.Printf("Served %d bytes of validation data in %v (from %s)", + len(data), time.Since(start), r.RemoteAddr) + }) + + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + // Set up TLS + bearer token auth + tlsConfig, token, err := ensureRelayAuth() + if err != nil { + log.Fatalf("Failed to initialize TLS/auth: %v", err) + } + + listenAddr := fmt.Sprintf("%s:%d", *addr, *port) + log.Printf("NAC relay listening on %s (HTTPS)", listenAddr) + + // Print helpful info + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + log.Printf(" → Bridge relay URL: https://%s:%d/validation-data", ipnet.IP, *port) + } + } + } + log.Println("Use -relay <url> when running extract-key to embed this URL in the hardware key.") + + server := &http.Server{ + Addr: listenAddr, + Handler: authMiddleware(token, http.DefaultServeMux), + TLSConfig: tlsConfig, + } + log.Fatal(server.ListenAndServeTLS("", "")) +} diff --git a/urlpreview.go b/urlpreview.go deleted file mode 100644 index c2a67765..00000000 --- a/urlpreview.go +++ /dev/null @@ -1,190 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2021 Tulir Asokan, Sumner Evans -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <https://www.gnu.org/licenses/>. - -package main - -import ( - "bytes" - "context" - "encoding/json" - "image" - "io/ioutil" - "net/http" - "time" - - "github.com/tidwall/gjson" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/imessage" -) - -type BeeperLinkPreview struct { - MatchedURL string `json:"matched_url"` - CanonicalURL string `json:"og:url,omitempty"` - Title string `json:"og:title,omitempty"` - Type string `json:"og:type,omitempty"` - Description string `json:"og:description,omitempty"` - - ImageURL id.ContentURIString `json:"og:image,omitempty"` - ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` - - ImageSize int `json:"matrix:image:size,omitempty"` - ImageWidth int `json:"og:image:width,omitempty"` - ImageHeight int `json:"og:image:height,omitempty"` - ImageType string `json:"og:image:type,omitempty"` -} - -func (portal *Portal) convertURLPreviewToIMessage(evt *event.Event) (output *imessage.RichLink) { - rawPreview := gjson.GetBytes(evt.Content.VeryRaw, `com\.beeper\.linkpreviews`) - if !rawPreview.Exists() || !rawPreview.IsArray() { - return - } - var previews []BeeperLinkPreview - if err := json.Unmarshal([]byte(rawPreview.Raw), &previews); err != nil || len(previews) == 0 { - return - } - // iMessage only supports a single preview. - preview := previews[0] - if len(preview.MatchedURL) == 0 && len(preview.CanonicalURL) == 0 { - return - } - - output = &imessage.RichLink{ - OriginalURL: preview.MatchedURL, - URL: preview.CanonicalURL, - Title: preview.Title, - Summary: preview.Description, - ItemType: preview.Type, - } - - if output.URL == "" { - output.URL = preview.MatchedURL - } else if output.OriginalURL == "" { - output.OriginalURL = preview.CanonicalURL - } - - if preview.ImageURL != "" || preview.ImageEncryption != nil { - output.Image = &imessage.RichLinkAsset{ - MimeType: preview.ImageType, - Size: &imessage.RichLinkAssetSize{ - Width: float64(preview.ImageWidth), - Height: float64(preview.ImageHeight), - }, - Source: &imessage.RichLinkAssetSource{}, - } - - var contentUri id.ContentURI - var err error - if preview.ImageURL == "" { - contentUri, err = preview.ImageEncryption.URL.Parse() - } else { - contentUri, err = preview.ImageURL.Parse() - } - if err != nil { - return - } - - imgBytes, err := portal.bridge.Bot.DownloadBytes(contentUri) - if err != nil { - return - } - - if preview.ImageEncryption != nil { - err = preview.ImageEncryption.DecryptInPlace(imgBytes) - if err != nil { - return - } - } - output.Image.Source.Data = imgBytes - } - return -} - -func (portal *Portal) convertRichLinkToBeeper(richLink *imessage.RichLink) (output *BeeperLinkPreview) { - if richLink == nil { - return - } - description := richLink.SelectedText - if description == "" { - description = richLink.Summary - } - - output = &BeeperLinkPreview{ - MatchedURL: richLink.OriginalURL, - CanonicalURL: richLink.URL, - Title: richLink.Title, - Description: description, - } - - if richLink.Image != nil { - if richLink.Image.Size != nil { - output.ImageWidth = int(richLink.Image.Size.Width) - output.ImageHeight = int(richLink.Image.Size.Height) - } - output.ImageType = richLink.ItemType - - if richLink.Image.Source != nil { - thumbnailData := richLink.Image.Source.Data - if thumbnailData == nil { - if url := richLink.Image.Source.URL; url != "" { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return - } - thumbnailData, _ = ioutil.ReadAll(req.Body) - } - } - - if output.ImageHeight == 0 || output.ImageWidth == 0 { - src, _, err := image.Decode(bytes.NewReader(thumbnailData)) - if err == nil { - imageBounds := src.Bounds() - output.ImageWidth, output.ImageHeight = imageBounds.Max.X, imageBounds.Max.Y - } - } - - output.ImageSize = len(thumbnailData) - if output.ImageType == "" { - output.ImageType = http.DetectContentType(thumbnailData) - } - - uploadData, uploadMime := thumbnailData, output.ImageType - if portal.Encrypted { - crypto := attachment.NewEncryptedFile() - crypto.EncryptInPlace(uploadData) - uploadMime = "application/octet-stream" - output.ImageEncryption = &event.EncryptedFileInfo{EncryptedFile: *crypto} - } - resp, err := portal.bridge.Bot.UploadBytes(uploadData, uploadMime) - if err != nil { - portal.log.Warnfln("Failed to reupload thumbnail for link preview: %v", err) - } else { - if output.ImageEncryption != nil { - output.ImageEncryption.URL = resp.ContentURI.CUString() - } else { - output.ImageURL = resp.ContentURI.CUString() - } - } - } - - } - - return -} diff --git a/user.go b/user.go deleted file mode 100644 index 3fed01a4..00000000 --- a/user.go +++ /dev/null @@ -1,250 +0,0 @@ -// mautrix-imessage - A Matrix-iMessage puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see <https://www.gnu.org/licenses/>. - -package main - -import ( - "errors" - "fmt" - "net/http" - "strings" - "sync" - - log "maunium.net/go/maulogger/v2" - - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/bridgeconfig" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-imessage/database" -) - -type User struct { - *database.User - - bridge *IMBridge - log log.Logger - - DoublePuppetIntent *appservice.IntentAPI - - mgmtCreateLock sync.Mutex - - spaceMembershipChecked bool - - BackfillQueue *BackfillQueue - backfillStatus BackfillStatus - backfillError error -} - -var _ bridge.User = (*User)(nil) - -func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel { - if user == user.bridge.user { - return bridgeconfig.PermissionLevelAdmin - } else if user.bridge.Config.Bridge.Relay.IsWhitelisted(user.MXID) { - return bridgeconfig.PermissionLevelRelay - } - return bridgeconfig.PermissionLevelBlock -} - -func (user *User) IsLoggedIn() bool { - return user == user.bridge.user -} - -func (user *User) GetManagementRoomID() id.RoomID { - return user.ManagementRoom -} - -func (user *User) SetManagementRoom(roomID id.RoomID) { - user.ManagementRoom = roomID - user.Update() -} - -func (user *User) GetMXID() id.UserID { - return user.MXID -} - -func (user *User) GetCommandState() map[string]interface{} { - return nil -} - -func (user *User) GetIDoublePuppet() bridge.DoublePuppet { - if user == user.bridge.user { - return user - } - return nil -} - -func (user *User) GetIGhost() bridge.Ghost { - return nil -} - -func (br *IMBridge) loadDBUser() *User { - dbUser := br.DB.User.GetByMXID(br.Config.Bridge.User) - if dbUser == nil { - dbUser = br.DB.User.New() - dbUser.MXID = br.Config.Bridge.User - dbUser.Insert() - } - user := br.NewUser(dbUser) - return user -} - -func (br *IMBridge) NewUser(dbUser *database.User) *User { - user := &User{ - User: dbUser, - bridge: br, - log: br.Log.Sub("User").Sub(string(dbUser.MXID)), - } - - return user -} - -func (user *User) getDirectChats() map[id.UserID][]id.RoomID { - res := make(map[id.UserID][]id.RoomID) - privateChats := user.bridge.DB.Portal.FindPrivateChats() - for _, portal := range privateChats { - if len(portal.MXID) > 0 { - // TODO Make FormatPuppetMXID work with chat GUIDs or add a field with the sender ID to portals - res[user.bridge.FormatPuppetMXID(portal.GUID)] = []id.RoomID{portal.MXID} - } - } - return res -} - -func (user *User) UpdateDirectChats(chats map[id.UserID][]id.RoomID) { - if !user.bridge.Config.Bridge.SyncDirectChatList || user.DoublePuppetIntent == nil { - return - } - method := http.MethodPatch - if chats == nil { - chats = user.getDirectChats() - method = http.MethodPut - } - user.log.Debugln("Updating m.direct list on homeserver") - var err error - if user.bridge.Config.Homeserver.Software == "asmux" { - url := user.DoublePuppetIntent.BuildClientURL("unstable", "com.beeper.asmux", "dms") - _, err = user.DoublePuppetIntent.MakeFullRequest(mautrix.FullRequest{ - Method: method, - URL: url, - RequestJSON: chats, - Headers: http.Header{"X-Asmux-Auth": {user.bridge.AS.Registration.AppToken}}, - }) - } else { - existingChats := make(map[id.UserID][]id.RoomID) - err = user.DoublePuppetIntent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats) - if err != nil { - user.log.Warnln("Failed to get m.direct list to update it:", err) - return - } - for userID, rooms := range existingChats { - if _, ok := user.bridge.ParsePuppetMXID(userID); !ok { - // This is not a ghost user, include it in the new list - chats[userID] = rooms - } else if _, ok := chats[userID]; !ok && method == http.MethodPatch { - // This is a ghost user, but we're not replacing the whole list, so include it too - chats[userID] = rooms - } - } - err = user.DoublePuppetIntent.SetAccountData(event.AccountDataDirectChats.Type, &chats) - } - if err != nil { - user.log.Warnln("Failed to update m.direct list:", err) - } -} - -func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) { - extraContent := map[string]interface{}{} - if isDirect { - extraContent["is_direct"] = true - } - if user.DoublePuppetIntent != nil { - extraContent["fi.mau.will_auto_accept"] = true - } - _, err := intent.InviteUser(roomID, &mautrix.ReqInviteUser{UserID: user.MXID}, extraContent) - var httpErr mautrix.HTTPError - if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") { - user.bridge.StateStore.SetMembership(roomID, user.MXID, event.MembershipJoin) - ok = true - return - } else if err != nil { - user.log.Warnfln("Failed to invite user to %s: %v", roomID, err) - } else { - ok = true - } - - if user.DoublePuppetIntent != nil { - err = user.DoublePuppetIntent.EnsureJoined(roomID) - if err != nil { - user.log.Warnfln("Failed to auto-join %s as %s: %v", roomID, user.MXID, err) - } - } - return -} - -func (user *User) GetSpaceRoom() id.RoomID { - if !user.bridge.Config.Bridge.PersonalFilteringSpaces { - return "" - } - - if len(user.SpaceRoom) == 0 { - name := "iMessage" - if user.bridge.Config.IMessage.Platform == "android" { - name = "Android SMS" - } - resp, err := user.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{ - Visibility: "private", - Name: name, - Topic: fmt.Sprintf("Your %s bridged chats", name), - InitialState: []*event.Event{{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: &event.RoomAvatarEventContent{ - URL: user.bridge.Config.AppService.Bot.ParsedAvatar, - }, - }, - }}, - CreationContent: map[string]interface{}{ - "type": event.RoomTypeSpace, - }, - PowerLevelOverride: &event.PowerLevelsEventContent{ - Users: map[id.UserID]int{ - user.bridge.Bot.UserID: 9001, - user.MXID: 100, - }, - }, - RoomVersion: "11", - }) - - if err != nil { - user.log.Errorln("Failed to auto-create space room:", err) - } else { - user.SpaceRoom = resp.RoomID - user.Update() - user.ensureInvited(user.bridge.Bot, user.SpaceRoom, false) - } - } else if !user.spaceMembershipChecked && !user.bridge.StateStore.IsInRoom(user.SpaceRoom, user.MXID) { - user.ensureInvited(user.bridge.Bot, user.SpaceRoom, false) - } - user.spaceMembershipChecked = true - - return user.SpaceRoom -}