mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-18 09:00:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bdd737d10 | ||
|
|
df253e786f | ||
|
|
53cd79b7da | ||
|
|
febebef554 | ||
|
|
23fe2eeca5 | ||
|
|
6f5de3a31d | ||
|
|
ea5929faf6 | ||
|
|
220c083e83 | ||
|
|
d778a5bc8a | ||
|
|
0a039628db | ||
|
|
b40fd5d2d6 | ||
|
|
5fb44c66e6 | ||
|
|
5d636749fa | ||
|
|
88b7382577 | ||
|
|
3ce39c1f39 | ||
|
|
6aa22c257d | ||
|
|
e5cf5e3de4 | ||
|
|
36733cfb75 | ||
|
|
ca760f111b | ||
|
|
bfb27b4104 | ||
|
|
5663fe7d25 |
No files matched your search
+20
-115
@@ -3,7 +3,27 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- 'CMakeLists.txt'
|
||||
- 'Makefile'
|
||||
- 'README.md'
|
||||
- 'config/**'
|
||||
- 'packaging/**'
|
||||
- 'scripts/**'
|
||||
- 'stubs/**'
|
||||
- 'tests/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- 'CMakeLists.txt'
|
||||
- 'Makefile'
|
||||
- 'README.md'
|
||||
- 'config/**'
|
||||
- 'packaging/**'
|
||||
- 'scripts/**'
|
||||
- 'stubs/**'
|
||||
- 'tests/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -38,118 +58,3 @@ jobs:
|
||||
bash tests/managed_payload_build_contract_test.sh .
|
||||
bash tests/flatpak_packaging_test.sh .
|
||||
bash tests/native_packaging_test.sh .
|
||||
|
||||
flatpak:
|
||||
name: Flatpak build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Flatpak builder
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes flatpak flatpak-builder gnupg
|
||||
flatpak remote-add --user --if-not-exists flathub \
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Build Flatpak repository and bundle
|
||||
shell: bash
|
||||
env:
|
||||
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
|
||||
FLATPAK_GPG_KEY_ID: ${{ secrets.FLATPAK_GPG_KEY_ID }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
git update-ref refs/heads/main "$GITHUB_SHA"
|
||||
builder_signing=()
|
||||
bundle_keys=()
|
||||
publish=false
|
||||
if [[ "$GITHUB_REF" == refs/heads/main && \
|
||||
"$GITHUB_EVENT_NAME" != pull_request ]]; then
|
||||
publish=true
|
||||
[[ -n "$FLATPAK_GPG_PRIVATE_KEY" && \
|
||||
-n "$FLATPAK_GPG_KEY_ID" ]]
|
||||
install -d -m 0700 "$RUNNER_TEMP/mocktail-gnupg"
|
||||
printf '%s' "$FLATPAK_GPG_PRIVATE_KEY" | \
|
||||
gpg --batch --homedir "$RUNNER_TEMP/mocktail-gnupg" --import
|
||||
gpg --batch --homedir "$RUNNER_TEMP/mocktail-gnupg" \
|
||||
--export "$FLATPAK_GPG_KEY_ID" >mocktail-flatpak.gpg
|
||||
builder_signing=(
|
||||
"--gpg-sign=$FLATPAK_GPG_KEY_ID"
|
||||
"--gpg-homedir=$RUNNER_TEMP/mocktail-gnupg"
|
||||
)
|
||||
bundle_keys=(--gpg-keys=mocktail-flatpak.gpg)
|
||||
fi
|
||||
flatpak-builder \
|
||||
--arch=x86_64 \
|
||||
--jobs=4 \
|
||||
--user \
|
||||
--install-deps-from=flathub \
|
||||
--disable-rofiles-fuse \
|
||||
--force-clean \
|
||||
--default-branch=stable \
|
||||
--repo=repo \
|
||||
"${builder_signing[@]}" \
|
||||
build-flatpak-ci \
|
||||
packaging/flatpak/space.bigrat.mocktail.json
|
||||
if [[ "$publish" == true ]]; then
|
||||
flatpak build-update-repo \
|
||||
--generate-static-deltas \
|
||||
--static-delta-jobs=2 \
|
||||
--prune \
|
||||
--gpg-sign="$FLATPAK_GPG_KEY_ID" \
|
||||
--gpg-homedir="$RUNNER_TEMP/mocktail-gnupg" \
|
||||
repo
|
||||
fi
|
||||
flatpak build-bundle \
|
||||
--arch=x86_64 \
|
||||
"${bundle_keys[@]}" \
|
||||
repo \
|
||||
Mocktail-x86_64.flatpak \
|
||||
space.bigrat.mocktail \
|
||||
stable
|
||||
if [[ "$publish" == true ]]; then
|
||||
./scripts/assemble_flatpak_pages.sh \
|
||||
repo Mocktail-x86_64.flatpak mocktail-flatpak.gpg public
|
||||
fi
|
||||
|
||||
- name: Upload Flatpak
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Mocktail-x86_64-flatpak
|
||||
path: Mocktail-x86_64.flatpak
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Upload GitHub Pages repository
|
||||
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: public
|
||||
|
||||
deploy-pages:
|
||||
name: Publish Flatpak repository
|
||||
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
needs: flatpak
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
@@ -0,0 +1,193 @@
|
||||
name: Flatpak
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Native packages]
|
||||
types: [completed]
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/flatpak.yml'
|
||||
- '.gitmodules'
|
||||
- 'CMakeLists.txt'
|
||||
- 'LICENSE'
|
||||
- 'assets/screenshots/**'
|
||||
- 'cmake/**'
|
||||
- 'config/**'
|
||||
- 'include/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'packaging/discord-join.html'
|
||||
- 'packaging/icons/**'
|
||||
- 'packaging/space.bigrat.mocktail.desktop'
|
||||
- 'packaging/space.bigrat.mocktail.metainfo.xml'
|
||||
- 'packaging/space.bigrat.mocktail.svg'
|
||||
- 'scripts/assemble_flatpak_pages.sh'
|
||||
- 'scripts/assemble_native_repositories.sh'
|
||||
- 'site/**'
|
||||
- 'src/**'
|
||||
- 'stubs/**'
|
||||
- 'third_party/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: flatpak-${{ github.event.workflow_run.head_sha || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
flatpak:
|
||||
name: Flatpak build
|
||||
if: >-
|
||||
github.event_name != 'workflow_run' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event != 'pull_request' &&
|
||||
github.event.workflow_run.head_branch == 'main')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
MOCKTAIL_PUBLISH: ${{ (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'pull_request' && github.event.workflow_run.head_branch == 'main') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') }}
|
||||
MOCKTAIL_SOURCE_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Download package repositories
|
||||
if: env.MOCKTAIL_PUBLISH == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
run_id="${{ github.event.workflow_run.id }}"
|
||||
if [[ -z "$run_id" ]]; then
|
||||
run_id="$(gh run list \
|
||||
--workflow packages.yml \
|
||||
--branch main \
|
||||
--event push \
|
||||
--status success \
|
||||
--limit 1 \
|
||||
--json databaseId \
|
||||
--jq '.[0].databaseId')"
|
||||
fi
|
||||
[[ -n "$run_id" ]]
|
||||
gh run download "$run_id" \
|
||||
--name Mocktail-native-repositories \
|
||||
--dir native-repositories
|
||||
[[ -d native-repositories/apt && \
|
||||
-d native-repositories/rpm && \
|
||||
-d native-repositories/downloads && \
|
||||
-s native-repositories/mocktail-packages.gpg ]]
|
||||
|
||||
- name: Install Flatpak builder
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes flatpak flatpak-builder gnupg
|
||||
flatpak remote-add --user --if-not-exists flathub \
|
||||
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Build Flatpak repository and bundle
|
||||
shell: bash
|
||||
env:
|
||||
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
|
||||
FLATPAK_GPG_KEY_ID: ${{ secrets.FLATPAK_GPG_KEY_ID }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
git update-ref refs/heads/main "$MOCKTAIL_SOURCE_SHA"
|
||||
builder_signing=()
|
||||
bundle_keys=()
|
||||
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
|
||||
[[ -n "$FLATPAK_GPG_PRIVATE_KEY" && \
|
||||
-n "$FLATPAK_GPG_KEY_ID" ]]
|
||||
install -d -m 0700 "$RUNNER_TEMP/mocktail-gnupg"
|
||||
printf '%s' "$FLATPAK_GPG_PRIVATE_KEY" | \
|
||||
gpg --batch --homedir "$RUNNER_TEMP/mocktail-gnupg" --import
|
||||
gpg --batch --homedir "$RUNNER_TEMP/mocktail-gnupg" \
|
||||
--export "$FLATPAK_GPG_KEY_ID" >mocktail-flatpak.gpg
|
||||
builder_signing=(
|
||||
"--gpg-sign=$FLATPAK_GPG_KEY_ID"
|
||||
"--gpg-homedir=$RUNNER_TEMP/mocktail-gnupg"
|
||||
)
|
||||
bundle_keys=(--gpg-keys=mocktail-flatpak.gpg)
|
||||
fi
|
||||
flatpak-builder \
|
||||
--arch=x86_64 \
|
||||
--jobs=4 \
|
||||
--user \
|
||||
--install-deps-from=flathub \
|
||||
--disable-rofiles-fuse \
|
||||
--force-clean \
|
||||
--default-branch=stable \
|
||||
--repo=repo \
|
||||
"${builder_signing[@]}" \
|
||||
build-flatpak-ci \
|
||||
packaging/flatpak/space.bigrat.mocktail.json
|
||||
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
|
||||
flatpak build-update-repo \
|
||||
--generate-static-deltas \
|
||||
--static-delta-jobs=2 \
|
||||
--prune \
|
||||
--gpg-sign="$FLATPAK_GPG_KEY_ID" \
|
||||
--gpg-homedir="$RUNNER_TEMP/mocktail-gnupg" \
|
||||
repo
|
||||
fi
|
||||
flatpak build-bundle \
|
||||
--arch=x86_64 \
|
||||
"${bundle_keys[@]}" \
|
||||
repo \
|
||||
Mocktail-x86_64.flatpak \
|
||||
space.bigrat.mocktail \
|
||||
stable
|
||||
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
|
||||
./scripts/assemble_flatpak_pages.sh \
|
||||
repo Mocktail-x86_64.flatpak mocktail-flatpak.gpg \
|
||||
public native-repositories
|
||||
fi
|
||||
|
||||
- name: Upload Flatpak
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Mocktail-x86_64-flatpak
|
||||
path: Mocktail-x86_64.flatpak
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
if: env.MOCKTAIL_PUBLISH == 'true'
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Upload GitHub Pages repository
|
||||
if: env.MOCKTAIL_PUBLISH == 'true'
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: public
|
||||
|
||||
deploy-pages:
|
||||
name: Publish Flatpak repository
|
||||
if: >-
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event != 'pull_request' &&
|
||||
github.event.workflow_run.head_branch == 'main') ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
github.ref == 'refs/heads/main')
|
||||
needs: flatpak
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
@@ -3,7 +3,25 @@ name: Native packages
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.gitignore'
|
||||
- 'README.md'
|
||||
- 'assets/screenshots/**'
|
||||
- 'docs/**'
|
||||
- 'packaging/aur/**'
|
||||
- 'scripts/build_flatpak.sh'
|
||||
- 'tests/**'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.gitignore'
|
||||
- 'README.md'
|
||||
- 'assets/screenshots/**'
|
||||
- 'docs/**'
|
||||
- 'packaging/aur/**'
|
||||
- 'scripts/build_flatpak.sh'
|
||||
- 'tests/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -97,9 +115,11 @@ jobs:
|
||||
- name: Build DEB
|
||||
shell: bash
|
||||
run: |
|
||||
nightly_suffix=".nightly.$(git show -s --format=%cd --date=format:%Y%m%d%H%M%S "$GITHUB_SHA").g${GITHUB_SHA:0:8}"
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env MOCKTAIL_PACKAGE_VERSION_SUFFIX="$nightly_suffix" \
|
||||
ubuntu:26.04 bash -Eeuo pipefail -c '
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
@@ -114,6 +134,8 @@ jobs:
|
||||
cmake -S . -B build-deb -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DMOCKTAIL_PACKAGE_NAME=mocktail-nightly \
|
||||
-DMOCKTAIL_PACKAGE_VERSION_SUFFIX="$MOCKTAIL_PACKAGE_VERSION_SUFFIX" \
|
||||
-DBUILD_TESTING=OFF
|
||||
cmake --build build-deb --parallel 4
|
||||
cpack --config build-deb/CPackConfig.cmake \
|
||||
@@ -143,9 +165,11 @@ jobs:
|
||||
- name: Build RPM
|
||||
shell: bash
|
||||
run: |
|
||||
nightly_suffix=".nightly.$(git show -s --format=%cd --date=format:%Y%m%d%H%M%S "$GITHUB_SHA").g${GITHUB_SHA:0:8}"
|
||||
docker run --rm \
|
||||
--volume "$GITHUB_WORKSPACE:/workspace" \
|
||||
--workdir /workspace \
|
||||
--env MOCKTAIL_PACKAGE_VERSION_SUFFIX="$nightly_suffix" \
|
||||
fedora:44 bash -Eeuo pipefail -c '
|
||||
dnf install --assumeyes \
|
||||
gcc-c++ cmake git ninja-build pkgconf-pkg-config rpm-build \
|
||||
@@ -158,6 +182,8 @@ jobs:
|
||||
cmake -S . -B build-rpm -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DMOCKTAIL_PACKAGE_NAME=mocktail-nightly \
|
||||
-DMOCKTAIL_PACKAGE_VERSION_SUFFIX="$MOCKTAIL_PACKAGE_VERSION_SUFFIX" \
|
||||
-DBUILD_TESTING=OFF
|
||||
cmake --build build-rpm --parallel 4
|
||||
cpack --config build-rpm/CPackConfig.cmake \
|
||||
@@ -219,7 +245,7 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
needs: [appimage, deb, rpm, arch]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -235,13 +261,52 @@ jobs:
|
||||
path: release
|
||||
merge-multiple: true
|
||||
|
||||
- name: Prepare continuous packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mv -- release/*.AppImage release/mocktail-nightly.AppImage
|
||||
mv -- release/*.deb release/mocktail-nightly.deb
|
||||
mv -- release/*.rpm release/mocktail-nightly.rpm
|
||||
mv -- release/*.pkg.tar.zst release/mocktail-nightly.pkg.tar.zst
|
||||
find release -maxdepth 1 -type f -print
|
||||
|
||||
- name: Build package repositories
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
|
||||
FLATPAK_GPG_KEY_ID: ${{ secrets.FLATPAK_GPG_KEY_ID }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
[[ -n "$FLATPAK_GPG_PRIVATE_KEY" && -n "$FLATPAK_GPG_KEY_ID" ]]
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes --no-install-recommends \
|
||||
apt-utils createrepo-c gnupg rpm
|
||||
install -d stable/deb stable/rpm
|
||||
stable_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)"
|
||||
gh release download "$stable_tag" --pattern '*.deb' --dir stable/deb
|
||||
gh release download "$stable_tag" --pattern '*.rpm' --dir stable/rpm
|
||||
mapfile -t stable_debs < <(find stable/deb -maxdepth 1 -type f -name '*.deb' -print)
|
||||
mapfile -t stable_rpms < <(find stable/rpm -maxdepth 1 -type f -name '*.rpm' -print)
|
||||
[[ "${#stable_debs[@]}" -eq 1 && "${#stable_rpms[@]}" -eq 1 ]]
|
||||
install -d -m 0700 "$RUNNER_TEMP/mocktail-native-gnupg"
|
||||
printf '%s' "$FLATPAK_GPG_PRIVATE_KEY" | \
|
||||
gpg --batch --homedir "$RUNNER_TEMP/mocktail-native-gnupg" --import
|
||||
gpg --batch --homedir "$RUNNER_TEMP/mocktail-native-gnupg" \
|
||||
--export "$FLATPAK_GPG_KEY_ID" >mocktail-packages.gpg
|
||||
./scripts/assemble_native_repositories.sh \
|
||||
"${stable_debs[0]}" release/mocktail-nightly.deb \
|
||||
"${stable_rpms[0]}" release/mocktail-nightly.rpm \
|
||||
mocktail-packages.gpg "$FLATPAK_GPG_KEY_ID" \
|
||||
"$RUNNER_TEMP/mocktail-native-gnupg" native-repositories
|
||||
|
||||
- name: Update continuous release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
find release -maxdepth 1 -type f -print
|
||||
git config user.name komaruworld
|
||||
git config user.email rtysus228@gmail.com
|
||||
git tag --force continuous "$GITHUB_SHA"
|
||||
@@ -253,3 +318,20 @@ jobs:
|
||||
--notes "Automated development packages from main."
|
||||
fi
|
||||
gh release upload continuous release/* --clobber
|
||||
while IFS= read -r asset; do
|
||||
case "$asset" in
|
||||
mocktail-nightly.AppImage|mocktail-nightly.deb|mocktail-nightly.rpm|mocktail-nightly.pkg.tar.zst)
|
||||
;;
|
||||
Mocktail-*.AppImage|mocktail_*.deb|mocktail-*.deb|mocktail-*.rpm|mocktail-*.pkg.tar.zst)
|
||||
gh release delete-asset continuous "$asset" --yes
|
||||
;;
|
||||
esac
|
||||
done < <(gh release view continuous --json assets --jq '.assets[].name')
|
||||
|
||||
- name: Upload package repositories
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Mocktail-native-repositories
|
||||
path: native-repositories
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
+87
-2
@@ -8,7 +8,7 @@
|
||||
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(Mocktail
|
||||
VERSION 0.1.0
|
||||
VERSION 1.0.2
|
||||
DESCRIPTION "Android x86-64 Roblox compatibility runtime for Linux"
|
||||
LANGUAGES CXX
|
||||
)
|
||||
@@ -16,6 +16,40 @@ project(Mocktail
|
||||
include(GNUInstallDirs)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
set(MOCKTAIL_BUILD_GIT_COMMIT "unknown")
|
||||
set(MOCKTAIL_BUILD_GIT_DIRTY "unknown")
|
||||
find_package(Git QUIET)
|
||||
if(Git_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git")
|
||||
execute_process(
|
||||
COMMAND "${GIT_EXECUTABLE}" rev-parse --short=12 HEAD
|
||||
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
||||
RESULT_VARIABLE MOCKTAIL_GIT_REV_PARSE_RESULT
|
||||
OUTPUT_VARIABLE MOCKTAIL_GIT_REV_PARSE_OUTPUT
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
ERROR_QUIET
|
||||
)
|
||||
if(MOCKTAIL_GIT_REV_PARSE_RESULT EQUAL 0 AND
|
||||
MOCKTAIL_GIT_REV_PARSE_OUTPUT MATCHES "^[0-9a-fA-F]+$")
|
||||
string(TOLOWER "${MOCKTAIL_GIT_REV_PARSE_OUTPUT}"
|
||||
MOCKTAIL_BUILD_GIT_COMMIT)
|
||||
execute_process(
|
||||
COMMAND "${GIT_EXECUTABLE}" status --porcelain --untracked-files=normal
|
||||
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
||||
RESULT_VARIABLE MOCKTAIL_GIT_STATUS_RESULT
|
||||
OUTPUT_VARIABLE MOCKTAIL_GIT_STATUS_OUTPUT
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
ERROR_QUIET
|
||||
)
|
||||
if(MOCKTAIL_GIT_STATUS_RESULT EQUAL 0)
|
||||
if(MOCKTAIL_GIT_STATUS_OUTPUT STREQUAL "")
|
||||
set(MOCKTAIL_BUILD_GIT_DIRTY "false")
|
||||
else()
|
||||
set(MOCKTAIL_BUILD_GIT_DIRTY "true")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST
|
||||
"${CMAKE_SOURCE_DIR}/config/roblox_compatibility.json"
|
||||
CACHE FILEPATH
|
||||
@@ -26,6 +60,18 @@ set(MOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST
|
||||
CACHE FILEPATH
|
||||
"Fallback signing trust manifest used outside an installed runtime"
|
||||
)
|
||||
set(MOCKTAIL_DISCORD_APPLICATION_ID "1537088975720812655" CACHE STRING
|
||||
"Discord application ID baked into Mocktail Rich Presence releases"
|
||||
)
|
||||
string(LENGTH "${MOCKTAIL_DISCORD_APPLICATION_ID}"
|
||||
MOCKTAIL_DISCORD_APPLICATION_ID_LENGTH)
|
||||
if(NOT MOCKTAIL_DISCORD_APPLICATION_ID STREQUAL "" AND
|
||||
(NOT MOCKTAIL_DISCORD_APPLICATION_ID MATCHES "^[0-9]+$" OR
|
||||
MOCKTAIL_DISCORD_APPLICATION_ID_LENGTH LESS 17 OR
|
||||
MOCKTAIL_DISCORD_APPLICATION_ID_LENGTH GREATER 20))
|
||||
message(FATAL_ERROR
|
||||
"MOCKTAIL_DISCORD_APPLICATION_ID must be empty or a Discord snowflake")
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -221,6 +267,7 @@ target_include_directories(mocktail_legacy_jnivm PUBLIC
|
||||
add_library(mocktail_runtime STATIC
|
||||
src/runtime/command_line.cc
|
||||
src/runtime/crash_report_policy.cc
|
||||
src/runtime/discord_rpc.cc
|
||||
src/runtime/device_profile.cc
|
||||
src/runtime/environment.cc
|
||||
src/runtime/external_launch_broker.cc
|
||||
@@ -246,6 +293,7 @@ add_library(mocktail_runtime STATIC
|
||||
src/runtime/runtime_config_bootstrap.cc
|
||||
src/runtime/runtime_config_file.cc
|
||||
src/runtime/runtime_paths.cc
|
||||
src/runtime/session_log.cc
|
||||
src/runtime/single_instance_lock.cc
|
||||
src/runtime/startup_state_machine.cc
|
||||
src/runtime/supported_launch_policy.cc
|
||||
@@ -256,6 +304,20 @@ target_include_directories(mocktail_runtime PUBLIC include)
|
||||
target_include_directories(mocktail_runtime PUBLIC
|
||||
${MOCKTAIL_JNI_INCLUDE_DIRS}
|
||||
)
|
||||
target_compile_definitions(mocktail_runtime PRIVATE
|
||||
"MOCKTAIL_DISCORD_APPLICATION_ID=\"${MOCKTAIL_DISCORD_APPLICATION_ID}\""
|
||||
)
|
||||
set_property(SOURCE src/runtime/session_log.cc APPEND PROPERTY
|
||||
COMPILE_DEFINITIONS
|
||||
"MOCKTAIL_PROJECT_VERSION=\"${PROJECT_VERSION}\""
|
||||
"MOCKTAIL_BUILD_GIT_COMMIT=\"${MOCKTAIL_BUILD_GIT_COMMIT}\""
|
||||
"MOCKTAIL_BUILD_GIT_DIRTY=\"${MOCKTAIL_BUILD_GIT_DIRTY}\""
|
||||
"MOCKTAIL_BUILD_TYPE=\"$<CONFIG>\""
|
||||
"MOCKTAIL_BUILD_COMPILER_ID=\"${CMAKE_CXX_COMPILER_ID}\""
|
||||
"MOCKTAIL_BUILD_COMPILER_VERSION=\"${CMAKE_CXX_COMPILER_VERSION}\""
|
||||
"MOCKTAIL_BUILD_TARGET_SYSTEM=\"${CMAKE_SYSTEM_NAME}\""
|
||||
"MOCKTAIL_BUILD_TARGET_PROCESSOR=\"${CMAKE_SYSTEM_PROCESSOR}\""
|
||||
)
|
||||
mocktail_apply_compile_options(mocktail_runtime)
|
||||
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND
|
||||
CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 16)
|
||||
@@ -655,7 +717,20 @@ install(CODE "
|
||||
endif()
|
||||
")
|
||||
|
||||
set(CPACK_PACKAGE_NAME "mocktail")
|
||||
set(MOCKTAIL_PACKAGE_NAME "mocktail" CACHE STRING
|
||||
"Native package name"
|
||||
)
|
||||
set(MOCKTAIL_PACKAGE_VERSION_SUFFIX "" CACHE STRING
|
||||
"Native package version suffix"
|
||||
)
|
||||
if(NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail" AND
|
||||
NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
|
||||
message(FATAL_ERROR
|
||||
"MOCKTAIL_PACKAGE_NAME must be mocktail or mocktail-nightly")
|
||||
endif()
|
||||
|
||||
set(CPACK_PACKAGE_NAME "${MOCKTAIL_PACKAGE_NAME}")
|
||||
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}")
|
||||
set(CPACK_PACKAGE_VENDOR "Mocktail Project")
|
||||
set(CPACK_PACKAGE_CONTACT "komaruworld <rtysus228@gmail.com>")
|
||||
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/komaruworld/mocktail")
|
||||
@@ -677,6 +752,16 @@ set(CPACK_RPM_FILE_NAME RPM-DEFAULT)
|
||||
set(CPACK_RPM_PACKAGE_LICENSE "Apache-2.0")
|
||||
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
|
||||
set(CPACK_RPM_PACKAGE_AUTOREQPROV ON)
|
||||
|
||||
if(MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
|
||||
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail")
|
||||
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail")
|
||||
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail")
|
||||
else()
|
||||
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail-nightly")
|
||||
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail-nightly")
|
||||
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail-nightly")
|
||||
endif()
|
||||
include(CPack)
|
||||
|
||||
add_dependencies(mocktail all_stubs)
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
Copyright 2026 komaruworld
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.DEFAULT_GOAL := all
|
||||
.PHONY: all build release debug test apk install register-url-handler release-runtime portable portable-test standalone appimage flatpak run-flatpak run run-smoke run-unlimited run-game run-input run-resize run-network auto-run run-gles run-angle update-roblox update-auto update-auto-launch install-updater payload-status payload-rollback support-bundle clean submodules help
|
||||
.PHONY: all build release debug test apk install register-url-handler release-runtime portable portable-test standalone appimage flatpak run-flatpak run run-smoke run-unlimited run-game run-input run-resize run-network auto-run run-gles run-angle update-roblox update-auto update-auto-launch payload-status payload-rollback support-bundle clean submodules help
|
||||
|
||||
BUILD_DIR := build
|
||||
BINARY := $(BUILD_DIR)/mocktail
|
||||
@@ -134,16 +134,13 @@ run-angle: ## Run with ANGLE/Vulkan compatibility backend
|
||||
update-roblox: ## Validate and import the current x86_64 Roblox bundle cached by Sober
|
||||
@./scripts/update_roblox_payload.sh
|
||||
|
||||
update-auto: build ## Download latest Roblox, derive its ABI, canary twice and activate safely
|
||||
update-auto: build ## Download latest Roblox, derive its ABI, run two canaries, then activate
|
||||
@./$(BUILD_DIR)/mocktail_updater update
|
||||
|
||||
update-auto-launch: build ## Update Roblox and launch the verified current payload
|
||||
@./$(BUILD_DIR)/mocktail_updater update
|
||||
@./$(BINARY)
|
||||
|
||||
install-updater: ## Install and enable the six-hour systemd user update timer
|
||||
@./scripts/install_user_updater.sh
|
||||
|
||||
payload-status: build ## Show current and previous-good immutable payloads
|
||||
@./$(BUILD_DIR)/mocktail_updater status
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
[](LICENSE)
|
||||
|
||||
Mocktail runs the Android `x86_64` Roblox client on Linux. It provides the
|
||||
Android ABI and JNI pieces the client expects, then uses SDL3 and Vulkan or
|
||||
OpenGL for the Linux side.
|
||||
Android ABI and JNI pieces the client expects, then connects them to SDL3 and
|
||||
Vulkan or OpenGL on the Linux side.
|
||||
|
||||
Mocktail is still experimental and may break after a Roblox update. It is not
|
||||
affiliated with Roblox Corporation or VinegarHQ.
|
||||
Mocktail is an independent community project. It is not affiliated with Roblox
|
||||
Corporation or VinegarHQ and does not distribute the Roblox client.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -24,8 +24,17 @@ an update fails.
|
||||
|
||||
## Install with Flatpak
|
||||
|
||||
Install the stable release from Flathub:
|
||||
|
||||
```bash
|
||||
flatpak install --user https://komaruworld.github.io/mocktail/mocktail.flatpakref
|
||||
flatpak install flathub space.bigrat.mocktail
|
||||
flatpak run space.bigrat.mocktail
|
||||
```
|
||||
|
||||
Nightly builds are produced automatically from the latest `main` branch:
|
||||
|
||||
```bash
|
||||
flatpak install --user https://mocktail.bigrat.space/mocktail.flatpakref
|
||||
flatpak run space.bigrat.mocktail
|
||||
```
|
||||
|
||||
@@ -44,39 +53,53 @@ Use `mocktail` instead of `mocktail-git` to build the pinned release from
|
||||
source, or install the stable prebuilt package with `paru -S mocktail-bin` or
|
||||
`yay -S mocktail-bin`.
|
||||
|
||||
## Screenshots
|
||||
## Install with DNF
|
||||
|
||||
<details>
|
||||
<summary>Flatpak build</summary>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Other Linux packages
|
||||
|
||||
The latest AppImage, DEB, RPM, and Arch packages are attached to the
|
||||
[continuous release](https://github.com/komaruworld/mocktail/releases/tag/continuous).
|
||||
Fedora 44 users can install either the stable `mocktail` package or the
|
||||
`mocktail-nightly` package built from `main`:
|
||||
|
||||
```bash
|
||||
# AppImage
|
||||
chmod +x Mocktail-x86_64.AppImage
|
||||
./Mocktail-x86_64.AppImage
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install ./mocktail_*_amd64.deb
|
||||
|
||||
# Fedora
|
||||
sudo dnf install ./mocktail-*.x86_64.rpm
|
||||
|
||||
# Arch Linux
|
||||
sudo pacman -U ./mocktail-*-x86_64.pkg.tar.zst
|
||||
sudo curl -fsSL https://mocktail.bigrat.space/rpm/mocktail.repo \
|
||||
-o /etc/yum.repos.d/mocktail.repo
|
||||
sudo dnf install mocktail
|
||||
# or
|
||||
sudo dnf install mocktail-nightly
|
||||
```
|
||||
|
||||
## Install with APT
|
||||
|
||||
Ubuntu 26.04 users can install either the stable `mocktail` package or the
|
||||
`mocktail-nightly` package built from `main`:
|
||||
|
||||
```bash
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
sudo curl -fsSL https://mocktail.bigrat.space/mocktail-packages.gpg \
|
||||
-o /etc/apt/keyrings/mocktail.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/mocktail.gpg] https://mocktail.bigrat.space/apt mocktail main" | \
|
||||
sudo tee /etc/apt/sources.list.d/mocktail.list >/dev/null
|
||||
sudo apt update
|
||||
sudo apt install mocktail
|
||||
# or
|
||||
sudo apt install mocktail-nightly
|
||||
```
|
||||
|
||||
## Direct downloads
|
||||
|
||||
Direct AppImage, DEB, and RPM downloads are available from the
|
||||
[Website](https://mocktail.bigrat.space/) and
|
||||
[GitHub Releases](https://github.com/komaruworld/mocktail/releases).
|
||||
|
||||
<details>
|
||||
<summary>Screenshots</summary>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Building
|
||||
|
||||
Only Linux `x86_64` is supported. Building requires CMake 3.20+, Git,
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -5,7 +5,7 @@ include_guard(GLOBAL)
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(UTF8PROC REQUIRED IMPORTED_TARGET libutf8proc)
|
||||
pkg_check_modules(SDL3_TTF REQUIRED IMPORTED_TARGET sdl3-ttf)
|
||||
find_package(SDL3_ttf REQUIRED CONFIG)
|
||||
pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig)
|
||||
|
||||
get_filename_component(MOCKTAIL_INPUT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.."
|
||||
@@ -35,7 +35,7 @@ target_link_libraries(mocktail_input_runtime PUBLIC
|
||||
PkgConfig::UTF8PROC
|
||||
PRIVATE
|
||||
PkgConfig::FONTCONFIG
|
||||
PkgConfig::SDL3_TTF
|
||||
SDL3_ttf::SDL3_ttf
|
||||
)
|
||||
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
|
||||
mocktail_apply_compile_options(mocktail_input_runtime)
|
||||
|
||||
@@ -64,6 +64,32 @@ audio:
|
||||
# To pin output, copy an exact SDL device name printed during startup.
|
||||
output_device: default
|
||||
|
||||
integrations:
|
||||
discord_rpc:
|
||||
# Boolean (default: false): publish Mocktail activity to Discord Desktop.
|
||||
# This never signs in to Discord and never reads an account token.
|
||||
enabled: false
|
||||
# Boolean (default: true): show the current Roblox experience name.
|
||||
show_place_name: true
|
||||
# Boolean (default: true): show how long the current session has run.
|
||||
show_elapsed_time: true
|
||||
join:
|
||||
# Boolean (default: true): let friends open the current experience. When
|
||||
# Roblox provides a public server ID, the button targets that server.
|
||||
enabled: true
|
||||
# Boolean (default: true): never expose private or reserved joins.
|
||||
public_servers_only: true
|
||||
# button_label: Join Server
|
||||
# English defaults. Uncomment only the lines you want to customize.
|
||||
# text:
|
||||
# browsing: Browsing experiences
|
||||
# joining: Joining an experience
|
||||
# playing: "{place_name}"
|
||||
# state: Playing Roblox
|
||||
# unknown_place: Unknown experience
|
||||
# Advanced: overrides the application bundled by the package maintainer.
|
||||
# application_id: 123456789012345678
|
||||
|
||||
window:
|
||||
# Integer (default: 1280): initial window width in pixels.
|
||||
width: 1280
|
||||
@@ -86,6 +112,11 @@ updates:
|
||||
# backend, and promote only on success. An existing current payload is
|
||||
# preserved when probation fails.
|
||||
automatic: true
|
||||
# Boolean (default: true): testing track. Always try provider latest, even
|
||||
# when it has no verified payload profile, and do not download or activate
|
||||
# an older supported payload in its place. Exact HostAbi derivation and two
|
||||
# canaries are still required before latest can become current.
|
||||
testing_latest_only: true
|
||||
# String (default: apk-pure): direct x86_64 APK provider.
|
||||
source: apk-pure
|
||||
# Reserved for desktop update integrations. `mocktail_updater` itself never
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=Check, canary, and install the latest working Roblox for Mocktail
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=%h/.config/mocktail/updater.env
|
||||
ExecStart=/usr/bin/env ${MOCKTAIL_UPDATE_HELPER} update
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,12 +0,0 @@
|
||||
[Unit]
|
||||
Description=Periodically check Roblox updates for Mocktail
|
||||
|
||||
[Timer]
|
||||
OnBootSec=5m
|
||||
OnUnitActiveSec=6h
|
||||
RandomizedDelaySec=15m
|
||||
Persistent=true
|
||||
Unit=mocktail-update.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -19,16 +19,8 @@ namespace mocktail::compat {
|
||||
|
||||
using BionicAtForkCallback = void (*)(void);
|
||||
|
||||
// Registers Android's __register_atfork callback contract with the host
|
||||
// pthread runtime. Bionic associates registrations with dso_handle and
|
||||
// removes them when that DSO unloads. POSIX pthread_atfork has no matching
|
||||
// unregister operation, so the host bridge accepts dso_handle for ABI
|
||||
// compatibility but cannot use it for cleanup.
|
||||
//
|
||||
// Callback code must remain mapped for every later fork. In practice, a guest
|
||||
// DSO that has registered callbacks must stay loaded until process exit;
|
||||
// unloading is safe only when the process guarantees that it will never fork
|
||||
// again.
|
||||
// POSIX cannot unregister by dso_handle, so registered guest callbacks must
|
||||
// remain mapped for every later fork.
|
||||
int RegisterBionicAtFork(BionicAtForkCallback prepare,
|
||||
BionicAtForkCallback parent,
|
||||
BionicAtForkCallback child, void* dso_handle) noexcept;
|
||||
@@ -37,8 +29,6 @@ int RegisterBionicAtFork(BionicAtForkCallback prepare,
|
||||
|
||||
extern "C" {
|
||||
|
||||
// Exact LP64 Android/Bionic __register_atfork signature. The prefixed host
|
||||
// symbol is exported to the guest as "__register_atfork" by synthetic libc.so.
|
||||
int mocktail_bionic_register_atfork(
|
||||
mocktail::compat::BionicAtForkCallback prepare,
|
||||
mocktail::compat::BionicAtForkCallback parent,
|
||||
|
||||
@@ -22,11 +22,8 @@
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
// Android x86-64's malloc.h declares ten size_t fields in this order. This is
|
||||
// not the host's struct mallinfo: glibc's legacy type uses int,
|
||||
// and musl exposes no allocator-telemetry API. On hosts without a compatible
|
||||
// size_t-based telemetry API, BionicMallinfo returns an all-zero "unknown"
|
||||
// snapshot. Callers must not interpret that fallback as proof of zero usage.
|
||||
// Android uses size_t fields here; glibc uses int and musl has no equivalent.
|
||||
// An all-zero fallback means unknown, not zero usage.
|
||||
struct BionicMallinfoSnapshot {
|
||||
size_t arena = 0;
|
||||
size_t ordblks = 0;
|
||||
@@ -44,10 +41,7 @@ struct BionicLocaleState;
|
||||
using BionicLocale = BionicLocaleState*;
|
||||
using BionicThreadDestructor = void (*)(void*);
|
||||
|
||||
// Do not use the host socket structs here. Although glibc happens to match
|
||||
// Android LP64, musl declares cmsg_len and msg_controllen as 32-bit socklen_t.
|
||||
// Bionic x86-64 uses size_t for both, as audited from the vendored
|
||||
// bionic/libc/include/sys/socket.h.
|
||||
// Bionic uses size_t where musl uses 32-bit socklen_t.
|
||||
struct BionicMessageHeader {
|
||||
void* msg_name = nullptr;
|
||||
uint32_t msg_namelen = 0;
|
||||
@@ -64,35 +58,24 @@ struct BionicControlMessageHeader {
|
||||
int cmsg_type = 0;
|
||||
};
|
||||
|
||||
// Implements Android's exported CMSG_NXTHDR helper over the explicit Bionic
|
||||
// x86-64 msghdr/cmsghdr layout above. Malformed or truncated control buffers
|
||||
// fail closed with nullptr.
|
||||
// Malformed or truncated control buffers return nullptr.
|
||||
BionicControlMessageHeader* BionicCmsgNextHeader(
|
||||
BionicMessageHeader* message, BionicControlMessageHeader* current) noexcept;
|
||||
|
||||
// Registers one destructor for the current thread using libstdc++'s Itanium
|
||||
// C++ ABI implementation. That implementation is present on both supported
|
||||
// glibc and musl toolchains and preserves destructor execution at thread exit.
|
||||
// Uses libstdc++'s Itanium ABI on both supported libc implementations.
|
||||
int BionicCxaThreadAtExit(BionicThreadDestructor destructor, void* argument,
|
||||
void* dso_handle) noexcept;
|
||||
|
||||
// Fills the complete buffer from the Linux kernel RNG. The implementation
|
||||
// falls back to /dev/urandom only when getrandom is unavailable or blocked and
|
||||
// aborts rather than returning predictable or partially initialized bytes.
|
||||
// Falls back to /dev/urandom; never returns partial or predictable bytes.
|
||||
void BionicArc4RandomBuffer(void* buffer, size_t size) noexcept;
|
||||
|
||||
BionicMallinfoSnapshot BionicMallinfo() noexcept;
|
||||
bool BionicMallinfoHasHostTelemetry() noexcept;
|
||||
|
||||
// Formats one Linux errno value using Bionic's POSIX strerror_r contract.
|
||||
// Unlike glibc's GNU variant, success is reported as zero; truncation returns
|
||||
// -1 and sets errno to ERANGE while preserving the caller's errno on success.
|
||||
// POSIX strerror_r contract: zero on success, -1/ERANGE on truncation.
|
||||
int BionicStrError(int error_number, char* buffer, size_t buffer_size) noexcept;
|
||||
|
||||
// Bionic supports only C and C.UTF-8, whose integer parsing is identical. A
|
||||
// host C locale is installed only for the duration of each call, so ambient
|
||||
// process locale cannot change guest parsing and a Bionic locale pointer is
|
||||
// never mistaken for an incompatible host locale_t.
|
||||
// Parses under host C locale without passing an incompatible Bionic locale_t.
|
||||
long long BionicStrToLongLongLocale(const char* text, char** end, int base,
|
||||
BionicLocale locale) noexcept;
|
||||
unsigned long long BionicStrToUnsignedLongLongLocale(
|
||||
|
||||
@@ -23,30 +23,21 @@ namespace mocktail::compat {
|
||||
|
||||
using NativeThreadInitializer = void (*)();
|
||||
|
||||
// Pinned Bionic LP64 pthread_internal.h keeps total per-thread reservation
|
||||
// near 1 MiB by subtracting its 32 KiB alternate signal stack from the normal
|
||||
// thread stack. Host defaults are not ABI substitutes: musl's default is only
|
||||
// 128 KiB on the supported Void runtime.
|
||||
// Bionic reserves 32 KiB of its 1 MiB thread allocation for the signal stack.
|
||||
inline constexpr size_t kBionicLp64DefaultThreadStackSize =
|
||||
(1U * 1024U * 1024U) - (32U * 1024U);
|
||||
static_assert(kBionicLp64DefaultThreadStackSize == 1015808U);
|
||||
|
||||
// Used only after a Bionic-sized stack is rejected by the host at thread
|
||||
// creation time. The retry takes the largest of this floor, the requested
|
||||
// guest size, and the host default. That retains enough stack on musl without
|
||||
// shrinking glibc's default.
|
||||
// Retry floor when the host rejects the Bionic-sized stack.
|
||||
inline constexpr size_t kBionicLp64FallbackThreadStackSize = 2U * 1024U * 1024U;
|
||||
|
||||
// Installs the payload-owned per-thread initializer used by subsequently
|
||||
// created guest threads. Passing nullptr restores ordinary host behavior.
|
||||
// nullptr restores host behavior.
|
||||
void ConfigureBionicPthreadThreadInitializer(
|
||||
NativeThreadInitializer initializer) noexcept;
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
// Creates a host thread after rebuilding the supported POSIX attributes.
|
||||
// Rebuilding avoids passing ABI-private pthread_attr_t state from the Android
|
||||
// payload directly into glibc.
|
||||
// Rebuilds attributes instead of passing Android pthread_attr_t to glibc.
|
||||
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
|
||||
const pthread_attr_t* attr,
|
||||
void* (*start_routine)(void*),
|
||||
|
||||
@@ -31,9 +31,8 @@ constexpr uint32_t kBionicPthreadKeyValidFlag = uint32_t{1} << 31;
|
||||
|
||||
using BionicPthreadKeyDestructor = void (*)(void*);
|
||||
|
||||
// implementation of Bionic's pthread_key_* ABI. It owns only guest
|
||||
// keys registered through the synthetic Android libc.so, while ordinary host
|
||||
// code continues to use glibc pthread keys.
|
||||
// Implements Bionic's pthread_key_* ABI for keys registered through the
|
||||
// synthetic libc.so. Host code continues to use glibc pthread keys.
|
||||
class BionicPthreadKeyRuntime {
|
||||
public:
|
||||
static BionicPthreadKeyRuntime& Instance() noexcept;
|
||||
|
||||
@@ -62,7 +62,7 @@ enum class NativePreJniBootstrapStatus {
|
||||
kFailed,
|
||||
};
|
||||
|
||||
// Installs one exact-Build-ID experiment before libroblox constructors run.
|
||||
// Installs one exact-Build-ID profile before libroblox constructors run.
|
||||
// The caller owns policy: this function never enables itself from environment
|
||||
// variables and must only receive a profile selected from the loaded ELF ID.
|
||||
HostAbiExperimentResult
|
||||
|
||||
@@ -81,7 +81,7 @@ struct NativeAllocatorProfile {
|
||||
inline constexpr size_t kMaxHostBridgeEntries = 8;
|
||||
inline constexpr size_t kMaxConstructorRunRanges = 26;
|
||||
|
||||
// Build-ID-scoped native ABI knowledge for supported and experimental payload
|
||||
// Build-ID-scoped native ABI knowledge for supported and candidate payload
|
||||
// profiles. Keeping these addresses outside the legacy startup graph makes the
|
||||
// version boundary reviewable and independently testable.
|
||||
struct HostAbiProfile {
|
||||
|
||||
@@ -28,11 +28,8 @@ struct FileSha256Result {
|
||||
explicit operator bool() const noexcept { return error.empty(); }
|
||||
};
|
||||
|
||||
// Computes a lowercase SHA-256 digest without invoking a shell or an external
|
||||
// utility. The external ABI approval receipt binds exact profile,
|
||||
// compatibility-manifest, and payload bytes through this implementation; the
|
||||
// runtime itself is bound by its stable GNU Build ID because installation may
|
||||
// rewrite RPATH bytes.
|
||||
// Approval receipts bind the profile, manifest, and payload to this digest.
|
||||
// Runtime identity uses its Build ID because installation may rewrite RPATH.
|
||||
FileSha256Result ComputeFileSha256(const std::string& path);
|
||||
|
||||
struct ExternalHostAbiProfileRequest {
|
||||
@@ -55,27 +52,19 @@ struct ExternalHostAbiProfileResult {
|
||||
explicit operator bool() const noexcept { return error.empty(); }
|
||||
};
|
||||
|
||||
// Loads one exact, payload-bound ABI profile into the process registry. A
|
||||
// candidate is accepted only by an explicitly authorized canary process. A
|
||||
// normal launch instead requires a separate approval receipt produced after
|
||||
// repeated Tier C readiness. Built-in profiles are immutable and always win.
|
||||
// Candidates require canary authorization; normal launches require an
|
||||
// approval receipt. Built-in profiles always win.
|
||||
ExternalHostAbiProfileResult LoadExternalHostAbiProfile(
|
||||
const ExternalHostAbiProfileRequest& request);
|
||||
|
||||
// Environment integration used by the pre-native compatibility gate. An
|
||||
// unset MOCKTAIL_HOST_ABI_PROFILE_FILE is a successful no-op. If it is set,
|
||||
// candidate authorization requires MOCKTAIL_HOST_ABI_CANARY=1,
|
||||
// MOCKTAIL_ALLOW_CANDIDATE_HOST_ABI=1, and the explicit research command-line
|
||||
// marker. Approved profiles require MOCKTAIL_HOST_ABI_APPROVAL_RECEIPT and
|
||||
// neither canary environment marker.
|
||||
// An unset profile is a no-op. Candidates require both canary flags and the
|
||||
// research CLI marker; approved profiles require a receipt and no canary flag.
|
||||
ExternalHostAbiProfileResult InitializeHostAbiProfileFromEnvironment(
|
||||
const std::string& payload_path,
|
||||
const std::string& compatibility_manifest_path,
|
||||
const std::string& expected_build_id,
|
||||
bool explicit_unverified_authorization);
|
||||
|
||||
// Registry lookup used by FindHostAbiProfile after its immutable built-in
|
||||
// table. Callers should use FindHostAbiProfile rather than this function.
|
||||
const HostAbiProfile* FindLoadedExternalHostAbiProfile(
|
||||
std::string_view build_id) noexcept;
|
||||
|
||||
|
||||
+15
-98
@@ -12,20 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// jnivm/jnivm.h — Pseudo Java Native Interface Virtual Machine.
|
||||
//
|
||||
// Provides a lightweight C++ implementation of a JVM stub for use with
|
||||
// Android-native shared libraries (e.g., libroblox.so) running on Linux.
|
||||
// No real Java bytecode is executed; instead, all JNI calls are intercepted
|
||||
// and dispatched to registered C++ callbacks.
|
||||
//
|
||||
// Usage:
|
||||
// auto vm = std::make_shared<jnivm::VM>();
|
||||
// auto cls = vm->RegisterClass("android/content/Context");
|
||||
// cls->RegisterMethod("getSystemService",
|
||||
// "(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
// [](JNIEnv*, jobject, ...) { return nullptr; });
|
||||
|
||||
#ifndef SOBER_TEST_JNIVM_JNIVM_H_
|
||||
#define SOBER_TEST_JNIVM_JNIVM_H_
|
||||
|
||||
@@ -42,29 +28,20 @@
|
||||
|
||||
namespace jnivm {
|
||||
|
||||
// Forward declarations
|
||||
|
||||
class VM;
|
||||
class Class;
|
||||
|
||||
// Object — base type for all pseudo-Java objects managed by the pseudo-JVM.
|
||||
// Analogous to java.lang.Object in the real JVM.
|
||||
|
||||
class Object {
|
||||
public:
|
||||
explicit Object(std::shared_ptr<Class> klass) : klass_(std::move(klass)) {}
|
||||
virtual ~Object() = default;
|
||||
|
||||
// Returns the pseudo-class descriptor of this object.
|
||||
std::shared_ptr<Class> GetClass() const { return klass_; }
|
||||
|
||||
private:
|
||||
std::shared_ptr<Class> klass_;
|
||||
};
|
||||
|
||||
// MethodCallback — signature for registered JNI method stubs.
|
||||
// The callback receives the raw JNIEnv pointer and the receiver jobject.
|
||||
|
||||
using MethodCallback = std::function<void(JNIEnv *, jobject)>;
|
||||
|
||||
// Resolved Roblox account identity supplied by the host runtime. This
|
||||
@@ -75,8 +52,6 @@ struct RobloxAuthIdentity {
|
||||
std::string display_name;
|
||||
};
|
||||
|
||||
// Host device capabilities exposed through the pseudo Android framework.
|
||||
// The guest ABI remains Android, while the default profile // describes a Linux desktop with physical mouse and keyboard input.
|
||||
struct PlatformIdentity {
|
||||
bool touch_enabled = false;
|
||||
bool mouse_enabled = true;
|
||||
@@ -99,23 +74,16 @@ struct RobloxCredentialView {
|
||||
|
||||
using RobloxCredentialProvider = RobloxCredentialView (*)(const void *context);
|
||||
|
||||
// Creates a Configuration object consistent with the VM's immutable platform
|
||||
// identity snapshot. This is the replacement for legacy construction of
|
||||
// contradictory Android keyboard/touch fields.
|
||||
jobject CreateAndroidConfiguration(JNIEnv *env);
|
||||
|
||||
// Narrow write-only boundary for credentials produced by Roblox after the
|
||||
// native runtime has started. A successfully persisted value supersedes the
|
||||
// bound provider for the remainder of the current VM lifetime so native sign-in
|
||||
// can become authoritative without reopening an untrusted credential source.
|
||||
// A stored credential replaces the bound provider for the rest of this VM's
|
||||
// lifetime.
|
||||
struct RobloxCredentialSinkCallbacks {
|
||||
bool (*store)(void *context, const char *data, std::size_t size) = nullptr;
|
||||
};
|
||||
|
||||
// Narrow host callback boundary for org/fmod/AudioDevice. The pseudo-JVM owns
|
||||
// only an opaque shared context and never depends on the SDL audio runtime.
|
||||
// Each Java receiver is passed as a stable identity so the host can keep
|
||||
// independent device lifecycles without retaining a JNI local reference.
|
||||
// The VM keeps the host context opaque. Receiver identities let the backend
|
||||
// track devices without retaining JNI local references.
|
||||
struct FmodAudioDeviceCallbacks {
|
||||
bool (*init)(void *context, const void *identity, int channels,
|
||||
int sample_rate_hz, int block_size_frames,
|
||||
@@ -126,17 +94,12 @@ struct FmodAudioDeviceCallbacks {
|
||||
void (*shutdown)(void *context) = nullptr;
|
||||
};
|
||||
|
||||
// Narrow host callback boundary for the activity.setWindowFlags(II)V contract.
|
||||
// Production LuaApp retains its activity through the android.app.Activity
|
||||
// interface, while GameActivity startup uses the concrete subclass. Guest JNI
|
||||
// threads only publish Android flags; the platform callback must marshal any
|
||||
// SDL work onto the SDL main thread.
|
||||
// Guest JNI threads only publish Android window flags. The host callback must
|
||||
// marshal SDL work onto the main thread.
|
||||
struct AndroidWindowCallbacks {
|
||||
bool (*set_flags)(void *context, int flags, int mask) = nullptr;
|
||||
};
|
||||
|
||||
// Immutable snapshot of the Android NativeTextBoxInfo payload supplied by
|
||||
// Roblox when a native text field receives focus.
|
||||
struct RobloxTextBoxInfo {
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
@@ -161,8 +124,7 @@ struct RobloxTextInputShowRequest {
|
||||
RobloxTextBoxInfo info;
|
||||
};
|
||||
|
||||
// Narrow callback boundary for the Roblox native text-input callbacks. The
|
||||
// pseudo-JVM snapshots all JNI-owned arguments before entering this boundary.
|
||||
// JNI-owned arguments are copied before the host callback runs.
|
||||
struct RobloxTextInputCallbacks {
|
||||
void (*show)(void *context,
|
||||
const RobloxTextInputShowRequest &request) = nullptr;
|
||||
@@ -172,9 +134,8 @@ struct RobloxTextInputCallbacks {
|
||||
void (*shutdown)(void *context) = nullptr;
|
||||
};
|
||||
|
||||
// Narrow boundary for a native MessageBus RawCallback object. The VM
|
||||
// retains context while a callback is in flight and dispatches only the exact
|
||||
// run(Ljava/lang/String;)V method on the exact created receiver.
|
||||
// Dispatch accepts run(String) only on a receiver created by this VM. The
|
||||
// context stays alive while the callback runs.
|
||||
struct MessageBusRawCallbacks {
|
||||
void (*run)(void *context, JNIEnv *env, jstring message) = nullptr;
|
||||
};
|
||||
@@ -183,18 +144,14 @@ struct MessageBusRequestHandlerCallbacks {
|
||||
std::string (*run)(void *context, JNIEnv *env, jstring message) = nullptr;
|
||||
};
|
||||
|
||||
// Narrow boundary for the current APK's legacy BrowserService transport.
|
||||
// Dispatch accepts only Callback.onItemSet(Ljava/lang/String;)V on the exact
|
||||
// pseudo receiver created by CreateMemStorageCallback.
|
||||
// Dispatch accepts Callback.onItemSet(String) only on a receiver created by
|
||||
// this VM. The context stays alive while the callback runs.
|
||||
struct MemStorageCallbackCallbacks {
|
||||
void (*on_item_set)(void *context, JNIEnv *env, jstring value) = nullptr;
|
||||
};
|
||||
|
||||
// Narrow boundary for the exact APK callbacks that update host web
|
||||
// state: NativeGLJavaInterface notifications, web activities, cookie sync,
|
||||
// and JNICookieProtocol.OnSetCookieHandler. The VM retains the shared callback
|
||||
// context while a callback is in flight; consumers must copy JNI strings
|
||||
// before returning.
|
||||
// Web callbacks share a retained context while in flight. Consumers must copy
|
||||
// JNI strings before returning.
|
||||
struct RobloxDataModelNotificationCallbacks {
|
||||
void (*on_notification)(void *context, JNIEnv *env, jstring type,
|
||||
jstring data) = nullptr;
|
||||
@@ -209,37 +166,22 @@ struct RobloxDataModelNotificationCallbacks {
|
||||
jstring url) = nullptr;
|
||||
};
|
||||
|
||||
// Narrow boundary for the exact NativeHelper lifecycle notification
|
||||
// emitted when Roblox returns from an experience to LuaApp.
|
||||
struct RobloxExperienceLifecycleCallbacks {
|
||||
void (*on_lua_app_did_return)(void *context) = nullptr;
|
||||
};
|
||||
|
||||
// Class — represents a pseudo Java class registered in the pseudo-JVM.
|
||||
// Stores the class name (JNI descriptor format) and a map of method stubs.
|
||||
|
||||
class Class {
|
||||
public:
|
||||
explicit Class(std::string name) : name_(std::move(name)) {}
|
||||
|
||||
// Returns the JNI-style class descriptor, e.g. "android/content/Context".
|
||||
const std::string &GetName() const { return name_; }
|
||||
|
||||
// Registers a JNI method stub.
|
||||
//
|
||||
// Args:
|
||||
// method_name: Simple method name (e.g. "getSystemService").
|
||||
// signature: JNI type descriptor (e.g. "(Ljava/lang/String;)V").
|
||||
// callback: C++ lambda invoked when the method is called via JNI.
|
||||
void RegisterMethod(const std::string &method_name,
|
||||
const std::string &signature, MethodCallback callback);
|
||||
|
||||
// Looks up a method stub by name and signature.
|
||||
// Returns nullptr if not found.
|
||||
const MethodCallback *FindMethod(const std::string &method_name,
|
||||
const std::string &signature) const;
|
||||
|
||||
// Returns all registered methods (name → callback).
|
||||
const std::unordered_map<std::string, MethodCallback> &GetMethods() const {
|
||||
return methods_;
|
||||
}
|
||||
@@ -247,57 +189,37 @@ public:
|
||||
private:
|
||||
std::string name_;
|
||||
|
||||
// Key format: "methodName:descriptor"
|
||||
std::unordered_map<std::string, MethodCallback> methods_;
|
||||
};
|
||||
|
||||
// VM — the top-level pseudo Java Virtual Machine.
|
||||
//
|
||||
// Owns a registry of pseudo-classes and exposes a JavaVM* compatible
|
||||
// pointer for passing to JNI_OnLoad in the loaded shared library.
|
||||
|
||||
class VM {
|
||||
public:
|
||||
VM();
|
||||
~VM();
|
||||
|
||||
// Disallow copy and assign.
|
||||
VM(const VM &) = delete;
|
||||
VM &operator=(const VM &) = delete;
|
||||
|
||||
// Registers a new pseudo-class by its JNI descriptor.
|
||||
// Repeated registration returns the existing instance.
|
||||
//
|
||||
// Args:
|
||||
// class_name: JNI-style descriptor, e.g.
|
||||
// "com/roblox/client/RobloxActivity".
|
||||
//
|
||||
// Returns:
|
||||
// Shared pointer to the registered (or pre-existing) Class object.
|
||||
// Repeated registration returns the existing class.
|
||||
std::shared_ptr<Class> RegisterClass(const std::string &class_name);
|
||||
|
||||
// Finds a previously registered class.
|
||||
// Returns nullptr if the class has not been registered.
|
||||
std::shared_ptr<Class> FindClass(const std::string &class_name) const;
|
||||
|
||||
// Returns the raw JavaVM pointer compatible with JNI_OnLoad signature.
|
||||
JavaVM *GetJavaVM() { return java_vm_; }
|
||||
|
||||
// Resolves the process pseudo-VM that owns an exact JavaVM pointer. Returns
|
||||
// null for foreign VMs and after the owner is destroyed.
|
||||
static VM *FromJavaVM(JavaVM *java_vm);
|
||||
|
||||
// Returns the attached JNIEnv pointer for the current host thread.
|
||||
JNIEnv *GetJNIEnv();
|
||||
|
||||
// Returns the number of registered classes.
|
||||
std::size_t GetClassCount() const { return class_registry_.size(); }
|
||||
|
||||
// Replaces the resolved account identity with an independent copy. Invalid
|
||||
// (non-positive) identities are normalized to the unresolved state.
|
||||
void SetRobloxAuthIdentity(const RobloxAuthIdentity &identity);
|
||||
|
||||
// Returns the VM to its unresolved account state.
|
||||
void ClearRobloxAuthIdentity();
|
||||
|
||||
// Returns an immutable-by-value snapshot safe for use by JNI callbacks on
|
||||
@@ -405,12 +327,10 @@ public:
|
||||
void ClearRobloxExperienceLifecycleCallbacks();
|
||||
bool DispatchRobloxExperienceLuaAppDidReturn();
|
||||
|
||||
// Restores env->functions to &native_interface_ in case libroblox replaced
|
||||
// it.
|
||||
// Restores the pseudo-JNI table after guest code replaces env->functions.
|
||||
void RestoreFunctions();
|
||||
|
||||
private:
|
||||
// Internal JNI function tables populated with stub implementations.
|
||||
JNIInvokeInterface_ invoke_interface_ = {};
|
||||
JavaVM java_vm_storage_ = {};
|
||||
JavaVM *java_vm_ = nullptr;
|
||||
@@ -419,7 +339,6 @@ private:
|
||||
JNIEnv jni_env_storage_ = {};
|
||||
JNIEnv *jni_env_ = nullptr;
|
||||
|
||||
// Registry of pseudo-classes indexed by their JNI descriptor.
|
||||
std::unordered_map<std::string, std::shared_ptr<Class>> class_registry_;
|
||||
|
||||
mutable std::mutex roblox_auth_identity_mutex_;
|
||||
@@ -498,8 +417,6 @@ private:
|
||||
std::shared_ptr<RobloxExperienceLifecycleBinding>
|
||||
roblox_experience_lifecycle_binding_;
|
||||
|
||||
// Initialises the JNIInvokeInterface_ and JNINativeInterface_ function tables
|
||||
// with stub implementations that forward calls to this VM instance.
|
||||
void InitJNIFunctionTables();
|
||||
};
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class RuntimeDependencies final {
|
||||
ShutdownBeforePlatformCallback shutdown_before_platform_ = nullptr;
|
||||
};
|
||||
|
||||
// Transitional reverse-engineering runtime. New subsystems must expose // interfaces and be composed outside this implementation.
|
||||
// Transitional runtime kept for the reverse-engineered startup path.
|
||||
int Run(const runtime::CommandLineOptions& options,
|
||||
RuntimeDependencies dependencies);
|
||||
|
||||
|
||||
@@ -12,17 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// libc_shim/libc_shim.h — Bionic libc → Glibc call translation layer.
|
||||
//
|
||||
// Intercepts calls made by Bionic-compiled code (libroblox.so) to
|
||||
// Bionic-specific libc functions and forwards them to their Glibc equivalents
|
||||
// on the host Linux system.
|
||||
//
|
||||
// Key responsibilities:
|
||||
// - pthread_mutex_t size/layout differences between Bionic and Glibc.
|
||||
// - Virtual filesystem path mapping (APK sandbox → Flatpak paths).
|
||||
// - Bionic-only syscall wrappers missing in Glibc.
|
||||
|
||||
#ifndef SOBER_TEST_LIBC_SHIM_LIBC_SHIM_H_
|
||||
#define SOBER_TEST_LIBC_SHIM_LIBC_SHIM_H_
|
||||
|
||||
@@ -57,10 +46,8 @@ struct HostCaBundleResolution {
|
||||
// boundary as the caller that will later free them.
|
||||
void ConfigureGuestAllocator(GuestAllocator allocator) noexcept;
|
||||
|
||||
// Installs all shim hooks. Must be called before the target .so is loaded.
|
||||
//
|
||||
// After this call, dlopen(3) symbol resolution for known Bionic symbols will
|
||||
// be intercepted and redirected to the shim implementations.
|
||||
// Must run before loading the target library so its Bionic imports resolve to
|
||||
// these shims.
|
||||
void Install();
|
||||
|
||||
// Resolves the host TLS trust bundle without weakening certificate
|
||||
@@ -76,29 +63,13 @@ HostCaBundleResolution ConfigureHostCaBundlePathMappings();
|
||||
|
||||
const char* HostCaBundleStatusName(HostCaBundleStatus status);
|
||||
|
||||
// Maps an Android-style virtual path to its Flatpak sandbox equivalent.
|
||||
//
|
||||
// Example:
|
||||
// "/data/user/0/com.roblox.client/files/logs" →
|
||||
// "/home/user/.var/app/org.vinegarhq.Sober/data/sober/logs"
|
||||
//
|
||||
// Args:
|
||||
// android_path: Absolute path as seen by the Bionic library.
|
||||
//
|
||||
// Returns:
|
||||
// Translated host path. Returns android_path unchanged if no mapping
|
||||
// is registered for it.
|
||||
// Applies the longest matching Android-to-host path prefix. Unmapped paths are
|
||||
// returned unchanged.
|
||||
std::string TranslatePath(const std::string& android_path);
|
||||
|
||||
// Registers a path prefix mapping used by TranslatePath.
|
||||
//
|
||||
// Args:
|
||||
// android_prefix: Android path prefix to intercept.
|
||||
// host_prefix: Replacement prefix on the host filesystem.
|
||||
void RegisterPathMapping(const std::string& android_prefix,
|
||||
const std::string& host_prefix);
|
||||
|
||||
// Removes all registered path mappings. Primarily for use in tests.
|
||||
void ClearPathMappings();
|
||||
|
||||
} // namespace libc_shim
|
||||
|
||||
+5
-67
@@ -12,18 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// linker/linker.h — ELF Bionic-compatible shared library loader.
|
||||
//
|
||||
// Provides a minimal interface for loading Android ELF shared libraries
|
||||
// (.so files compiled for Bionic libc / Android ABI) into a Linux process
|
||||
// address space, resolving dependencies and exporting a symbol table.
|
||||
//
|
||||
// Design notes:
|
||||
// - Uses dlopen(3) under the hood for system libraries.
|
||||
// - For Bionic-ABI libraries (libroblox.so), manual ELF parsing and
|
||||
// mmap(2)-based loading is required; this header exposes that interface.
|
||||
// - All paths follow POSIX conventions.
|
||||
|
||||
#ifndef MOCKTAIL_LINKER_LINKER_H_
|
||||
#define MOCKTAIL_LINKER_LINKER_H_
|
||||
|
||||
@@ -34,9 +22,7 @@
|
||||
|
||||
namespace linker {
|
||||
|
||||
// Opaque handle returned by LoadLibrary. Callers must not interpret the
|
||||
// pointer value; it is only valid as an argument to ResolveSymbol or
|
||||
// UnloadLibrary.
|
||||
// Opaque; valid only with this linker's resolve/unload functions.
|
||||
using LibraryHandle = void*;
|
||||
using SymbolMap = std::unordered_map<std::string, void*>;
|
||||
using AndroidLibraryLoadObserver = bool (*)(void* context,
|
||||
@@ -50,59 +36,27 @@ struct ProgramHeaderValidation {
|
||||
explicit operator bool() const noexcept { return valid; }
|
||||
};
|
||||
|
||||
// AddSyntheticLibrary — registers one Android SONAME and only the exports
|
||||
// owned by that library. Dependencies of subsequently loaded Android ELF
|
||||
// files resolve these symbols through mcpelauncher-linker.
|
||||
LibraryHandle AddSyntheticLibrary(const std::string& soname,
|
||||
const SymbolMap& exports);
|
||||
|
||||
// RegisterSyntheticSymbol — stages or adds one export to a specific Android
|
||||
// SONAME. If the synthetic library is already loaded, its symbol table is
|
||||
// updated in place.
|
||||
// Updates an already loaded library in place.
|
||||
void RegisterSyntheticSymbol(const std::string& soname,
|
||||
const std::string& name, void* address);
|
||||
|
||||
// Registers Android Bionic thread-local and synchronization ABIs only
|
||||
// for synthetic libc.so. Host code keeps its glibc implementations.
|
||||
// These adapters belong to their synthetic SONAME, never the host namespace.
|
||||
void RegisterBionicPthreadKeyRuntimeForLibc();
|
||||
|
||||
// Registers Android's DSO-aware __register_atfork entrypoint only for
|
||||
// synthetic libc.so. The host pthread bridge preserves callback order but
|
||||
// cannot unregister callbacks when a guest DSO unloads.
|
||||
void RegisterBionicAtForkRuntimeForLibc();
|
||||
|
||||
// Registers Android LP64 positioned I/O and seek entrypoints only for
|
||||
// synthetic libc.so. The bridge relies on the x86-64 host's 64-bit
|
||||
// off_t contract and never exposes host large-file feature-macro aliases.
|
||||
void RegisterBionicLargeFileRuntimeForLibc();
|
||||
|
||||
// Registers host-independent implementations of Android libc exports that
|
||||
// are not uniformly supplied by glibc and musl. Every symbol remains owned by
|
||||
// synthetic libc.so and preserves the Android x86-64 ABI.
|
||||
void RegisterBionicHostLibcRuntimeForLibc();
|
||||
|
||||
// Registers Bionic sysconf name translation only for synthetic libc.so.
|
||||
// Bionic and glibc use different integer values for sysconf names.
|
||||
void RegisterBionicSysconfRuntimeForLibc();
|
||||
|
||||
// Registers the Android LP64 sigaction layout adapter only for libc.so.
|
||||
void RegisterBionicSignalRuntimeForLibc();
|
||||
|
||||
// Registers the LP64 Bionic FILE/__sF ABI and host stdio translation only for
|
||||
// synthetic libc.so.
|
||||
void RegisterBionicStdioRuntimeForLibc();
|
||||
|
||||
// Restores mcpelauncher-linker's own libdl exports after legacy host-symbol
|
||||
// discovery. Android dlopen/dlsym must never escape into glibc.
|
||||
// Android dlopen/dlsym must not escape into glibc.
|
||||
void RegisterBionicDynamicLoaderForLibdl();
|
||||
|
||||
// GetSyntheticLibrarySymbols — returns a snapshot of one SONAME's exports.
|
||||
// The copy is safe to inspect while other libraries are being registered.
|
||||
SymbolMap GetSyntheticLibrarySymbols(const std::string& soname);
|
||||
|
||||
// UpdateAndroidLibraryPath — configures the Bionic linker's dependency search
|
||||
// path. Multiple directories use the same colon-separated syntax as
|
||||
// LD_LIBRARY_PATH.
|
||||
void UpdateAndroidLibraryPath(const std::string& search_path);
|
||||
|
||||
// Installs the single process owner that validates or adapts a real Android
|
||||
@@ -112,18 +66,12 @@ bool SetAndroidLibraryLoadObserver(AndroidLibraryLoadObserver observer,
|
||||
void* context);
|
||||
void ClearAndroidLibraryLoadObserver(void* context);
|
||||
|
||||
// OpenAndroidLibrary — loads a real Android ELF and registers its handle under
|
||||
// logical_name. The containing directory is added to the Bionic dependency
|
||||
// search path before loading.
|
||||
LibraryHandle OpenAndroidLibrary(const std::string& real_path,
|
||||
const std::string& logical_name);
|
||||
|
||||
// Legacy compatibility bridge. New code should use AddSyntheticLibrary or
|
||||
// OpenAndroidLibrary so real files and synthetic SONAMEs cannot be confused.
|
||||
LibraryHandle LoadLibrary(const std::string& real_path,
|
||||
const std::string& host_alias);
|
||||
|
||||
// ResolveSymbol — looks up an exported symbol in a loaded library.
|
||||
void* ResolveSymbol(LibraryHandle handle, const std::string& symbol_name);
|
||||
|
||||
// Crashpad execution is intentionally outside Mocktail's supported JNI
|
||||
@@ -132,24 +80,15 @@ void* ResolveSymbol(LibraryHandle handle, const std::string& symbol_name);
|
||||
bool IsBlockedRobloxCrashReportNativeSymbol(
|
||||
std::string_view symbol_name) noexcept;
|
||||
|
||||
// UnloadLibrary — releases resources associated with a loaded library.
|
||||
void UnloadLibrary(LibraryHandle handle);
|
||||
|
||||
// Legacy compatibility bridge used by the current monolithic startup path.
|
||||
// New code should use RegisterSyntheticSymbol and assign every export to its
|
||||
// owning SONAME.
|
||||
void RegisterSymbol(const std::string& name, void* addr);
|
||||
|
||||
// Returns the legacy accumulated symbol table. It remains available until the
|
||||
// existing startup path is migrated to per-SONAME maps.
|
||||
const SymbolMap& GetBionicSymbols();
|
||||
|
||||
// Returns mcpelauncher-linker's Bionic dl_iterate_phdr entrypoint. Android
|
||||
// unwinders must not receive glibc's host implementation/layout.
|
||||
// Android unwinders must not receive glibc's implementation or layout.
|
||||
void* BionicDlIteratePhdrAddress();
|
||||
|
||||
// Confirms that the Bionic program-header iterator can see a manually loaded
|
||||
// Android ELF, including the unwind metadata needed for C++ exceptions.
|
||||
ProgramHeaderValidation ValidateBionicProgramHeaders(LibraryHandle handle);
|
||||
|
||||
// Returns the mapped ELF base for a loaded Android library alias. The lookup
|
||||
@@ -157,7 +96,6 @@ ProgramHeaderValidation ValidateBionicProgramHeaders(LibraryHandle handle);
|
||||
// alias or a host-dlopen library without Bionic image metadata.
|
||||
std::uintptr_t FindLoadedAndroidLibraryBase(const std::string& logical_name);
|
||||
|
||||
// SymbolTable — global registry of alias → handle mappings.
|
||||
const std::unordered_map<std::string, LibraryHandle>& GetSymbolTable();
|
||||
|
||||
} // namespace linker
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
// Interleaved PCM formats accepted at the host audio boundary. Endianness is
|
||||
// explicit because Android x86-64 producers use little-endian PCM regardless
|
||||
// of the host implementation chosen by SDL.
|
||||
// Android producers supply little-endian interleaved PCM.
|
||||
enum class PcmSampleFormat {
|
||||
kUnsigned8,
|
||||
kSigned16LittleEndian,
|
||||
@@ -41,11 +39,9 @@ struct PcmSpec {
|
||||
using AudioBufferReleaseCallback = void (*)(void* context, const void* data,
|
||||
std::size_t size_bytes);
|
||||
|
||||
// A PCM submission. With no release callback, Enqueue() copies the bytes
|
||||
// before returning. With a release callback, the sink may borrow the buffer;
|
||||
// the caller must keep it valid until the callback runs. A successful
|
||||
// submission invokes the callback exactly once, including when Clear() or
|
||||
// Shutdown() discards queued audio. A failed submission never invokes it.
|
||||
// Without a callback Enqueue copies the bytes. With one, a successful Enqueue
|
||||
// borrows them until exactly one release callback, including on Clear or
|
||||
// Shutdown. On failure the callback is not invoked.
|
||||
struct PcmBuffer {
|
||||
const void* data = nullptr;
|
||||
std::size_t size_bytes = 0;
|
||||
@@ -57,9 +53,7 @@ Status ValidatePcmSpec(const PcmSpec& spec);
|
||||
std::size_t BytesPerSample(PcmSampleFormat format);
|
||||
std::size_t BytesPerFrame(const PcmSpec& spec);
|
||||
|
||||
// Thread-safe PCM playback contract. Implementations perform device format
|
||||
// conversion and resampling through their host audio library; callers always
|
||||
// submit data in source_spec().
|
||||
// Thread-safe; callers submit source_spec(), and the backend converts it.
|
||||
class AudioSink {
|
||||
public:
|
||||
virtual ~AudioSink() = default;
|
||||
@@ -68,15 +62,14 @@ class AudioSink {
|
||||
virtual Status Enqueue(const PcmBuffer& buffer) = 0;
|
||||
virtual Status Pause() = 0;
|
||||
virtual Status Resume() = 0;
|
||||
// Linear gain is delegated to the host audio library. Zero mutes, one is
|
||||
// unity gain, and negative or non-finite values are rejected.
|
||||
// Zero mutes; one is unity; negative and non-finite values are invalid.
|
||||
virtual Status SetGain(float linear_gain) = 0;
|
||||
virtual Status Flush() = 0;
|
||||
virtual Status Clear() = 0;
|
||||
virtual Status GetQueuedBytes(std::size_t* size_bytes) const = 0;
|
||||
|
||||
// Idempotent. It waits for in-flight API calls and releases all borrowed
|
||||
// buffers before returning. Calls started after shutdown fail closed.
|
||||
// Idempotent. Waits for in-flight calls and borrowed buffers; later calls
|
||||
// fail closed.
|
||||
virtual void Shutdown() = 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,14 +23,10 @@
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
// org.fmod.AudioDevice is an ordinary Java object. The JNI boundary passes
|
||||
// its jobject value through this opaque key without making the audio layer
|
||||
// depend on JNI headers or retain a JNI reference.
|
||||
// Opaque jobject identity; the audio layer never retains a JNI reference.
|
||||
using FmodJavaAudioDeviceIdentity = const void*;
|
||||
|
||||
// A factory returns a paused sink with exactly source_spec. Production uses
|
||||
// SDL3; tests can inject a deterministic sink without opening a host device.
|
||||
// factory_context must remain valid until the runtime is shut down.
|
||||
// Returns a paused sink. factory_context must outlive the runtime.
|
||||
using FmodJavaAudioSinkFactory = Status (*)(
|
||||
void* factory_context, const PcmSpec& source_spec,
|
||||
std::unique_ptr<AudioSink>* sink);
|
||||
@@ -44,8 +40,7 @@ struct FmodJavaAudioRuntimeOptions {
|
||||
std::size_t max_buffer_bytes_per_device = 64U * 1024U * 1024U;
|
||||
};
|
||||
|
||||
// Content-free counters suitable for readiness logs. They never expose PCM,
|
||||
// JNI identities, or guest pointers.
|
||||
// Contains no PCM, JNI identities, or guest pointers.
|
||||
struct FmodJavaAudioRuntimeStats {
|
||||
std::uint64_t init_attempts = 0;
|
||||
std::uint64_t initialized_devices = 0;
|
||||
@@ -65,18 +60,9 @@ struct FmodJavaAudioRuntimeStats {
|
||||
std::size_t pending_bytes = 0;
|
||||
};
|
||||
|
||||
// Thread-safe implementation of the APK's exact Java playback contract:
|
||||
//
|
||||
// AudioDevice.init(channels, sampleRate, blockSize, blockCount)Z
|
||||
// AudioDevice.write(byte[], length)V
|
||||
// AudioDevice.close()V
|
||||
//
|
||||
// Init() allocates blockCount fixed slots of blockSize frames. Every successful
|
||||
// Write() copies guest bytes synchronously into one slot, then lends that stable
|
||||
// storage to AudioSink without a second PCM copy. FmodJavaAudioRuntime performs
|
||||
// no per-write allocation for buffer storage or release ownership. A producer
|
||||
// waits with AudioTrack-style blocking backpressure when all slots are borrowed
|
||||
// instead of growing memory or dropping a block.
|
||||
// Fixed slots avoid per-write allocation. Write copies into a slot, then lends
|
||||
// it to AudioSink; a full pool applies blocking backpressure instead of
|
||||
// growing memory or dropping audio.
|
||||
class FmodJavaAudioRuntime final {
|
||||
public:
|
||||
explicit FmodJavaAudioRuntime(
|
||||
@@ -94,8 +80,7 @@ class FmodJavaAudioRuntime final {
|
||||
|
||||
FmodJavaAudioRuntimeStats GetStats() const;
|
||||
|
||||
// Idempotent. Prevents new devices/writes, shuts down every sink, and waits
|
||||
// for every borrowed owned buffer to be released before returning.
|
||||
// Waits for every borrowed buffer to be released.
|
||||
void Shutdown();
|
||||
|
||||
private:
|
||||
|
||||
@@ -25,9 +25,7 @@
|
||||
|
||||
namespace mocktail::audio {
|
||||
|
||||
// SDL documents subsystem initialization and shutdown as main-thread-only.
|
||||
// The process composition root owns this lifetime; audio workers only create
|
||||
// streams while the subsystem is active.
|
||||
// SDL requires subsystem startup and shutdown on the main thread.
|
||||
Status InitializeSdlAudioSubsystem();
|
||||
Status ShutdownSdlAudioSubsystem();
|
||||
|
||||
@@ -36,50 +34,37 @@ struct SdlPlaybackDevice {
|
||||
std::string name;
|
||||
};
|
||||
|
||||
// Lists physical SDL playback devices. The abstract `default` target is not a
|
||||
// physical entry and is therefore intentionally absent from this vector.
|
||||
// The virtual `default` target is not returned.
|
||||
Status ListSdlPlaybackDevices(std::vector<SdlPlaybackDevice>* devices);
|
||||
|
||||
// Resolves `default` or one exact physical device name. This pure selection
|
||||
// step is separate from SDL enumeration so configuration behavior is directly
|
||||
// testable.
|
||||
Status ResolveSdlPlaybackDevice(
|
||||
std::string_view requested,
|
||||
const std::vector<SdlPlaybackDevice>& available_devices,
|
||||
std::uint32_t* playback_device_id, std::string* resolved_name);
|
||||
|
||||
// Sets the process playback target used by every subsequently created FMOD
|
||||
// Java and OpenSL sink. It must run after SDL audio initialization and before
|
||||
// the first sink is opened.
|
||||
// Must run after SDL audio initialization and before the first sink opens.
|
||||
Status ConfigureSdlPlaybackDevice(
|
||||
std::string_view requested,
|
||||
std::vector<SdlPlaybackDevice>* available_devices,
|
||||
std::string* resolved_name);
|
||||
|
||||
// Returns the process playback target currently used by new sinks. The
|
||||
// physical ID is zero for SDL's system-default route.
|
||||
// Device ID zero means the system default.
|
||||
Status GetConfiguredSdlPlaybackDevice(std::uint32_t* playback_device_id,
|
||||
std::string* resolved_name);
|
||||
|
||||
// Atomically migrates every live FMOD Java and OpenSL stream to one physical
|
||||
// SDL device, or to the system default when playback_device_id is zero. New
|
||||
// sinks inherit the same target. Existing queued PCM stays in its stream.
|
||||
// Migrates live streams without dropping queued PCM. New sinks inherit it.
|
||||
Status SwitchSdlPlaybackDevice(std::uint32_t playback_device_id,
|
||||
std::string* resolved_name);
|
||||
|
||||
struct SdlAudioSinkOptions {
|
||||
PcmSpec source_spec;
|
||||
|
||||
// Zero selects the process playback target configured above (the SDL
|
||||
// default until explicitly configured). A nonzero ID bypasses that target.
|
||||
// Zero selects the configured process target.
|
||||
std::uint32_t playback_device_id = 0;
|
||||
bool start_paused = true;
|
||||
};
|
||||
|
||||
// Opens a real SDL 3.4+ playback device and its bound SDL_AudioStream. The SDL
|
||||
// audio subsystem must already be owned by InitializeSdlAudioSubsystem(). SDL
|
||||
// owns all format conversion, resampling, and borrowed-buffer consumption.
|
||||
// On failure, *sink remains null.
|
||||
// SDL owns conversion, resampling, and borrowed-buffer consumption.
|
||||
Status CreateSdlAudioSink(const SdlAudioSinkOptions& options,
|
||||
std::unique_ptr<AudioSink>* sink);
|
||||
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
namespace mocktail {
|
||||
namespace graphics {
|
||||
|
||||
// Vulkan VkResult values are a stable signed 32-bit ABI. Keeping the Android
|
||||
// boundary independent of the optional Vulkan SDK lets capability-only builds
|
||||
// continue to compile with SDL's opaque Vulkan handles.
|
||||
// Stable signed 32-bit ABI, independent of the optional Vulkan SDK.
|
||||
using AndroidVulkanResult = VkResult;
|
||||
|
||||
inline constexpr AndroidVulkanResult kAndroidVulkanSuccess =
|
||||
@@ -43,43 +41,29 @@ inline constexpr std::uint64_t kHostImageAcquireWatchdogTimeoutNs =
|
||||
inline constexpr char kAndroidSurfaceExtension[] =
|
||||
"VK_KHR_android_surface";
|
||||
|
||||
// Desktop compositors can report a valid acquired/presented image as
|
||||
// suboptimal while a fullscreen configure settles. The Android client path
|
||||
// used by Roblox treats that positive success code as a hard error, so the
|
||||
// compatibility boundary exposes it as the equivalent VK_SUCCESS result.
|
||||
// Roblox treats VK_SUBOPTIMAL_KHR as a hard error, so expose it as success.
|
||||
VkResult NormalizeAndroidSwapchainResult(VkResult result);
|
||||
|
||||
// Android requests an infinite image-acquire wait, but a lost Wayland/X11
|
||||
// compositor wakeup would then block Roblox's RenderJob forever. Only the
|
||||
// infinite guest request is bounded; explicit finite Vulkan timeouts retain
|
||||
// their original contract.
|
||||
// Bounds only infinite acquire waits so a lost compositor wakeup cannot hang.
|
||||
std::uint64_t BoundHostImageAcquireTimeout(std::uint64_t requested_timeout);
|
||||
bool IsHostImageAcquireWatchdogTimeout(std::uint64_t requested_timeout,
|
||||
VkResult host_result);
|
||||
VkResult NormalizeHostImageAcquireResult(std::uint64_t requested_timeout,
|
||||
VkResult host_result);
|
||||
|
||||
// Infinite fence/timeline waits retain their guest-visible semantics, but are
|
||||
// issued to the host in finite slices so a stalled renderer produces evidence
|
||||
// while it remains blocked. VK_TIMEOUT from a slice is never returned to the
|
||||
// Android client as a completed wait.
|
||||
// Infinite guest waits use finite host slices without exposing slice timeouts.
|
||||
std::uint64_t BoundHostSynchronizationWaitTimeout(
|
||||
std::uint64_t requested_timeout);
|
||||
bool ShouldContinueHostSynchronizationWait(std::uint64_t requested_timeout,
|
||||
VkResult host_result);
|
||||
|
||||
// Rewrites an Android VkInstance extension request for the host WSI. The
|
||||
// returned storage owns its strings and can be used to build the
|
||||
// ppEnabledExtensionNames array passed to the host vkCreateInstance call.
|
||||
// Returned storage owns the translated extension names.
|
||||
Status TranslateAndroidVulkanInstanceExtensions(
|
||||
const std::vector<std::string>& android_extensions,
|
||||
const std::vector<std::string>& host_wsi_extensions,
|
||||
std::vector<std::string>* host_extensions);
|
||||
|
||||
// Android WSI boundary. vkCreateAndroidSurfaceKHR-compatible exports can
|
||||
// delegate here after vkCreateInstance has used the translated extension list.
|
||||
// The Android native-window member is intentionally ignored: SDL owns the real
|
||||
// host window and is the only component allowed to create its VkSurfaceKHR.
|
||||
// Ignores the guest native-window field; SDL alone creates the host surface.
|
||||
class AndroidVulkanWsiAdapter final {
|
||||
public:
|
||||
using PresentCallback = void (*)(void* user_data);
|
||||
|
||||
@@ -12,14 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Exact-path loader for Mocktail's Bionic libEGL adapter.
|
||||
//
|
||||
// A host EGL implementation may have the Android SONAME "libEGL.so" too
|
||||
// (Chromium ANGLE does). Loading by SONAME after SDL/ANGLE starts can then
|
||||
// return the host implementation and make Android code create a second EGL
|
||||
// surface for one native window. This adapter opens the Mocktail
|
||||
// bridge beside the executable and exposes only its EGL exports for the
|
||||
// synthetic Bionic libEGL.so dependency.
|
||||
// Loading by SONAME can select host ANGLE instead of the Bionic adapter. This
|
||||
// bridge therefore opens the library beside the executable by exact path.
|
||||
|
||||
#ifndef MOCKTAIL_GRAPHICS_BIONIC_EGL_BRIDGE_H_
|
||||
#define MOCKTAIL_GRAPHICS_BIONIC_EGL_BRIDGE_H_
|
||||
@@ -37,9 +31,7 @@ public:
|
||||
BionicEglBridge(const BionicEglBridge &) = delete;
|
||||
BionicEglBridge &operator=(const BionicEglBridge &) = delete;
|
||||
|
||||
// Loads $ORIGIN/libEGL.so by its exact absolute path. On success, the
|
||||
// handle intentionally remains live for the process lifetime because the
|
||||
// Bionic linker retains the exported function addresses.
|
||||
// The handle stays live because the Bionic linker retains its exports.
|
||||
bool Load();
|
||||
|
||||
bool IsLoaded() const { return handle_ != nullptr; }
|
||||
|
||||
@@ -30,9 +30,8 @@
|
||||
namespace mocktail {
|
||||
namespace graphics {
|
||||
|
||||
// Thin host WSI scaffold. It does not create a VkInstance, device, swapchain,
|
||||
// renderer, or allocator. A future Android Vulkan adapter may translate
|
||||
// vkCreateAndroidSurfaceKHR into CreateSurface after rewriting extensions.
|
||||
// Provides SDL's host WSI entry points. Instance, device, swapchain, renderer,
|
||||
// and allocator ownership stays with the caller.
|
||||
class SdlVulkanWsi final {
|
||||
public:
|
||||
SdlVulkanWsi() = default;
|
||||
|
||||
@@ -18,19 +18,15 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// Stable process-local ABI between the host input runtime in the executable
|
||||
// and libvulkan.so, which is loaded as a separate Android compatibility DSO.
|
||||
// Pixels are tightly packed RGBA8. The producer owns and securely clears its
|
||||
// storage; consumers must copy a matching revision before returning.
|
||||
// Process-local ABI with libvulkan.so. Pixels are RGBA8; consumers must copy a
|
||||
// matching revision before the producer clears its storage.
|
||||
struct MocktailTextOverlayFrameInfo {
|
||||
static constexpr std::uint32_t kAbiVersion = 2;
|
||||
|
||||
std::uint32_t abi_version = kAbiVersion;
|
||||
std::uint32_t visible = 0;
|
||||
std::uint64_t revision = 0;
|
||||
// x/y/width/height use the host's logical input coordinate space. These
|
||||
// dimensions let a Vulkan consumer map that space to the swapchain's pixel
|
||||
// extent without assuming a 1:1 high-DPI scale.
|
||||
// Coordinates are logical, not swapchain pixels.
|
||||
std::uint32_t coordinate_width = 0;
|
||||
std::uint32_t coordinate_height = 0;
|
||||
std::int32_t x = 0;
|
||||
@@ -43,9 +39,7 @@ struct MocktailTextOverlayFrameInfo {
|
||||
|
||||
extern "C" {
|
||||
|
||||
// Lock-free conservative hint for the Vulkan present fast path. A false
|
||||
// result guarantees that no drawable overlay is currently published. A true
|
||||
// result only means that the synchronized query/copy path must decide.
|
||||
// false guarantees no overlay; true still requires a synchronized query.
|
||||
bool mocktail_text_overlay_may_present();
|
||||
bool mocktail_text_overlay_query(MocktailTextOverlayFrameInfo* frame);
|
||||
bool mocktail_text_overlay_copy(std::uint64_t revision, void* rgba,
|
||||
|
||||
@@ -22,13 +22,8 @@
|
||||
namespace mocktail {
|
||||
namespace graphics {
|
||||
|
||||
// Composites the process-local RGBA text surface into application-owned
|
||||
// swapchain images. The adapter retains ownership of every Vulkan object it
|
||||
// registers. DestroySwapchain and DestroyDevice must be called before the
|
||||
// corresponding host Vulkan destroy function.
|
||||
//
|
||||
// An unsupported device or swapchain remains a valid registration failure:
|
||||
// QueuePresent then forwards the unmodified VkPresentInfoKHR to fallback.
|
||||
// Registered Vulkan objects remain caller-owned and must be unregistered
|
||||
// before host destruction. Unsupported registrations fall back cleanly.
|
||||
class VulkanTextOverlayCompositor final {
|
||||
public:
|
||||
VulkanTextOverlayCompositor();
|
||||
@@ -38,10 +33,8 @@ class VulkanTextOverlayCompositor final {
|
||||
VulkanTextOverlayCompositor& operator=(const VulkanTextOverlayCompositor&) =
|
||||
delete;
|
||||
|
||||
// enabled_features describes the features actually enabled by
|
||||
// vkCreateDevice, not merely the features supported by the physical device.
|
||||
// The supplied proc-address callbacks must bypass the Android-facing Vulkan
|
||||
// adapter and resolve host functions directly.
|
||||
// enabled_features must match vkCreateDevice. Proc callbacks resolve host
|
||||
// functions directly, bypassing the Android adapter.
|
||||
bool RegisterDevice(VkInstance instance, VkPhysicalDevice physical_device,
|
||||
VkDevice device, std::uint32_t api_version,
|
||||
const char* const* enabled_extensions,
|
||||
@@ -61,10 +54,7 @@ class VulkanTextOverlayCompositor final {
|
||||
const VkImage* images, std::uint32_t image_count);
|
||||
void DestroySwapchain(VkDevice device, VkSwapchainKHR swapchain);
|
||||
|
||||
// Serializes adapter-mediated submit, sparse-bind, present, and idle
|
||||
// operations on the exact queue range imported by libplacebo. Other
|
||||
// VkQueues remain independent, including the inactive-overlay fast path on
|
||||
// a non-imported queue.
|
||||
// Adapter operations serialize only queues imported by libplacebo.
|
||||
VkResult QueueSubmit(VkQueue queue, std::uint32_t submit_count,
|
||||
const VkSubmitInfo* submits, VkFence fence,
|
||||
PFN_vkQueueSubmit fallback);
|
||||
@@ -77,10 +67,7 @@ class VulkanTextOverlayCompositor final {
|
||||
VkResult QueueWaitIdle(VkQueue queue, PFN_vkQueueWaitIdle fallback);
|
||||
VkResult DeviceWaitIdle(VkDevice device, PFN_vkDeviceWaitIdle fallback);
|
||||
|
||||
// Always invokes fallback when it is non-null. The original wait semaphores
|
||||
// are forwarded unchanged until the compositor successfully submits the
|
||||
// wait-collapse bridge; after that point only compositor-owned semaphores
|
||||
// are passed to fallback.
|
||||
// After wait collapse, fallback receives only compositor-owned semaphores.
|
||||
VkResult QueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info,
|
||||
PFN_vkQueuePresentKHR fallback);
|
||||
|
||||
|
||||
@@ -40,9 +40,7 @@ enum class AuthRuntimeStatus {
|
||||
kUnavailable,
|
||||
};
|
||||
|
||||
// Process-local ownership for the canonical .ROBLOSECURITY header. The class
|
||||
// is move-only and clears its backing allocation before release.
|
||||
// Callers must never log the value returned by view() or c_str().
|
||||
// Move-only credential storage that clears its allocation. Never log its view.
|
||||
class SecureRobloxCredential final {
|
||||
public:
|
||||
SecureRobloxCredential() = default;
|
||||
@@ -64,14 +62,10 @@ class SecureRobloxCredential final {
|
||||
std::vector<char> bytes_;
|
||||
};
|
||||
|
||||
// Clears a transient string that contained derived credential material.
|
||||
void SecurelyClearString(std::string* value);
|
||||
|
||||
// Binds the production pseudo-VM to the exact credential resolved by the
|
||||
// authentication preflight. The binding is intentionally non-movable:
|
||||
// it must be created only after its credential reaches its final address and
|
||||
// destroyed before that credential or the VM. Guest credentials block legacy
|
||||
// env/disk fallback until the sink durably accepts a native sign-in value.
|
||||
// Must be created after the credential reaches its final address and destroyed
|
||||
// before the credential or VM. Guest bindings block legacy env/disk fallback.
|
||||
class ScopedRobloxCredentialBinding final {
|
||||
public:
|
||||
ScopedRobloxCredentialBinding(jnivm::VM* jni_vm,
|
||||
@@ -93,9 +87,7 @@ class ScopedRobloxCredentialBinding final {
|
||||
jnivm::VM* jni_vm_ = nullptr;
|
||||
};
|
||||
|
||||
// Result of resolving host credentials before the Android runtime starts.
|
||||
// Credential ownership is retained only so the exact credential validated by
|
||||
// AuthService can be moved into native startup without reopening its source.
|
||||
// Retains the validated credential without reopening its source.
|
||||
struct AuthRuntimeComposition {
|
||||
AuthRuntimeStatus status = AuthRuntimeStatus::kUnavailable;
|
||||
std::shared_ptr<jnivm::VM> jni_vm;
|
||||
@@ -107,21 +99,14 @@ struct AuthRuntimeComposition {
|
||||
explicit operator bool() const { return jni_vm != nullptr; }
|
||||
};
|
||||
|
||||
// Resolves the saved Roblox session and constructs the production pseudo-VM.
|
||||
// A VM is returned only for an authenticated session or when guest startup is
|
||||
// explicitly enabled with MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP. An exact HTTP 401
|
||||
// from a managed Mocktail file clears only the rejected .ROBLOSECURITY. A
|
||||
// rejected default Sober credential is suppressed by a private fingerprint
|
||||
// without modifying Sober's file. The guest policy then allows native sign-in
|
||||
// to continue. Explicit overrides, unsafe recovery, other invalid credentials,
|
||||
// and unavailable authentication still fail closed before native startup.
|
||||
// A VM requires authentication or explicit guest mode. HTTP 401 clears only a
|
||||
// managed credential; rejected Sober credentials are suppressed without
|
||||
// changing Sober's file.
|
||||
AuthRuntimeComposition ComposeAuthRuntime(const Environment& environment,
|
||||
const RuntimePaths& paths,
|
||||
services::AuthService& auth_service);
|
||||
|
||||
// Production overload retaining the HTTP transport for credentials delivered
|
||||
// by native sign-in after startup. A persisted credential is validated before
|
||||
// its account identity is promoted into the running pseudo-VM.
|
||||
// Retains HTTP transport for native sign-in and validates persisted credentials.
|
||||
AuthRuntimeComposition ComposeAuthRuntime(
|
||||
const Environment& environment, const RuntimePaths& paths,
|
||||
services::AuthService& auth_service,
|
||||
|
||||
@@ -21,19 +21,12 @@
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Applies Mocktail's mandatory privacy boundary to the flat Roblox
|
||||
// client-settings override object. The policy disables native Crashpad, Java
|
||||
// crash/ANR uploads, Lua Backtrace errors, and inferred-crash submission while
|
||||
// preserving unrelated caller overrides. Unsafe values are replaced rather
|
||||
// than treated as conflicts because crash upload is not a user-configurable
|
||||
// runtime capability.
|
||||
// Disables Roblox crash uploads while preserving unrelated overrides.
|
||||
bool MergeCrashReportClientSettingsOverrides(std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
|
||||
// Applies the same mandatory values to Roblox's separate AppBridge
|
||||
// fastFlagsJson channel. Only FastVariable names are inserted here;
|
||||
// Java-only client settings remain on the client-settings boundary above.
|
||||
// AppBridge accepts only the FastVariable subset of this policy.
|
||||
bool MergeCrashReportFastFlagsOverrides(std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
|
||||
@@ -28,9 +28,7 @@ enum class DeviceClass {
|
||||
kConsole,
|
||||
};
|
||||
|
||||
// One immutable profile keeps Roblox admission, pseudo-Android hardware
|
||||
// identity, and built-in input capabilities consistent. The guest ABI and
|
||||
// client-settings group remain Android/GoogleAndroidApp.
|
||||
// Admission changes do not change the Android guest ABI.
|
||||
struct DeviceProfile {
|
||||
std::string name;
|
||||
std::string cache_key;
|
||||
@@ -51,18 +49,14 @@ struct DeviceProfile {
|
||||
};
|
||||
|
||||
inline constexpr std::string_view kDefaultDeviceProfileName = "pc-windows-11";
|
||||
// These identifiers select Roblox server-side admission only. They never
|
||||
// replace the Android APK ABI or its GoogleAndroidApp client-settings group.
|
||||
// Transport classifier only; the guest remains GoogleAndroidApp.
|
||||
inline constexpr std::string_view kRobloxDesktopHttpUserAgent =
|
||||
"Roblox/WinInet";
|
||||
// The current Android-host playability endpoint recognizes this as console.
|
||||
// PlayStation-labelled user agents currently fall back to desktop admission,
|
||||
// so the PS5 marketing profile keeps this transport-only classifier.
|
||||
// The playability endpoint recognizes this classifier as console.
|
||||
inline constexpr std::string_view kRobloxConsoleAdmissionUserAgent =
|
||||
"Roblox/XboxOne";
|
||||
|
||||
// Accepts canonical names and the convenient pc/mobile/console aliases.
|
||||
// Returned profiles have static lifetime.
|
||||
// Accepts canonical names and pc/mobile/console aliases; result is static.
|
||||
const DeviceProfile* FindDeviceProfile(std::string_view name);
|
||||
bool IsValidDeviceProfileValue(std::string_view value, std::size_t maximum);
|
||||
std::string BuildCustomDeviceProfileCacheKey(const DeviceProfile& profile);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 Mocktail Project Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
#ifndef MOCKTAIL_RUNTIME_DISCORD_RPC_H_
|
||||
#define MOCKTAIL_RUNTIME_DISCORD_RPC_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "runtime/roblox_experience_presence.h"
|
||||
#include "runtime/runtime_config.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
struct DiscordRpcActivity {
|
||||
std::string details;
|
||||
std::string state;
|
||||
std::optional<int64_t> start_timestamp;
|
||||
std::string large_image;
|
||||
std::string large_text;
|
||||
std::string button_label;
|
||||
std::string button_url;
|
||||
};
|
||||
|
||||
bool IsPublicDiscordJoin(const RobloxExperienceLaunchRequest& request);
|
||||
std::string BuildDiscordJoinUrl(const RobloxExperienceLaunchRequest& request);
|
||||
DiscordRpcActivity BuildDiscordRpcActivity(
|
||||
const DiscordRpcConfig& config, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request, std::string place_name,
|
||||
int64_t session_started_at, std::string place_icon_url = {});
|
||||
|
||||
// Maintains a direct, unauthenticated Discord Desktop RPC connection. The
|
||||
// worker owns all network and IPC work; lifecycle notifications only replace
|
||||
// a small in-memory snapshot and never block the Roblox main thread.
|
||||
class DiscordRpcSession final {
|
||||
public:
|
||||
explicit DiscordRpcSession(DiscordRpcConfig config);
|
||||
~DiscordRpcSession();
|
||||
|
||||
DiscordRpcSession(const DiscordRpcSession&) = delete;
|
||||
DiscordRpcSession& operator=(const DiscordRpcSession&) = delete;
|
||||
|
||||
bool Start(std::string* detail = nullptr);
|
||||
void Stop();
|
||||
RobloxExperiencePresenceObserver observer();
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
static void Notify(void* context, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request);
|
||||
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_DISCORD_RPC_H_
|
||||
@@ -28,14 +28,10 @@ namespace runtime {
|
||||
inline constexpr std::size_t kMaximumPendingExternalLaunches = 8;
|
||||
|
||||
struct ExternalLaunchBrokerOptions {
|
||||
// Empty selects the current user's protected runtime endpoint. Tests and
|
||||
// isolated launchers may provide an explicit socket under a private
|
||||
// directory owned by the current user.
|
||||
// Empty selects the current user's protected endpoint.
|
||||
std::filesystem::path socket_path;
|
||||
std::size_t maximum_pending_launches = kMaximumPendingExternalLaunches;
|
||||
// The owner crosses configuration/bootstrap and a possible cgroup
|
||||
// re-exec before it can safely ACK. A concurrent browser click waits through
|
||||
// that bounded startup window instead of racing a not-yet-created socket.
|
||||
// Covers bootstrap and a possible cgroup re-exec.
|
||||
int forwarding_timeout_ms = 30000;
|
||||
};
|
||||
|
||||
@@ -49,16 +45,11 @@ struct ExternalLaunchSink {
|
||||
bool valid() const { return dispatch != nullptr; }
|
||||
};
|
||||
|
||||
// Resolves the per-user, per-installation endpoint. Owner resolution may
|
||||
// create only the private fallback directory; it never removes a stale
|
||||
// socket. Socket cleanup belongs exclusively to StartOwnerAfterLockAcquired
|
||||
// after the caller owns the matching Mocktail single-instance lock.
|
||||
// Resolution never removes stale sockets; only a lock-owning broker may do so.
|
||||
Status ResolveExternalLaunchSocketPath(bool owner,
|
||||
std::filesystem::path* socket_path);
|
||||
|
||||
// Owns a bounded process-local launch queue and its same-user AF_UNIX endpoint.
|
||||
// Wire requests contain only already-normalized ExperienceProtocol JSON; the
|
||||
// raw browser URI and its gameinfo ticket never cross this boundary.
|
||||
// The wire format contains normalized requests, never raw browser tickets.
|
||||
class ExternalLaunchBroker final {
|
||||
public:
|
||||
~ExternalLaunchBroker();
|
||||
@@ -68,25 +59,20 @@ class ExternalLaunchBroker final {
|
||||
ExternalLaunchBroker(ExternalLaunchBroker&&) = delete;
|
||||
ExternalLaunchBroker& operator=(ExternalLaunchBroker&&) = delete;
|
||||
|
||||
// The caller must hold the single-instance lock. Only this operation may
|
||||
// remove a stale socket left by a crashed owner. An optional initial request
|
||||
// is normalized and queued before the listener worker can accept later
|
||||
// browser clicks, preserving click order without a startup race.
|
||||
// Requires the single-instance lock. The initial request is queued before
|
||||
// the listener starts, preserving click order.
|
||||
static Status StartOwnerAfterLockAcquired(
|
||||
ExternalLaunchBrokerOptions options,
|
||||
std::shared_ptr<ExternalLaunchBroker>* broker,
|
||||
const RobloxExperienceLaunchRequest* initial_request = nullptr);
|
||||
|
||||
// Forwards one normalized request and waits for an explicit owner ACK. The
|
||||
// operation never starts a second runtime and never logs request contents.
|
||||
// Waits for an owner ACK and never logs the request.
|
||||
static Status ForwardToOwner(const ExternalLaunchBrokerOptions& options,
|
||||
const RobloxExperienceLaunchRequest& request);
|
||||
|
||||
Status QueueInitialRequest(RobloxExperienceLaunchRequest request);
|
||||
|
||||
// Dispatches at most maximum_requests in FIFO order. A failed dispatch is
|
||||
// returned to the front of the queue, so temporary downstream backpressure
|
||||
// never drops a browser launch.
|
||||
// A failed dispatch returns to the front of the FIFO queue.
|
||||
Status Drain(const ExternalLaunchSink& sink, std::size_t maximum_requests);
|
||||
|
||||
Status Shutdown();
|
||||
@@ -100,9 +86,7 @@ class ExternalLaunchBroker final {
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
// Process-global attachment used by the composition root and the dynamic
|
||||
// ExperienceProtocol runtime. shared_ptr acquisition keeps the broker alive
|
||||
// while an external request is being drained.
|
||||
// shared_ptr acquisition keeps the active broker alive during a drain.
|
||||
Status InstallActiveExternalLaunchBroker(
|
||||
const std::shared_ptr<ExternalLaunchBroker>& broker);
|
||||
void ClearActiveExternalLaunchBroker(const ExternalLaunchBroker* broker);
|
||||
|
||||
@@ -22,19 +22,14 @@
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// Returns whether an interactive launch may present a desktop failure dialog.
|
||||
// Readiness canaries and headless sessions remain strictly non-interactive.
|
||||
// Canaries and headless sessions remain non-interactive.
|
||||
bool FailureDialogsEnabled(const Environment& environment);
|
||||
|
||||
// Presents one synchronous GTK failure dialog. This is intended for startup
|
||||
// failures that happen before the crash monitor can be armed.
|
||||
bool ShowFailureDialog(const Environment& environment,
|
||||
std::string_view message);
|
||||
|
||||
// Owns a small out-of-process GTK monitor. The helper is started before guest
|
||||
// threads exist and receives an explicit success disposition. If Mocktail
|
||||
// returns an error, calls _Exit(), or dies from a fatal signal, the helper
|
||||
// remains able to present the last safe user-facing message.
|
||||
// The helper starts before guest threads, so it can report errors and fatal
|
||||
// exits even when the main process can no longer show a dialog.
|
||||
class FailureDialogMonitor final {
|
||||
public:
|
||||
FailureDialogMonitor() = default;
|
||||
|
||||
@@ -87,9 +87,9 @@ using ResumeGameFn = Status (*)(void* context, const GameSurface& surface);
|
||||
using LeaveGameFn = Status (*)(void* context);
|
||||
using ShutdownGameRuntimeFn = Status (*)(void* context);
|
||||
|
||||
// native boundary. All callbacks are synchronous and non-throwing. The
|
||||
// coordinator serializes lifecycle invocation, matching MainGameActivity's
|
||||
// single-thread executor without embedding JNI details in the policy layer.
|
||||
// These callbacks form the native boundary. They are synchronous and
|
||||
// non-throwing; the coordinator serializes lifecycle calls to match
|
||||
// MainGameActivity's single-thread executor.
|
||||
struct GameSessionCapabilities {
|
||||
void* context = nullptr;
|
||||
SetGameForegroundFn set_game_foreground = nullptr;
|
||||
@@ -171,16 +171,11 @@ struct GameSessionSnapshot {
|
||||
std::string failure_reason;
|
||||
};
|
||||
|
||||
// Fail-closed policy for the MainGameActivity game-session branch.
|
||||
//
|
||||
// SurfaceCreated must prepare a generation before SurfaceChanged can supply its
|
||||
// native window and dimensions. QueueJoinRequest and the prepared surface are
|
||||
// independent initial prerequisites; whichever completes last starts the game
|
||||
// with the current candidate surface. Initial start never calls ResumeGame.
|
||||
// Once running, SurfaceChanged updates the active surface, SurfaceDestroyed
|
||||
// pauses, and SurfaceCreated(new generation) plus SurfaceChanged resumes.
|
||||
// Candidate surfaces become active only after the corresponding native call
|
||||
// succeeds.
|
||||
// SurfaceCreated must open a generation before its matching SurfaceChanged can
|
||||
// supply a window and size. A join request and the first surface may arrive in
|
||||
// either order; initial startup calls StartGame, never ResumeGame. Once running,
|
||||
// new generations pause and resume the session. A candidate surface becomes
|
||||
// active only after its native callback succeeds.
|
||||
class GameSessionCoordinator final {
|
||||
public:
|
||||
explicit GameSessionCoordinator(GameSessionCapabilities capabilities,
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// adapter kept outside the research runtime. The opaque context must be
|
||||
// the production jnivm::VM owned by the startup composition.
|
||||
// The opaque context must be the jnivm::VM owned by startup composition.
|
||||
bool SetJnivmPlatformWebCallbacks(
|
||||
void* context, std::shared_ptr<void> callback_context,
|
||||
void (*on_data_model_notification)(void*, JNIEnv*, jstring, jstring),
|
||||
|
||||
@@ -53,10 +53,8 @@ struct CgroupMemoryLimitResult {
|
||||
bool active() const { return status == CgroupMemoryLimitStatus::kActive; }
|
||||
};
|
||||
|
||||
// Replaces the current process with the same command inside a transient
|
||||
// systemd user scope when cgroup v2 delegation is available. A successful
|
||||
// re-exec does not return. Systems without systemd continue with the watchdog
|
||||
// fallback and receive kUnavailable.
|
||||
// Compatibility hook for the former cgroup re-exec path. Automatic cgroup
|
||||
// scope creation is disabled, so callers continue with the watchdog fallback.
|
||||
CgroupMemoryLimitResult MaybeReexecWithCgroupMemoryLimit(
|
||||
int argc, char* const argv[], std::uint64_t limit_bytes,
|
||||
const std::vector<std::string>* reexec_arguments = nullptr);
|
||||
|
||||
@@ -34,8 +34,7 @@ enum class PhysicsWorkerMode {
|
||||
bool ParsePhysicsWorkerMode(std::string_view value, PhysicsWorkerMode* mode);
|
||||
std::string_view PhysicsWorkerModeName(PhysicsWorkerMode mode);
|
||||
|
||||
// Makes every available physical core eligible for Roblox scheduler work.
|
||||
// At least one scheduler worker remains on malformed topology input.
|
||||
// Malformed topology still yields one worker.
|
||||
int CalculateThroughputWorkerCount(int available_physical_cores);
|
||||
|
||||
struct PerformancePolicy {
|
||||
@@ -61,25 +60,16 @@ PerformancePolicy ParsePerformancePolicy(
|
||||
std::string_view memory_limit_mb = "0", std::string_view game_mode = "auto",
|
||||
std::string_view physics_worker_mode = "auto");
|
||||
|
||||
// Counts unique physical cores available to the current process. Linux CPU
|
||||
// affinity is respected; systems without readable topology fall back to the
|
||||
// available logical CPU count.
|
||||
// Respects CPU affinity and falls back to the available logical count.
|
||||
int DetectAvailablePhysicalCoreCount();
|
||||
|
||||
// Merges the supported Roblox scheduler/rendering mode into the flat
|
||||
// client-settings override object. Disabled policy leaves existing overrides
|
||||
// intact. Conflicting explicit values fail closed instead of silently
|
||||
// replacing the caller's settings.
|
||||
// Disabled policy preserves overrides; conflicting values fail.
|
||||
bool MergePerformanceClientSettingsOverrides(const PerformancePolicy& policy,
|
||||
std::string_view base_json,
|
||||
std::string* merged_json,
|
||||
std::string* error);
|
||||
|
||||
// Applies every runtime policy in a stable order before the resulting
|
||||
// override object crosses the legacy startup boundary. The mandatory
|
||||
// HttpClient compatibility policy is applied before the mandatory crash-report
|
||||
// upload block. Neither policy can be overridden by a caller-provided client
|
||||
// setting.
|
||||
// Applies mandatory HTTP and crash policies after caller settings.
|
||||
bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
|
||||
const PerformancePolicy& performance,
|
||||
std::string_view base_json,
|
||||
|
||||
@@ -143,11 +143,10 @@ struct RobloxBrowserServiceCallbackFactory {
|
||||
}
|
||||
};
|
||||
|
||||
// equivalent of the current APK's yg.c and gi.a BrowserService bridges.
|
||||
// It binds the four exact MemStorage keys and owns every Callback and
|
||||
// Connection until a disconnect-and-release shutdown has completed. Outgoing
|
||||
// WebView events are queued without JNI and must be drained by the platform
|
||||
// main thread.
|
||||
// Host counterpart to the APK's yg.c and gi.a BrowserService bridges. It binds
|
||||
// the four expected MemStorage keys. Shutdown disconnects before releasing its
|
||||
// callbacks and connections. The platform main thread drains outgoing WebView
|
||||
// events without holding JNI references.
|
||||
class RobloxBrowserServiceBridge final {
|
||||
public:
|
||||
RobloxBrowserServiceBridge(JniEnvironmentProvider environment,
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/owned_pthread.h"
|
||||
#include "runtime/roblox_browser_service_bridge.h"
|
||||
#include "runtime/roblox_experience_launch_bridge.h"
|
||||
#include "runtime/roblox_experience_presence.h"
|
||||
#include "runtime/roblox_fresh_game_launch_controller.h"
|
||||
#include "runtime/roblox_web_view_bridge.h"
|
||||
#include "runtime/webview_roblox_cookie.h"
|
||||
@@ -120,7 +122,8 @@ class RobloxExperienceComposition final {
|
||||
RobloxFreshLaunchPresentBoundary present_boundary,
|
||||
RobloxGameSurfaceJniConfig surface_config = {},
|
||||
const SecureRobloxCredential* initial_web_view_credential = nullptr,
|
||||
RobloxExperienceSurfaceProvider surface_provider = {});
|
||||
RobloxExperienceSurfaceProvider surface_provider = {},
|
||||
RobloxExperiencePresenceObserver presence_observer = {});
|
||||
~RobloxExperienceComposition();
|
||||
|
||||
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
|
||||
@@ -185,6 +188,7 @@ class RobloxExperienceComposition final {
|
||||
void* context, const RobloxBrowserServiceExecuteRequest& request);
|
||||
static void WebSurfaceExited(void* context);
|
||||
static void NotifyLateLuaAppDidReturn(void* context);
|
||||
static void GamePresented(void* context, uint64_t frame_serial);
|
||||
static void* RunLaunchWorker(void* context);
|
||||
static Status RouteWebSurfaceEvent(WebSurfaceRoute route,
|
||||
const WebViewHelperEvent& event,
|
||||
@@ -205,6 +209,7 @@ class RobloxExperienceComposition final {
|
||||
Status Dispatch(const RobloxExperienceLaunchRequest& request);
|
||||
Status PromoteAuthenticatedSession();
|
||||
Status RefreshLateSurface();
|
||||
void PublishPresentedPresence(uint64_t frame_serial);
|
||||
void RunActiveLaunch();
|
||||
Status BuildPlatformGlobalObjects();
|
||||
Status BuildLuaAppGlobalObjects();
|
||||
@@ -218,6 +223,8 @@ class RobloxExperienceComposition final {
|
||||
Status RestartLuaAppSurface(const GameSurface& surface);
|
||||
Status ReleaseLuaAppGlobalObjects();
|
||||
Status ReleaseGlobalObjects();
|
||||
void NotifyPresence(RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request) const;
|
||||
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxExperienceMessageBusSymbols message_bus_symbols_;
|
||||
@@ -229,6 +236,7 @@ class RobloxExperienceComposition final {
|
||||
const RobloxGameSurfaceJniConfig surface_config_;
|
||||
const bool consumes_window_surface_events_;
|
||||
const RobloxExperienceSurfaceProvider surface_provider_;
|
||||
const RobloxExperiencePresenceObserver presence_observer_;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::mutex surface_operation_mutex_;
|
||||
@@ -253,6 +261,7 @@ class RobloxExperienceComposition final {
|
||||
bool web_view_cookie_synchronized_ = false;
|
||||
std::deque<RobloxExperienceLaunchRequest> pending_launch_requests_;
|
||||
std::unique_ptr<LaunchTask> active_launch_;
|
||||
std::optional<RobloxExperienceLaunchRequest> presence_request_;
|
||||
std::string active_game_canonical_json_;
|
||||
OwnedPthread launch_worker_;
|
||||
uint64_t next_request_id_ = 1;
|
||||
@@ -260,6 +269,7 @@ class RobloxExperienceComposition final {
|
||||
bool subscribed_ = false;
|
||||
bool launch_in_progress_ = false;
|
||||
bool game_active_ = false;
|
||||
bool playing_presence_published_ = false;
|
||||
bool lua_app_return_pending_ = false;
|
||||
bool controlled_switch_waiting_for_return_ = false;
|
||||
bool lua_app_surface_recreation_pending_ = false;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 Mocktail Project Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_EXPERIENCE_PRESENCE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_EXPERIENCE_PRESENCE_H_
|
||||
|
||||
#include "runtime/roblox_experience_launch_bridge.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
enum class RobloxExperiencePresencePhase {
|
||||
kBrowsing,
|
||||
kJoining,
|
||||
kPlaying,
|
||||
};
|
||||
|
||||
using NotifyRobloxExperiencePresenceFn =
|
||||
void (*)(void* context, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request);
|
||||
|
||||
// Non-owning observer retained by RobloxExperienceComposition. Its owner must
|
||||
// outlive the composition. Browsing notifications carry a null request.
|
||||
struct RobloxExperiencePresenceObserver {
|
||||
void* context = nullptr;
|
||||
NotifyRobloxExperiencePresenceFn notify = nullptr;
|
||||
|
||||
bool valid() const { return notify != nullptr; }
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_EXPERIENCE_PRESENCE_H_
|
||||
@@ -86,7 +86,8 @@ class RobloxFreshGameLaunchController final {
|
||||
RobloxFreshGameLaunchController(
|
||||
JniEnvironmentProvider environment, RobloxGameSessionSymbols symbols,
|
||||
RobloxFreshLaunchPresentBoundary present_boundary,
|
||||
RobloxGameSurfaceJniConfig surface_config = {});
|
||||
RobloxGameSurfaceJniConfig surface_config = {},
|
||||
RobloxGamePresentedObserver presented_observer = {});
|
||||
~RobloxFreshGameLaunchController();
|
||||
|
||||
RobloxFreshGameLaunchController(const RobloxFreshGameLaunchController&) =
|
||||
@@ -114,6 +115,7 @@ class RobloxFreshGameLaunchController final {
|
||||
const RobloxGameSessionSymbols symbols_;
|
||||
const RobloxFreshLaunchPresentBoundary present_boundary_;
|
||||
const RobloxGameSurfaceJniConfig surface_config_;
|
||||
const RobloxGamePresentedObserver presented_observer_;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::unique_ptr<RobloxGameSessionRuntime> runtime_;
|
||||
|
||||
@@ -221,6 +221,16 @@ class RobloxGameSessionNativeAdapter final {
|
||||
bool initialized_ = false;
|
||||
};
|
||||
|
||||
using RobloxGamePresentedCallback = void (*)(void* context,
|
||||
uint64_t frame_serial);
|
||||
|
||||
struct RobloxGamePresentedObserver {
|
||||
void* context = nullptr;
|
||||
RobloxGamePresentedCallback notify = nullptr;
|
||||
|
||||
bool valid() const { return notify != nullptr; }
|
||||
};
|
||||
|
||||
// Small production owner that binds the JNI adapter to the policy coordinator.
|
||||
// Initial startup always follows the APK sequence:
|
||||
// principal -> join request -> SurfaceCreated(foreground) ->
|
||||
@@ -229,7 +239,8 @@ class RobloxGameSessionRuntime final {
|
||||
public:
|
||||
RobloxGameSessionRuntime(JniEnvironmentProvider environment,
|
||||
RobloxGameSessionSymbols symbols,
|
||||
RobloxGameSurfaceJniConfig surface_config = {});
|
||||
RobloxGameSurfaceJniConfig surface_config = {},
|
||||
RobloxGamePresentedObserver presented_observer = {});
|
||||
|
||||
RobloxGameSessionRuntime(const RobloxGameSessionRuntime&) = delete;
|
||||
RobloxGameSessionRuntime& operator=(const RobloxGameSessionRuntime&) = delete;
|
||||
@@ -256,6 +267,7 @@ class RobloxGameSessionRuntime final {
|
||||
private:
|
||||
RobloxGameSessionNativeAdapter adapter_;
|
||||
GameSessionCoordinator coordinator_;
|
||||
const RobloxGamePresentedObserver presented_observer_;
|
||||
// Makes the process-level pause/leave/destroy sequence atomic with respect
|
||||
// to surface updates. Present evidence bypasses this mutex
|
||||
// because StartGame can report it synchronously from the render callback.
|
||||
|
||||
@@ -28,7 +28,8 @@ inline constexpr std::size_t kMaximumRobloxLaunchUriBytes = 64 * 1024;
|
||||
|
||||
// Converts the two Roblox website launch protocols into the owned
|
||||
// ExperienceProtocol contract used by the supported game-session pipeline.
|
||||
// Browser authentication tickets and tracker identifiers are // discarded: the runtime uses its already validated account identity.
|
||||
// Authentication tickets and tracker IDs are discarded; the runtime already
|
||||
// has a validated account identity.
|
||||
Status ParseRobloxLaunchUri(std::string_view uri,
|
||||
RobloxExperienceLaunchRequest* request);
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// host copy of the current APK's NativeTextBoxInfo Java value. The copy
|
||||
// contains no text and remains valid after the JNI local reference is released.
|
||||
// Host-owned NativeTextBoxInfo copy that outlives the JNI local reference.
|
||||
struct RobloxNativeTextBoxInfoSnapshot {
|
||||
float x = 0.0F;
|
||||
float y = 0.0F;
|
||||
|
||||
@@ -55,11 +55,30 @@ struct NetworkProxyConfig {
|
||||
int port = 0;
|
||||
};
|
||||
|
||||
struct DiscordRpcTextConfig {
|
||||
std::string browsing = "Browsing experiences";
|
||||
std::string joining = "Joining an experience";
|
||||
std::string playing = "{place_name}";
|
||||
std::string state = "Playing Roblox";
|
||||
std::string unknown_place = "Unknown experience";
|
||||
};
|
||||
|
||||
struct DiscordRpcConfig {
|
||||
bool enabled = false;
|
||||
bool show_place_name = true;
|
||||
bool show_elapsed_time = true;
|
||||
bool join_enabled = true;
|
||||
bool public_servers_only = true;
|
||||
std::string join_button_label = "Join Server";
|
||||
std::string application_id;
|
||||
DiscordRpcTextConfig text;
|
||||
};
|
||||
|
||||
std::optional<NetworkProxyConfig> ParseNetworkProxyConfig(
|
||||
std::string_view host, std::string_view port);
|
||||
std::string BuildNetworkProxyUrl(const NetworkProxyConfig& proxy);
|
||||
|
||||
// Immutable, supported runtime options. Experimental MOCKTAIL_PATCH_* knobs
|
||||
// Immutable, supported runtime options. Advanced MOCKTAIL_PATCH_* controls
|
||||
// intentionally do not belong here; they remain isolated in the legacy path.
|
||||
class RuntimeConfig {
|
||||
public:
|
||||
@@ -95,6 +114,8 @@ class RuntimeConfig {
|
||||
const std::optional<NetworkProxyConfig>& network_proxy() const {
|
||||
return network_proxy_;
|
||||
}
|
||||
const DiscordRpcConfig& discord_rpc() const { return discord_rpc_; }
|
||||
bool discord_rpc_valid() const { return discord_rpc_valid_; }
|
||||
|
||||
// These legacy opt-ins create workers that do not own their VM/JNI state.
|
||||
// Empty and "0" values are disabled; every other non-empty value is unsafe.
|
||||
@@ -123,6 +144,8 @@ class RuntimeConfig {
|
||||
std::string audio_output_device_ = "default";
|
||||
bool audio_output_device_valid_ = true;
|
||||
std::optional<NetworkProxyConfig> network_proxy_;
|
||||
DiscordRpcConfig discord_rpc_;
|
||||
bool discord_rpc_valid_ = true;
|
||||
std::vector<std::string> unsafe_detached_thread_overrides_;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 Mocktail Project Authors
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
#ifndef MOCKTAIL_RUNTIME_SESSION_LOG_H_
|
||||
#define MOCKTAIL_RUNTIME_SESSION_LOG_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "runtime/environment.h"
|
||||
#include "runtime/runtime_paths.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
struct SessionBuildInformation {
|
||||
std::string version;
|
||||
std::string git_commit;
|
||||
std::string git_dirty;
|
||||
std::string build_type;
|
||||
std::string compiler;
|
||||
std::string target;
|
||||
};
|
||||
|
||||
SessionBuildInformation CurrentSessionBuildInformation();
|
||||
|
||||
class SessionLog final {
|
||||
public:
|
||||
SessionLog() = default;
|
||||
~SessionLog();
|
||||
|
||||
SessionLog(const SessionLog&) = delete;
|
||||
SessionLog& operator=(const SessionLog&) = delete;
|
||||
SessionLog(SessionLog&& other) noexcept;
|
||||
SessionLog& operator=(SessionLog&& other) noexcept;
|
||||
|
||||
static SessionLog Start(const Environment& environment,
|
||||
const RuntimePaths& paths,
|
||||
std::chrono::system_clock::time_point started_at =
|
||||
std::chrono::system_clock::now());
|
||||
|
||||
bool active() const { return active_; }
|
||||
bool attempted() const { return attempted_; }
|
||||
explicit operator bool() const { return active_; }
|
||||
|
||||
const std::filesystem::path& path() const { return path_; }
|
||||
const std::filesystem::path& latest_path() const { return latest_path_; }
|
||||
const std::string& error() const { return error_; }
|
||||
const std::string& warning() const { return warning_; }
|
||||
|
||||
std::string Header(const Environment& environment, const RuntimePaths& paths,
|
||||
std::string_view graphics_backend) const;
|
||||
|
||||
private:
|
||||
void Stop();
|
||||
|
||||
bool attempted_ = false;
|
||||
bool active_ = false;
|
||||
int original_stdout_ = -1;
|
||||
int original_stderr_ = -1;
|
||||
int logger_process_ = -1;
|
||||
std::chrono::system_clock::time_point started_at_{};
|
||||
std::filesystem::path path_;
|
||||
std::filesystem::path latest_path_;
|
||||
std::string error_;
|
||||
std::string warning_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_SESSION_LOG_H_
|
||||
@@ -52,7 +52,6 @@ CandidateApprovalResult CreateCandidateApproval(
|
||||
bool ValidateCandidateApproval(const std::filesystem::path& store_root,
|
||||
std::string_view activation_json,
|
||||
const PayloadIntegrityResult& payload,
|
||||
const std::filesystem::path& runtime_binary,
|
||||
CandidateApprovalResult* approval,
|
||||
std::string* error);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ struct HostAbiDerivationResult {
|
||||
}
|
||||
};
|
||||
|
||||
// Derives an exact, experimental HostAbi profile by matching normalized x86-64
|
||||
// Derives an exact candidate HostAbi profile by matching normalized x86-64
|
||||
// instruction signatures against one already approved reference payload. This
|
||||
// is static analysis only; the result still requires two real Tier C canaries
|
||||
// before normal runtime activation.
|
||||
|
||||
@@ -53,7 +53,7 @@ class PayloadStore final {
|
||||
const std::array<std::filesystem::path, 2>& canary_logs);
|
||||
PayloadStoreResult Import(const std::filesystem::path& prepared_payload);
|
||||
PayloadStoreResult InspectCurrent() const;
|
||||
PayloadStoreResult VerifyCurrent() const;
|
||||
PayloadStoreResult VerifyCurrent();
|
||||
PayloadStoreResult Rollback();
|
||||
std::string StatusJson(std::string* error = nullptr) const;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace mocktail::update {
|
||||
|
||||
struct UpdateConfig {
|
||||
bool automatic = true;
|
||||
bool testing_latest_only = true;
|
||||
bool launch_after_update = false;
|
||||
std::string source = "apk-pure";
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: komaruworld <rtysus228@gmail.com>
|
||||
|
||||
pkgname=mocktail
|
||||
pkgver=0.1.0
|
||||
pkgver=1.0.2
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = mocktail-bin
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
|
||||
pkgver = 0.1.0
|
||||
pkgver = 1.0.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
arch = x86_64
|
||||
@@ -31,10 +31,10 @@ pkgbase = mocktail-bin
|
||||
provides = mocktail
|
||||
conflicts = mocktail
|
||||
conflicts = mocktail-git
|
||||
noextract = mocktail-0.1.0-1-x86_64.pkg.tar.zst
|
||||
noextract = mocktail-1.0.1-1-x86_64.pkg.tar.zst
|
||||
options = !debug
|
||||
options = !strip
|
||||
source = mocktail-0.1.0-1-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/0.1.0/mocktail-0.1.0-1-x86_64.pkg.tar.zst
|
||||
sha256sums = f8a44ca7bd35526035ce7d1902ff7a4b1981a7c36e54d82ce4fc655db6fdf0f3
|
||||
source = mocktail-1.0.1-1-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.1/mocktail-1.0.1-1-x86_64.pkg.tar.zst
|
||||
sha256sums = 63072fa49b3776cb164b71067696cd195e77470be7e0374444d4e8548d1de35c
|
||||
|
||||
pkgname = mocktail-bin
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
|
||||
pkgname=mocktail-bin
|
||||
pkgver=0.1.0
|
||||
pkgver=1.0.1
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
|
||||
arch=('x86_64')
|
||||
@@ -38,7 +38,7 @@ options=('!debug' '!strip')
|
||||
_archive="mocktail-${pkgver}-${pkgrel}-${CARCH}.pkg.tar.zst"
|
||||
source=("${_archive}::https://github.com/komaruworld/mocktail/releases/download/${pkgver}/${_archive}")
|
||||
noextract=("${_archive}")
|
||||
sha256sums=('f8a44ca7bd35526035ce7d1902ff7a4b1981a7c36e54d82ce4fc655db6fdf0f3')
|
||||
sha256sums=('63072fa49b3776cb164b71067696cd195e77470be7e0374444d4e8548d1de35c')
|
||||
|
||||
package() {
|
||||
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = mocktail-git
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
||||
pkgver = 0.1.0.r1.g4e78c7d
|
||||
pkgver = 1.0.1.r15.g0a03962
|
||||
pkgrel = 1
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
arch = x86_64
|
||||
@@ -10,6 +10,7 @@ pkgbase = mocktail-git
|
||||
makedepends = ninja
|
||||
makedepends = nlohmann-json
|
||||
makedepends = pkgconf
|
||||
makedepends = vulkan-headers
|
||||
depends = capstone
|
||||
depends = curl
|
||||
depends = fontconfig
|
||||
@@ -38,10 +39,6 @@ pkgbase = mocktail-git
|
||||
conflicts = mocktail-bin
|
||||
options = !debug
|
||||
source = mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main
|
||||
source = libjnivm::git+https://github.com/ChristopherHX/libjnivm.git
|
||||
source = vulkan-headers::git+https://github.com/KhronosGroup/Vulkan-Headers.git
|
||||
sha256sums = SKIP
|
||||
sha256sums = SKIP
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = mocktail-git
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
|
||||
pkgname=mocktail-git
|
||||
pkgver=0.1.0.r1.g4e78c7d
|
||||
pkgver=1.0.1.r15.g0a03962
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
@@ -37,6 +37,7 @@ makedepends=(
|
||||
'ninja'
|
||||
'nlohmann-json'
|
||||
'pkgconf'
|
||||
'vulkan-headers'
|
||||
)
|
||||
optdepends=('gamemode: Feral GameMode integration')
|
||||
provides=('mocktail')
|
||||
@@ -44,13 +45,9 @@ conflicts=('mocktail' 'mocktail-bin')
|
||||
options=('!debug')
|
||||
source=(
|
||||
'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'
|
||||
'libjnivm::git+https://github.com/ChristopherHX/libjnivm.git'
|
||||
'vulkan-headers::git+https://github.com/KhronosGroup/Vulkan-Headers.git'
|
||||
)
|
||||
sha256sums=(
|
||||
'SKIP'
|
||||
'SKIP'
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
pkgver() {
|
||||
@@ -69,11 +66,9 @@ pkgver() {
|
||||
prepare() {
|
||||
cd mocktail
|
||||
|
||||
git submodule init
|
||||
git config submodule.third_party/libjnivm.url "${srcdir}/libjnivm"
|
||||
git config submodule.third_party/Vulkan-Headers.url \
|
||||
"${srcdir}/vulkan-headers"
|
||||
git -c protocol.file.allow=always submodule update
|
||||
sed -i \
|
||||
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
|
||||
CMakeLists.txt
|
||||
}
|
||||
|
||||
build() {
|
||||
@@ -83,6 +78,7 @@ build() {
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=/usr/share/mocktail/metadata/roblox_compatibility.json \
|
||||
-DMOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST=/usr/share/mocktail/metadata/roblox_signing_certificates.json \
|
||||
-DMOCKTAIL_ENABLE_UPSTREAM_JNIVM=OFF \
|
||||
-DBUILD_TESTING=OFF
|
||||
cmake --build build
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = mocktail
|
||||
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
|
||||
pkgver = 0.1.0
|
||||
pkgver = 1.0.2
|
||||
pkgrel = 1
|
||||
url = https://github.com/komaruworld/mocktail
|
||||
arch = x86_64
|
||||
@@ -10,6 +10,7 @@ pkgbase = mocktail
|
||||
makedepends = ninja
|
||||
makedepends = nlohmann-json
|
||||
makedepends = pkgconf
|
||||
makedepends = vulkan-headers
|
||||
depends = capstone
|
||||
depends = curl
|
||||
depends = fontconfig
|
||||
@@ -36,11 +37,7 @@ pkgbase = mocktail
|
||||
conflicts = mocktail-bin
|
||||
conflicts = mocktail-git
|
||||
options = !debug
|
||||
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=0.1.0
|
||||
source = libjnivm::git+https://github.com/ChristopherHX/libjnivm.git
|
||||
source = vulkan-headers::git+https://github.com/KhronosGroup/Vulkan-Headers.git
|
||||
sha256sums = SKIP
|
||||
sha256sums = SKIP
|
||||
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.2
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = mocktail
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: komaruworld <rtysus228 at gmail dot com>
|
||||
|
||||
pkgname=mocktail
|
||||
pkgver=0.1.0
|
||||
pkgver=1.0.2
|
||||
pkgrel=1
|
||||
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
|
||||
arch=('x86_64')
|
||||
@@ -37,29 +37,24 @@ makedepends=(
|
||||
'ninja'
|
||||
'nlohmann-json'
|
||||
'pkgconf'
|
||||
'vulkan-headers'
|
||||
)
|
||||
optdepends=('gamemode: Feral GameMode integration')
|
||||
conflicts=('mocktail-bin' 'mocktail-git')
|
||||
options=('!debug')
|
||||
source=(
|
||||
"mocktail::git+https://github.com/komaruworld/mocktail.git#tag=${pkgver}"
|
||||
'libjnivm::git+https://github.com/ChristopherHX/libjnivm.git'
|
||||
'vulkan-headers::git+https://github.com/KhronosGroup/Vulkan-Headers.git'
|
||||
)
|
||||
sha256sums=(
|
||||
'SKIP'
|
||||
'SKIP'
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
prepare() {
|
||||
cd mocktail
|
||||
|
||||
git submodule init
|
||||
git config submodule.third_party/libjnivm.url "${srcdir}/libjnivm"
|
||||
git config submodule.third_party/Vulkan-Headers.url \
|
||||
"${srcdir}/vulkan-headers"
|
||||
git -c protocol.file.allow=always submodule update
|
||||
sed -i \
|
||||
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
|
||||
CMakeLists.txt
|
||||
}
|
||||
|
||||
build() {
|
||||
@@ -69,6 +64,7 @@ build() {
|
||||
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=/usr/share/mocktail/metadata/roblox_compatibility.json \
|
||||
-DMOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST=/usr/share/mocktail/metadata/roblox_signing_certificates.json \
|
||||
-DMOCKTAIL_ENABLE_UPSTREAM_JNIVM=OFF \
|
||||
-DBUILD_TESTING=OFF
|
||||
cmake --build build
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
||||
<title>Join a Roblox server with Mocktail</title>
|
||||
<style>
|
||||
:root { font: 17px/1.55 system-ui, sans-serif; color-scheme: dark; }
|
||||
body { margin: 0; background: #0d1117; color: #e6edf3; }
|
||||
main { max-width: 560px; margin: 15vh auto; padding: 32px; }
|
||||
h1 { font-size: 2rem; }
|
||||
p { color: #9da7b3; }
|
||||
a { display: inline-block; padding: 12px 18px; border-radius: 9px;
|
||||
background: #238636; color: white; text-decoration: none;
|
||||
font-weight: 700; }
|
||||
[hidden] { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>Join Server</h1>
|
||||
<p id="status">Checking the Discord invitation…</p>
|
||||
<a id="launch" hidden>Open Mocktail</a>
|
||||
<noscript>This invitation requires JavaScript to open Mocktail.</noscript>
|
||||
</main>
|
||||
<script>
|
||||
'use strict';
|
||||
const status = document.querySelector('#status');
|
||||
const launch = document.querySelector('#launch');
|
||||
const query = new URLSearchParams(location.hash.slice(1));
|
||||
const placeId = query.get('placeId') || '';
|
||||
const gameInstanceId = query.get('gameInstanceId') || '';
|
||||
const validPlace = /^[1-9][0-9]{0,18}$/.test(placeId);
|
||||
const validInstance = gameInstanceId.length === 0 ||
|
||||
(gameInstanceId.length <= 128 &&
|
||||
!/[\u0000-\u001f\u007f]/.test(gameInstanceId));
|
||||
if (!validPlace || !validInstance) {
|
||||
status.textContent = 'This server invitation is invalid or incomplete.';
|
||||
} else {
|
||||
let target = 'roblox://experiences/start?placeId=' + placeId;
|
||||
if (gameInstanceId.length > 0) {
|
||||
target += '&gameInstanceId=' + encodeURIComponent(gameInstanceId);
|
||||
}
|
||||
launch.href = target;
|
||||
launch.hidden = false;
|
||||
status.textContent = gameInstanceId.length > 0
|
||||
? 'Opening this public Roblox server in Mocktail…'
|
||||
: 'Opening this Roblox experience in Mocktail…';
|
||||
location.replace(target);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,7 +12,17 @@
|
||||
"--socket=fallback-x11",
|
||||
"--socket=pulseaudio",
|
||||
"--device=dri",
|
||||
"--talk-name=com.feralinteractive.GameMode"
|
||||
"--talk-name=com.feralinteractive.GameMode",
|
||||
"--filesystem=xdg-run/discord-ipc-0:rw",
|
||||
"--filesystem=xdg-run/discord-ipc-1:rw",
|
||||
"--filesystem=xdg-run/discord-ipc-2:rw",
|
||||
"--filesystem=xdg-run/discord-ipc-3:rw",
|
||||
"--filesystem=xdg-run/discord-ipc-4:rw",
|
||||
"--filesystem=xdg-run/discord-ipc-5:rw",
|
||||
"--filesystem=xdg-run/discord-ipc-6:rw",
|
||||
"--filesystem=xdg-run/discord-ipc-7:rw",
|
||||
"--filesystem=xdg-run/discord-ipc-8:rw",
|
||||
"--filesystem=xdg-run/discord-ipc-9:rw"
|
||||
],
|
||||
"build-options": {
|
||||
"strip": true,
|
||||
@@ -166,6 +176,9 @@
|
||||
"buildsystem": "cmake-ninja",
|
||||
"config-opts": [
|
||||
"-DCMAKE_BUILD_TYPE=Release",
|
||||
"-DCMAKE_INSTALL_LIBDIR=lib",
|
||||
"-DMOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=/app/share/mocktail/metadata/roblox_compatibility.json",
|
||||
"-DMOCKTAIL_DEFAULT_SIGNING_TRUST_MANIFEST=/app/share/mocktail/metadata/roblox_signing_certificates.json",
|
||||
"-DBUILD_TESTING=OFF"
|
||||
],
|
||||
"sources": [
|
||||
|
||||
@@ -4,46 +4,74 @@
|
||||
<component type="desktop-application">
|
||||
<id>space.bigrat.mocktail</id>
|
||||
<name>Mocktail</name>
|
||||
<summary>Experimental Roblox Android compatibility runtime for Linux</summary>
|
||||
<summary>Play Roblox on Linux</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>Apache-2.0</project_license>
|
||||
<developer id="space.bigrat">
|
||||
<name>Mocktail Project Authors</name>
|
||||
</developer>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#CEB3EF</color>
|
||||
<color type="primary" scheme_preference="dark">#2F2A6B</color>
|
||||
</branding>
|
||||
<url type="homepage">https://github.com/komaruworld/mocktail</url>
|
||||
<description>
|
||||
<p>
|
||||
Mocktail is an experimental compatibility runtime for running the
|
||||
Android x86-64 Roblox client on a native Linux desktop. It provides the
|
||||
pseudo-JVM, Android ABI adapters, SDL window/input/audio integration and
|
||||
Vulkan presentation needed by the client.
|
||||
Sign in to Roblox, browse experiences, and play them on Linux.
|
||||
</p>
|
||||
<p>
|
||||
The project is a prototype and is not affiliated with or endorsed by
|
||||
Roblox Corporation. The verified Roblox payload is downloaded on first
|
||||
launch and is not distributed with Mocktail.
|
||||
Mocktail is an independent community project. It is not affiliated with
|
||||
Roblox Corporation or VinegarHQ, and the Roblox client is not included.
|
||||
</p>
|
||||
</description>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/0.1.0/docs/screenshots/flatpak-home.png</image>
|
||||
<caption>Browse Roblox experiences in the desktop client</caption>
|
||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-home.png</image>
|
||||
<caption>Browse Roblox experiences</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/0.1.0/docs/screenshots/flatpak-gameplay-tower.png</image>
|
||||
<caption>Roblox gameplay running through Mocktail</caption>
|
||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-tower.png</image>
|
||||
<caption>Play Tower of Hell on Linux</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/0.1.0/docs/screenshots/flatpak-gameplay-lobby.png</image>
|
||||
<caption>A Roblox experience running in the Flatpak build</caption>
|
||||
<image>https://raw.githubusercontent.com/komaruworld/mocktail/88b7382577aa138a2d16619b091f69abcdf7da15/assets/screenshots/flatpak-gameplay-lobby.png</image>
|
||||
<caption>Play DOORS on Linux</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<launchable type="desktop-id">space.bigrat.mocktail.desktop</launchable>
|
||||
<content_rating type="oars-1.1"/>
|
||||
<content_rating type="oars-1.1">
|
||||
<content_attribute id="violence-cartoon">moderate</content_attribute>
|
||||
<content_attribute id="violence-fantasy">moderate</content_attribute>
|
||||
<content_attribute id="violence-realistic">moderate</content_attribute>
|
||||
<content_attribute id="social-chat">moderate</content_attribute>
|
||||
<content_attribute id="social-info">moderate</content_attribute>
|
||||
<content_attribute id="social-audio">moderate</content_attribute>
|
||||
<content_attribute id="social-contacts">intense</content_attribute>
|
||||
<content_attribute id="money-purchasing">intense</content_attribute>
|
||||
<content_attribute id="money-advertising">intense</content_attribute>
|
||||
<content_attribute id="money-gambling">moderate</content_attribute>
|
||||
</content_rating>
|
||||
<provides>
|
||||
<binary>mocktail</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="1.0.2" date="2026-08-14">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fixed the “Roblox is out of date” bug</li>
|
||||
<li>Improved logging</li>
|
||||
<li>Fixed issues with Discord RPC where the game you were playing wasn’t displayed</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="1.0.1" date="2026-08-13">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Fix issues with GameMode</li>
|
||||
<li>Add Discord RPC support</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.0" date="2026-08-12"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -59,7 +59,7 @@ VERSION_MARKER_PATTERN = re.compile(
|
||||
|
||||
|
||||
class ProviderError(RuntimeError):
|
||||
"""A safe, user-facing provider failure."""
|
||||
"""Raised when a provider response cannot be downloaded or validated."""
|
||||
|
||||
|
||||
def _has_version_record(metadata: bytes, version: str) -> bool:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
"""Download a provenance-pinned Uptodown XAPK bootstrap archive."""
|
||||
"""Download a pinned Uptodown XAPK for Mocktail's first-run setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -47,7 +47,7 @@ DOWNLOAD_ORIGIN = "https://dw.uptodown.com/dwn/"
|
||||
|
||||
|
||||
class ProviderError(RuntimeError):
|
||||
"""A safe, user-facing provider failure."""
|
||||
"""Raised when a provider response cannot be downloaded or validated."""
|
||||
|
||||
|
||||
class DownloadPageParser(HTMLParser):
|
||||
|
||||
@@ -9,7 +9,10 @@ readonly REPOSITORY="${1:?Flatpak repository path is required}"
|
||||
readonly BUNDLE="${2:?Flatpak bundle path is required}"
|
||||
readonly PUBLIC_KEY="${3:?GPG public key path is required}"
|
||||
readonly OUTPUT="${4:?Pages output path is required}"
|
||||
readonly BASE_URL="https://komaruworld.github.io/mocktail"
|
||||
readonly NATIVE_REPOSITORIES="${5:?Native repositories path is required}"
|
||||
readonly BASE_URL="https://mocktail.bigrat.space"
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
readonly PROJECT_DIR="$(dirname -- "${SCRIPT_DIR}")"
|
||||
|
||||
[[ -d "${REPOSITORY}" && ! -L "${REPOSITORY}" ]] || {
|
||||
printf 'Flatpak repository is missing or unsafe: %s\n' "${REPOSITORY}" >&2
|
||||
@@ -23,6 +26,13 @@ readonly BASE_URL="https://komaruworld.github.io/mocktail"
|
||||
printf 'Flatpak public key is missing or unsafe: %s\n' "${PUBLIC_KEY}" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -d "${NATIVE_REPOSITORIES}/apt" &&
|
||||
-d "${NATIVE_REPOSITORIES}/rpm" &&
|
||||
-d "${NATIVE_REPOSITORIES}/downloads" &&
|
||||
-s "${NATIVE_REPOSITORIES}/mocktail-packages.gpg" ]] || {
|
||||
printf 'Native repositories are missing: %s\n' "${NATIVE_REPOSITORIES}" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ ! -e "${OUTPUT}" ]] || {
|
||||
printf 'Pages output already exists: %s\n' "${OUTPUT}" >&2
|
||||
exit 1
|
||||
@@ -33,67 +43,47 @@ cp -a -- "${REPOSITORY}" "${OUTPUT}/repo"
|
||||
install -m 0644 -- "${BUNDLE}" "${OUTPUT}/Mocktail-x86_64.flatpak"
|
||||
install -m 0644 -- "${PUBLIC_KEY}" "${OUTPUT}/mocktail-flatpak.gpg"
|
||||
install -m 0644 -- \
|
||||
"$(dirname -- "${BASH_SOURCE[0]}")/../packaging/space.bigrat.mocktail.svg" \
|
||||
"${PROJECT_DIR}/packaging/space.bigrat.mocktail.svg" \
|
||||
"${OUTPUT}/mocktail.svg"
|
||||
install -m 0644 -- \
|
||||
"${PROJECT_DIR}/packaging/discord-join.html" \
|
||||
"${OUTPUT}/join.html"
|
||||
install -m 0644 -- "${PROJECT_DIR}/site/index.html" "${OUTPUT}/index.html"
|
||||
install -m 0644 -- "${PROJECT_DIR}/site/styles.css" "${OUTPUT}/styles.css"
|
||||
install -d -- "${OUTPUT}/packaging"
|
||||
install -m 0644 -- \
|
||||
"${PROJECT_DIR}/packaging/space.bigrat.mocktail.svg" \
|
||||
"${OUTPUT}/packaging/space.bigrat.mocktail.svg"
|
||||
cp -a -- "${NATIVE_REPOSITORIES}/apt" "${OUTPUT}/apt"
|
||||
cp -a -- "${NATIVE_REPOSITORIES}/rpm" "${OUTPUT}/rpm"
|
||||
cp -a -- "${NATIVE_REPOSITORIES}/downloads" "${OUTPUT}/downloads"
|
||||
install -m 0644 -- \
|
||||
"${NATIVE_REPOSITORIES}/mocktail-packages.gpg" \
|
||||
"${OUTPUT}/mocktail-packages.gpg"
|
||||
|
||||
readonly GPG_KEY="$(base64 --wrap=0 "${PUBLIC_KEY}")"
|
||||
|
||||
cat >"${OUTPUT}/mocktail.flatpakrepo" <<EOF
|
||||
[Flatpak Repo]
|
||||
Title=Mocktail
|
||||
Title=Mocktail Nightly
|
||||
Url=${BASE_URL}/repo/
|
||||
Homepage=https://github.com/komaruworld/mocktail
|
||||
Comment=Development builds of Mocktail
|
||||
Description=Signed x86_64 Flatpak builds published from Mocktail main
|
||||
Comment=Nightly builds of Mocktail
|
||||
Description=Signed x86_64 nightly builds published from the latest Mocktail main branch
|
||||
Icon=${BASE_URL}/mocktail.svg
|
||||
GPGKey=${GPG_KEY}
|
||||
EOF
|
||||
|
||||
cat >"${OUTPUT}/mocktail.flatpakref" <<EOF
|
||||
[Flatpak Ref]
|
||||
Title=Mocktail
|
||||
Title=Mocktail Nightly
|
||||
Name=space.bigrat.mocktail
|
||||
Branch=stable
|
||||
Url=${BASE_URL}/repo/
|
||||
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
Homepage=https://github.com/komaruworld/mocktail
|
||||
Comment=Experimental Linux compatibility runtime
|
||||
Comment=Nightly build from the latest main branch
|
||||
Icon=${BASE_URL}/mocktail.svg
|
||||
GPGKey=${GPG_KEY}
|
||||
IsRuntime=false
|
||||
EOF
|
||||
|
||||
cat >"${OUTPUT}/index.html" <<'EOF'
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Mocktail for Linux</title>
|
||||
<style>
|
||||
:root { font: 17px/1.55 system-ui, sans-serif; color-scheme: dark; }
|
||||
body { margin: 0; background: #0d1117; color: #e6edf3; }
|
||||
main { max-width: 680px; margin: 12vh auto; padding: 32px; }
|
||||
img { width: 88px; height: 88px; }
|
||||
h1 { font-size: clamp(2.5rem, 8vw, 4.5rem); margin: .2rem 0; }
|
||||
p { color: #9da7b3; }
|
||||
a.button { display: inline-block; margin: 16px 8px 16px 0; padding: 12px 18px;
|
||||
border-radius: 9px; background: #238636; color: white; text-decoration: none;
|
||||
font-weight: 700; }
|
||||
a.secondary { background: #21262d; }
|
||||
code { background: #161b22; border: 1px solid #30363d; border-radius: 6px;
|
||||
display: block; padding: 14px; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<img src="mocktail.svg" alt="Mocktail logo">
|
||||
<h1>Mocktail</h1>
|
||||
<p>Signed development Flatpak builds for Linux x86_64.</p>
|
||||
<a class="button" href="mocktail.flatpakref">Install with Flatpak</a>
|
||||
<a class="button secondary" href="https://github.com/komaruworld/mocktail">Source</a>
|
||||
<code>flatpak install --user https://komaruworld.github.io/mocktail/mocktail.flatpakref</code>
|
||||
<p>Mocktail is experimental and is not affiliated with Roblox Corporation.</p>
|
||||
</main></body>
|
||||
</html>
|
||||
EOF
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -Eeuo pipefail
|
||||
umask 022
|
||||
|
||||
readonly STABLE_DEB="${1:?Stable DEB path is required}"
|
||||
readonly NIGHTLY_DEB="${2:?Nightly DEB path is required}"
|
||||
readonly STABLE_RPM="${3:?Stable RPM path is required}"
|
||||
readonly NIGHTLY_RPM="${4:?Nightly RPM path is required}"
|
||||
readonly PUBLIC_KEY="${5:?GPG public key path is required}"
|
||||
readonly GPG_KEY_ID="${6:?GPG key ID is required}"
|
||||
readonly GPG_HOME="${7:?GPG home path is required}"
|
||||
readonly OUTPUT="${8:?Repository output path is required}"
|
||||
readonly BASE_URL="https://mocktail.bigrat.space"
|
||||
|
||||
for command in apt-ftparchive createrepo_c dpkg-deb gpg gzip rpm rpmsign; do
|
||||
command -v "${command}" >/dev/null || {
|
||||
printf 'Required command is missing: %s\n' "${command}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
for package in "${STABLE_DEB}" "${NIGHTLY_DEB}" \
|
||||
"${STABLE_RPM}" "${NIGHTLY_RPM}"; do
|
||||
[[ -s "${package}" && ! -L "${package}" ]] || {
|
||||
printf 'Package is missing or unsafe: %s\n' "${package}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
[[ -s "${PUBLIC_KEY}" && ! -L "${PUBLIC_KEY}" ]] || {
|
||||
printf 'GPG public key is missing or unsafe: %s\n' "${PUBLIC_KEY}" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -d "${GPG_HOME}" && ! -L "${GPG_HOME}" ]] || {
|
||||
printf 'GPG home is missing or unsafe: %s\n' "${GPG_HOME}" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ ! -e "${OUTPUT}" ]] || {
|
||||
printf 'Repository output already exists: %s\n' "${OUTPUT}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ "$(dpkg-deb --field "${STABLE_DEB}" Package)" == mocktail ]]
|
||||
[[ "$(dpkg-deb --field "${NIGHTLY_DEB}" Package)" == mocktail-nightly ]]
|
||||
[[ "$(rpm --query --package --queryformat '%{NAME}' "${STABLE_RPM}")" == mocktail ]]
|
||||
[[ "$(rpm --query --package --queryformat '%{NAME}' "${NIGHTLY_RPM}")" == mocktail-nightly ]]
|
||||
|
||||
readonly APT_ROOT="${OUTPUT}/apt"
|
||||
readonly APT_POOL="${APT_ROOT}/pool/main/m/mocktail"
|
||||
readonly APT_DIST="${APT_ROOT}/dists/mocktail"
|
||||
readonly RPM_ROOT="${OUTPUT}/rpm/x86_64"
|
||||
readonly DOWNLOADS="${OUTPUT}/downloads"
|
||||
|
||||
install -d -- \
|
||||
"${APT_POOL}" "${APT_DIST}/main/binary-amd64" "${RPM_ROOT}" "${DOWNLOADS}"
|
||||
install -m 0644 -- "${STABLE_DEB}" "${APT_POOL}/$(basename -- "${STABLE_DEB}")"
|
||||
install -m 0644 -- "${NIGHTLY_DEB}" "${APT_POOL}/$(basename -- "${NIGHTLY_DEB}")"
|
||||
install -m 0644 -- "${STABLE_RPM}" "${RPM_ROOT}/$(basename -- "${STABLE_RPM}")"
|
||||
install -m 0644 -- "${NIGHTLY_RPM}" "${RPM_ROOT}/$(basename -- "${NIGHTLY_RPM}")"
|
||||
install -m 0644 -- "${STABLE_DEB}" "${DOWNLOADS}/mocktail.deb"
|
||||
install -m 0644 -- "${STABLE_RPM}" "${DOWNLOADS}/mocktail.rpm"
|
||||
install -m 0644 -- "${PUBLIC_KEY}" "${OUTPUT}/mocktail-packages.gpg"
|
||||
|
||||
(
|
||||
cd -- "${APT_ROOT}"
|
||||
apt-ftparchive packages pool/main >dists/mocktail/main/binary-amd64/Packages
|
||||
gzip -9n -c dists/mocktail/main/binary-amd64/Packages \
|
||||
>dists/mocktail/main/binary-amd64/Packages.gz
|
||||
apt-ftparchive \
|
||||
-o APT::FTPArchive::Release::Origin=Mocktail \
|
||||
-o APT::FTPArchive::Release::Label=Mocktail \
|
||||
-o APT::FTPArchive::Release::Suite=mocktail \
|
||||
-o APT::FTPArchive::Release::Codename=mocktail \
|
||||
-o APT::FTPArchive::Release::Architectures=amd64 \
|
||||
-o APT::FTPArchive::Release::Components=main \
|
||||
release dists/mocktail >dists/mocktail/Release
|
||||
)
|
||||
|
||||
gpg --batch --yes --homedir "${GPG_HOME}" --local-user "${GPG_KEY_ID}" \
|
||||
--digest-algo SHA256 --clearsign \
|
||||
--output "${APT_DIST}/InRelease" "${APT_DIST}/Release"
|
||||
gpg --batch --yes --homedir "${GPG_HOME}" --local-user "${GPG_KEY_ID}" \
|
||||
--digest-algo SHA256 --armor --detach-sign \
|
||||
--output "${APT_DIST}/Release.gpg" "${APT_DIST}/Release"
|
||||
|
||||
for package in "${RPM_ROOT}"/*.rpm; do
|
||||
GNUPGHOME="${GPG_HOME}" rpmsign --addsign \
|
||||
--define "_gpg_name ${GPG_KEY_ID}" \
|
||||
--define "_gpg_path ${GPG_HOME}" \
|
||||
"${package}"
|
||||
done
|
||||
|
||||
createrepo_c "${RPM_ROOT}"
|
||||
gpg --batch --yes --homedir "${GPG_HOME}" --local-user "${GPG_KEY_ID}" \
|
||||
--digest-algo SHA256 --armor --detach-sign \
|
||||
--output "${RPM_ROOT}/repodata/repomd.xml.asc" \
|
||||
"${RPM_ROOT}/repodata/repomd.xml"
|
||||
|
||||
cat >"${OUTPUT}/rpm/mocktail.repo" <<EOF
|
||||
[mocktail]
|
||||
name=Mocktail
|
||||
baseurl=${BASE_URL}/rpm/\$basearch/
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
gpgkey=${BASE_URL}/mocktail-packages.gpg
|
||||
EOF
|
||||
@@ -86,6 +86,7 @@ LAUNCH="${MOCKTAIL_UPDATE_LAUNCH:-false}"
|
||||
SKIP_BUILD=false
|
||||
SCHEDULED=false
|
||||
STARTUP_PREFLIGHT=false
|
||||
TESTING_LATEST_ONLY=true
|
||||
UPDATE_CONFIG_JSON="{}"
|
||||
STAGED_PAYLOAD_ID=""
|
||||
DOWNLOAD_STAGING_ROOT="${CACHE_ROOT}/downloads/apk-staging"
|
||||
@@ -113,7 +114,8 @@ Options:
|
||||
--skip-build Reuse the existing Release binary for the canary.
|
||||
--scheduled Run only when updates.automatic is true in YAML.
|
||||
--startup-preflight Validate a runnable local payload without checking for
|
||||
remote updates; provision one when none is available.
|
||||
remote updates, unless updates.testing_latest_only is
|
||||
enabled; provision one when none is available.
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
@@ -188,6 +190,8 @@ ParseArguments() {
|
||||
LoadYamlDefaults() {
|
||||
UPDATE_CONFIG_JSON="$(python3 "${SCRIPT_DIR}/read_update_config.py" "${CONFIG_FILE}")"
|
||||
[[ -n "${SOURCE}" ]] || SOURCE="$(jq -r '.source // "apk-pure"' <<<"${UPDATE_CONFIG_JSON}")"
|
||||
TESTING_LATEST_ONLY="$(jq -r '.testing_latest_only // true' \
|
||||
<<<"${UPDATE_CONFIG_JSON}")"
|
||||
if [[ "${LAUNCH}" != true &&
|
||||
"$(jq -r '.launch_after_update // false' <<<"${UPDATE_CONFIG_JSON}")" == true ]]; then
|
||||
LAUNCH=true
|
||||
@@ -562,6 +566,9 @@ SkipDownloadWhenCurrent() {
|
||||
Log "latest-version API check failed; preserving the runnable local payload"
|
||||
return 0
|
||||
fi
|
||||
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
|
||||
Die "updates.testing_latest_only requires available provider latest metadata"
|
||||
fi
|
||||
Log "latest-version API check failed and no runnable local payload exists; using the compatibility bootstrap profile"
|
||||
SelectSupportedBootstrapFallback
|
||||
return 1
|
||||
@@ -572,6 +579,9 @@ SkipDownloadWhenCurrent() {
|
||||
Log "latest-version API returned invalid metadata; preserving the runnable local payload"
|
||||
return 0
|
||||
fi
|
||||
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
|
||||
Die "updates.testing_latest_only requires valid provider latest metadata"
|
||||
fi
|
||||
Log "provider returned invalid version metadata and no runnable local payload exists; using the compatibility bootstrap profile"
|
||||
SelectSupportedBootstrapFallback
|
||||
return 1
|
||||
@@ -605,6 +615,11 @@ SkipRemoteUpdateForStartup() {
|
||||
Die "current payload is not runnable by this Mocktail runtime and rollback failed"
|
||||
fi
|
||||
|
||||
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
|
||||
Log "testing latest-only track enabled; checking provider latest during startup"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local current_payload_id
|
||||
current_payload_id="$(VerifiedCurrentPayloadId 2>/dev/null || true)"
|
||||
if [[ "${current_payload_id}" =~ ^[0-9]+-[0-9a-f]{40}$ ]]; then
|
||||
@@ -1551,6 +1566,9 @@ Main() {
|
||||
LoadYamlDefaults
|
||||
ParseArguments "$@"
|
||||
[[ "${SOURCE}" == apk-pure ]] || Die "update source must be apk-pure"
|
||||
if [[ "${TESTING_LATEST_ONLY}" == true && -n "${VERSION}" ]]; then
|
||||
Die "--version cannot be combined with updates.testing_latest_only"
|
||||
fi
|
||||
if [[ "${SCHEDULED}" == true &&
|
||||
"$(jq -r 'if has("automatic") then .automatic else true end' \
|
||||
<<<"${UPDATE_CONFIG_JSON}")" != true ]]; then
|
||||
@@ -1585,6 +1603,10 @@ Main() {
|
||||
PreserveCurrentAfterUpdateFailure "latest payload acquisition failed" \
|
||||
"${pre_stage_payload_id}" &&
|
||||
return 0
|
||||
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
|
||||
Log "latest payload acquisition failed; testing latest-only track forbids a supported fallback"
|
||||
return "${stage_status}"
|
||||
fi
|
||||
local bootstrap_ceiling=""
|
||||
if [[ "${BOOTSTRAP_SELECTED_VERSION_CODE}" =~ ^[0-9]+$ ]]; then
|
||||
(( 10#${BOOTSTRAP_SELECTED_VERSION_CODE} > 1 )) ||
|
||||
@@ -1618,6 +1640,16 @@ Main() {
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if [[ "${TESTING_LATEST_ONLY}" == true ]]; then
|
||||
if [[ "${had_current}" == true ]]; then
|
||||
PreserveCurrentAfterUpdateFailure \
|
||||
"latest candidate ${latest_payload_id} failed probation" \
|
||||
"${LAST_ATTEMPT_BASELINE_PAYLOAD_ID}"
|
||||
return 0
|
||||
fi
|
||||
Log "latest candidate ${latest_payload_id} failed probation; testing latest-only track forbids a supported fallback"
|
||||
return "${attempt_status}"
|
||||
fi
|
||||
if [[ "${latest_needs_reference}" != true && "${had_current}" == true ]]; then
|
||||
PreserveCurrentAfterUpdateFailure \
|
||||
"latest candidate ${latest_payload_id} failed probation" \
|
||||
|
||||
@@ -6,47 +6,9 @@
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# scripts/build.sh — Mocktail one-shot native build script.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build.sh [OPTIONS]
|
||||
#
|
||||
# Options:
|
||||
# --apk <path> Explicitly extract a local Roblox x86_64 APK.
|
||||
# --apk-url <url> Explicitly download and extract an APK URL.
|
||||
# --skip-apk Compatibility alias for the default managed-payload mode.
|
||||
# --build-dir <path> CMake build directory. Relative paths use project root.
|
||||
# Default: build.
|
||||
# --cmake-toolchain <file>
|
||||
# Explicit CMake toolchain file.
|
||||
# --cmake-sysroot <dir>
|
||||
# Explicit compiler/CMake sysroot.
|
||||
# --build-type <type> CMake build type: Debug | Release | RelWithDebInfo.
|
||||
# Default: Release.
|
||||
# --jobs <n> Parallel build jobs. Default: nproc.
|
||||
# --run-tests Run CTest after build.
|
||||
# --clean Remove the build/ directory before configuring.
|
||||
# --help Show this help and exit.
|
||||
#
|
||||
# Examples:
|
||||
# # Build with a local APK file:
|
||||
# ./scripts/build.sh --apk ~/Downloads/roblox-x86_64.apk
|
||||
#
|
||||
# # Build with a direct download URL:
|
||||
# ./scripts/build.sh --apk-url "https://example.com/roblox-x86_64.apk"
|
||||
#
|
||||
# # Rebuild the C++ project; the first launch obtains the managed payload:
|
||||
# ./scripts/build.sh --skip-apk --build-type Debug --run-tests
|
||||
#
|
||||
# With no APK option, Mocktail builds only the native runtime. Its first normal
|
||||
# launch downloads, validates, canaries, and activates a supported x86_64
|
||||
# payload in the user's XDG data directory. ROBLOX_LIB_PATH remains an explicit
|
||||
# research override and disables APK extraction when already set.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colour helpers
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
@@ -91,7 +53,6 @@ Examples:
|
||||
EOF
|
||||
}
|
||||
|
||||
# Default configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
BUILD_DIR="${PROJECT_ROOT}/build"
|
||||
@@ -107,7 +68,6 @@ CLEAN=false
|
||||
CMAKE_TOOLCHAIN_FILE=""
|
||||
CMAKE_SYSROOT=""
|
||||
|
||||
# Argument parsing
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--apk)
|
||||
@@ -180,7 +140,6 @@ info "Mocktail build: ${BUILD_TYPE}, -j${JOBS}"
|
||||
info "Project root: ${PROJECT_ROOT}"
|
||||
info "Build directory: ${BUILD_DIR}"
|
||||
|
||||
# Step 1 — Check required tools
|
||||
step "1/5 checking dependencies"
|
||||
|
||||
check_tool() {
|
||||
@@ -208,7 +167,6 @@ if [[ "${EXTRACT_APK}" == true && -n "${APK_URL}" ]] &&
|
||||
check_tool wget "sudo apt install wget"
|
||||
fi
|
||||
|
||||
# Step 2 — Download APK (if URL provided)
|
||||
if [[ "${EXTRACT_APK}" == true && -n "${APK_URL}" ]]; then
|
||||
step "2/5 downloading Roblox APK"
|
||||
APK_PATH="${PROJECT_ROOT}/rbx_bin/roblox-x86_64.apk"
|
||||
@@ -223,7 +181,6 @@ if [[ "${EXTRACT_APK}" == true && -n "${APK_URL}" ]]; then
|
||||
success "APK downloaded."
|
||||
fi
|
||||
|
||||
# Step 3 — Extract libroblox.so from APK
|
||||
LIBROBLOX_DST="${RBX_BIN_DIR}/libroblox.so"
|
||||
|
||||
if [[ -n "${ROBLOX_LIB_PATH:-}" ]]; then
|
||||
@@ -251,7 +208,6 @@ else
|
||||
success "No APK or libroblox.so is required at build time."
|
||||
fi
|
||||
|
||||
# Step 4 — Init git submodules
|
||||
step "4/5 initialising git submodules"
|
||||
cd "${PROJECT_ROOT}"
|
||||
if [[ -f ".gitmodules" ]]; then
|
||||
@@ -262,7 +218,6 @@ else
|
||||
info "To add submodules run: ./scripts/add_submodules.sh"
|
||||
fi
|
||||
|
||||
# Step 5 — CMake configure
|
||||
step "5/5 configuring CMake"
|
||||
|
||||
if [[ "${CLEAN}" == true && -d "${BUILD_DIR}" ]]; then
|
||||
@@ -293,21 +248,18 @@ cmake "${cmake_arguments[@]}"
|
||||
|
||||
success "CMake configuration complete."
|
||||
|
||||
# Step 6 — Build
|
||||
step "building Mocktail"
|
||||
|
||||
cmake --build "${BUILD_DIR}" -j"${JOBS}"
|
||||
|
||||
success "Build complete → ${BUILD_DIR}/mocktail"
|
||||
|
||||
# Step 7 — Run tests (optional)
|
||||
if [[ "${RUN_TESTS}" == true ]]; then
|
||||
step "running unit tests"
|
||||
ctest --test-dir "${BUILD_DIR}" --output-on-failure -j"${JOBS}"
|
||||
success "All tests passed."
|
||||
fi
|
||||
|
||||
# Done
|
||||
echo
|
||||
success "Build successful."
|
||||
echo "Run:"
|
||||
|
||||
@@ -13,12 +13,11 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Derive a fail-closed HostAbi profile from one exact approved payload.
|
||||
"""Build a HostAbi profile for one approved payload without running it.
|
||||
|
||||
The analyzer never executes guest code and never marks a payload supported.
|
||||
It accepts only an exact reference sidecar bound to the reference ELF digest,
|
||||
matches native boundaries structurally, and emits an experimental manifest for
|
||||
an isolated updater probation run.
|
||||
The reference sidecar must match the reference ELF digest. The resulting
|
||||
manifest is meant for an updater probation run; it does not mark the payload as
|
||||
supported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,7 +39,7 @@ from typing import Any, Iterator, Sequence
|
||||
try:
|
||||
import capstone
|
||||
from capstone import x86 as capstone_x86
|
||||
except ImportError: # The CLI turns this into one explicit fail-closed error.
|
||||
except ImportError:
|
||||
capstone = None
|
||||
capstone_x86 = None
|
||||
|
||||
@@ -107,7 +106,7 @@ PRIMARY_TOOLCHAIN_MARKERS = (
|
||||
|
||||
|
||||
class AnalyzerError(RuntimeError):
|
||||
"""One deterministic fail-closed analysis error."""
|
||||
"""Raised when HostAbi analysis rejects an input."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -1089,9 +1088,13 @@ def validate_semantic_match(
|
||||
raise AnalyzerError("registry initializer object size is inconsistent")
|
||||
|
||||
|
||||
def find_signature_match(
|
||||
reference: ElfImage, candidate: ElfImage, spec: SignatureSpec
|
||||
) -> SignatureMatch:
|
||||
def find_signature_matches(
|
||||
reference: ElfImage,
|
||||
candidate: ElfImage,
|
||||
spec: SignatureSpec,
|
||||
*,
|
||||
allow_multiple_candidates: bool = False,
|
||||
) -> tuple[SignatureMatch, ...]:
|
||||
reference_instructions = disassemble(
|
||||
reference, spec.reference_rva, spec.instruction_count
|
||||
)
|
||||
@@ -1132,18 +1135,59 @@ def find_signature_match(
|
||||
):
|
||||
matches.append(signature_offset)
|
||||
search_offset = anchor_position + 1
|
||||
if not matches:
|
||||
raise AnalyzerError(
|
||||
f"signature {spec.name} matched 0 candidate locations"
|
||||
)
|
||||
if not allow_multiple_candidates and len(matches) != 1:
|
||||
raise AnalyzerError(
|
||||
f"signature {spec.name} matched {len(matches)} candidate locations"
|
||||
)
|
||||
semantic_matches = []
|
||||
semantic_errors = []
|
||||
for match in matches:
|
||||
match_rva = text.address + match - text.offset
|
||||
candidate_instructions = disassemble(
|
||||
candidate, match_rva, spec.instruction_count
|
||||
)
|
||||
try:
|
||||
validate_semantic_match(
|
||||
reference_instructions,
|
||||
candidate_instructions,
|
||||
spec.allow_registry_object_size_change,
|
||||
)
|
||||
except AnalyzerError as error:
|
||||
semantic_errors.append(error)
|
||||
continue
|
||||
semantic_matches.append(
|
||||
SignatureMatch(
|
||||
match_rva, reference_instructions, candidate_instructions
|
||||
)
|
||||
)
|
||||
if not semantic_matches and len(matches) == 1 and semantic_errors:
|
||||
raise semantic_errors[0]
|
||||
return tuple(semantic_matches)
|
||||
|
||||
|
||||
def find_signature_match(
|
||||
reference: ElfImage, candidate: ElfImage, spec: SignatureSpec
|
||||
) -> SignatureMatch:
|
||||
matches = find_signature_matches(reference, candidate, spec)
|
||||
if len(matches) != 1:
|
||||
raise AnalyzerError(
|
||||
f"signature {spec.name} matched {len(matches)} candidate locations"
|
||||
)
|
||||
match_rva = text.address + matches[0] - text.offset
|
||||
candidate_instructions = disassemble(candidate, match_rva, spec.instruction_count)
|
||||
validate_semantic_match(
|
||||
reference_instructions,
|
||||
candidate_instructions,
|
||||
spec.allow_registry_object_size_change,
|
||||
)
|
||||
return SignatureMatch(match_rva, reference_instructions, candidate_instructions)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def direct_call_target(instruction: Any, description: str) -> int:
|
||||
if (
|
||||
instruction.mnemonic != "call"
|
||||
or len(instruction.operands) != 1
|
||||
or instruction.operands[0].type != capstone_x86.X86_OP_IMM
|
||||
):
|
||||
raise AnalyzerError(f"{description} is not one direct call")
|
||||
return instruction.operands[0].imm
|
||||
|
||||
|
||||
def rip_targets(instruction: Any) -> tuple[int, ...]:
|
||||
@@ -1257,7 +1301,13 @@ def create_signature_specs(
|
||||
24,
|
||||
),
|
||||
SignatureSpec(
|
||||
"usable-size", parse_rva(bridges["usable-size"]["rva"], "usable size"), 24
|
||||
# This bridge is an 18-instruction leaf. Keeping the signature
|
||||
# bounded to the leaf avoids coupling it to padding or the next
|
||||
# function, which changed from executable code to INT3 padding in
|
||||
# Roblox versionCode 2904.
|
||||
"usable-size",
|
||||
parse_rva(bridges["usable-size"]["rva"], "usable size"),
|
||||
18,
|
||||
),
|
||||
SignatureSpec(
|
||||
"reallocate", parse_rva(bridges["reallocate"]["rva"], "reallocate"), 24
|
||||
@@ -1278,8 +1328,12 @@ def create_signature_specs(
|
||||
),
|
||||
SignatureSpec(
|
||||
"allocator-thread-initializer",
|
||||
parse_rva(data_seeds["allocator_thread_initializer"], "thread initializer"),
|
||||
24,
|
||||
parse_rva(
|
||||
data_seeds["allocator_thread_initializer"], "thread initializer"
|
||||
),
|
||||
# The wrapper ends at instruction 19. Later instructions belong
|
||||
# to an unrelated adjacent initializer.
|
||||
19,
|
||||
),
|
||||
SignatureSpec(
|
||||
"registry-initializer",
|
||||
@@ -1363,10 +1417,47 @@ def derive_profile(
|
||||
|
||||
reference_profile = reference_sidecar["profile"]
|
||||
anchors = reference_sidecar["derivation_anchors"]
|
||||
matches = {
|
||||
spec.name: find_signature_match(reference, candidate, spec)
|
||||
for spec in create_signature_specs(reference_profile, anchors)
|
||||
}
|
||||
matches = {}
|
||||
allocate_matches = ()
|
||||
for spec in create_signature_specs(reference_profile, anchors):
|
||||
if spec.name == "allocate":
|
||||
allocate_matches = find_signature_matches(
|
||||
reference, candidate, spec, allow_multiple_candidates=True
|
||||
)
|
||||
else:
|
||||
matches[spec.name] = find_signature_match(reference, candidate, spec)
|
||||
|
||||
# The allocate signature alone is intentionally ambiguous in 2904. Resolve
|
||||
# it through an independent invariant: the registry initializer directly
|
||||
# calls the allocator wrapper that Mocktail must publish in the profile.
|
||||
registry_match = matches["registry-initializer"]
|
||||
reference_allocate = parse_rva(
|
||||
bridge_map(reference_profile)["allocate"]["rva"],
|
||||
"reference allocate",
|
||||
)
|
||||
if (
|
||||
direct_call_target(
|
||||
registry_match.reference_instructions[9],
|
||||
"reference registry allocator call",
|
||||
)
|
||||
!= reference_allocate
|
||||
):
|
||||
raise AnalyzerError(
|
||||
"reference registry initializer does not call its allocator bridge"
|
||||
)
|
||||
candidate_allocate = direct_call_target(
|
||||
registry_match.candidate_instructions[9],
|
||||
"candidate registry allocator call",
|
||||
)
|
||||
selected_allocate = tuple(
|
||||
match for match in allocate_matches if match.rva == candidate_allocate
|
||||
)
|
||||
if len(selected_allocate) != 1:
|
||||
raise AnalyzerError(
|
||||
"signature allocate candidates do not identify exactly one "
|
||||
"registry allocator call"
|
||||
)
|
||||
matches["allocate"] = selected_allocate[0]
|
||||
reference_init = reference.init_array_relocations()
|
||||
candidate_init = candidate.init_array_relocations()
|
||||
if len(reference_init) != reference_profile["init_array_count"]:
|
||||
@@ -1636,9 +1727,7 @@ def analyze(arguments: argparse.Namespace) -> tuple[dict[str, Any], dict[str, An
|
||||
|
||||
def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Derive an exact experimental Roblox HostAbi sidecar for isolated probation"
|
||||
)
|
||||
description="Derive a probationary Roblox HostAbi sidecar"
|
||||
)
|
||||
parser.add_argument("--reference-lib", required=True, metavar="REF")
|
||||
parser.add_argument("--reference-profile", required=True, metavar="REFJSON")
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# Licensed under the Apache License, Version 2.0.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
|
||||
CONFIG_ROOT="${XDG_CONFIG_HOME:-${HOME}/.config}/mocktail"
|
||||
UNIT_ROOT="${XDG_CONFIG_HOME:-${HOME}/.config}/systemd/user"
|
||||
CANARY_BINARY="${MOCKTAIL_UPDATE_CANARY_BIN:-}"
|
||||
UPDATE_HELPER="${MOCKTAIL_UPDATE_HELPER:-}"
|
||||
|
||||
if [[ -z "${CANARY_BINARY}" ]]; then
|
||||
for candidate in "${PROJECT_ROOT}/build/mocktail" \
|
||||
/usr/lib/mocktail/mocktail; do
|
||||
if [[ -f "${candidate}" && ! -L "${candidate}" && -x "${candidate}" ]]; then
|
||||
CANARY_BINARY="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "${UPDATE_HELPER}" ]]; then
|
||||
for candidate in "${PROJECT_ROOT}/build/mocktail_updater" \
|
||||
/usr/lib/mocktail/mocktail_updater; do
|
||||
if [[ -f "${candidate}" && ! -L "${candidate}" && -x "${candidate}" ]]; then
|
||||
UPDATE_HELPER="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
[[ "${CANARY_BINARY}" == /* && -f "${CANARY_BINARY}" &&
|
||||
! -L "${CANARY_BINARY}" && -x "${CANARY_BINARY}" ]] || {
|
||||
echo "build or install Mocktail before enabling its update timer" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "${UPDATE_HELPER}" == /* && -f "${UPDATE_HELPER}" &&
|
||||
! -L "${UPDATE_HELPER}" && -x "${UPDATE_HELPER}" ]] || {
|
||||
echo "build or install the native Mocktail updater before enabling its timer" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ "${PROJECT_ROOT}" != *$'\n'* && "${PROJECT_ROOT}" != *'%'* ]] || {
|
||||
echo "unsupported project path for systemd environment file" >&2
|
||||
exit 1
|
||||
}
|
||||
mkdir -p "${CONFIG_ROOT}" "${UNIT_ROOT}"
|
||||
install -m 0644 "${PROJECT_ROOT}/config/systemd/mocktail-update.service" \
|
||||
"${UNIT_ROOT}/mocktail-update.service"
|
||||
install -m 0644 "${PROJECT_ROOT}/config/systemd/mocktail-update.timer" \
|
||||
"${UNIT_ROOT}/mocktail-update.timer"
|
||||
if [[ ! -e "${CONFIG_ROOT}/config.yaml" ]]; then
|
||||
install -m 0600 "${PROJECT_ROOT}/config/mocktail.example.yaml" \
|
||||
"${CONFIG_ROOT}/config.yaml"
|
||||
fi
|
||||
temporary="$(mktemp "${CONFIG_ROOT}/.updater.env.XXXXXX")"
|
||||
trap 'rm -f -- "${temporary}"' EXIT
|
||||
printf 'MOCKTAIL_UPDATE_HELPER=%q\n' "${UPDATE_HELPER}" > "${temporary}"
|
||||
printf 'MOCKTAIL_UPDATE_CANARY_BIN=%q\n' "${CANARY_BINARY}" >> "${temporary}"
|
||||
chmod 0600 "${temporary}"
|
||||
mv -f -- "${temporary}" "${CONFIG_ROOT}/updater.env"
|
||||
trap - EXIT
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now mocktail-update.timer
|
||||
systemctl --user list-timers mocktail-update.timer --no-pager
|
||||
@@ -105,13 +105,14 @@ def main() -> None:
|
||||
fail("updates must be a mapping")
|
||||
allowed = {
|
||||
"automatic",
|
||||
"testing_latest_only",
|
||||
"source",
|
||||
"launch_after_update",
|
||||
}
|
||||
unknown = set(updates) - allowed
|
||||
if unknown:
|
||||
fail(f"unknown updates key: {sorted(unknown)[0]}")
|
||||
for key in ("automatic", "launch_after_update"):
|
||||
for key in ("automatic", "testing_latest_only", "launch_after_update"):
|
||||
if key in updates and not isinstance(updates[key], bool):
|
||||
fail(f"updates.{key} must be true or false")
|
||||
if "source" in updates and updates["source"] not in SUPPORTED_SOURCES:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Progressive real-startup harness for Mocktail.
|
||||
# Tiers A, B, and LEGACY are research experiments. Tier C verifies the
|
||||
# Tiers A, B, and LEGACY are diagnostic modes. Tier C verifies the
|
||||
# supported LuaApp graphics path. GAME verifies the local UGCGame graphics
|
||||
# path. INPUT adds a real SDL-to-JNI click to that gate. NETWORK verifies an
|
||||
# authenticated public-place join without exposing
|
||||
@@ -360,7 +360,7 @@ SUPPORT_BUNDLE_DISABLED="${MOCKTAIL_DISABLE_SUPPORT_BUNDLE:-0}"
|
||||
export MOCKTAIL_DISABLE_SUPPORT_BUNDLE=1
|
||||
set +e
|
||||
# Roblox may ignore SIGTERM while a native engine thread is
|
||||
# active. Bound the smoke experiment even in that state so the caller gets a
|
||||
# active. Bound the smoke run even in that state so the caller gets a
|
||||
# deterministic result and no stale window/process is left behind. Tier C with
|
||||
# an explicitly disabled present timer is the supported interactive launch,
|
||||
# however, and must remain alive until the user closes it.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Mocktail for Linux</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body><main>
|
||||
<img src="../packaging/space.bigrat.mocktail.svg" alt="Mocktail logo">
|
||||
<h1>Mocktail</h1>
|
||||
<p>Play Roblox on Linux. Use the stable Flathub release, or try the latest nightly build from main.</p>
|
||||
<a class="button" href="https://flathub.org/apps/space.bigrat.mocktail">Stable on Flathub</a>
|
||||
<a class="button secondary" href="https://mocktail.bigrat.space/mocktail.flatpakref">Nightly build</a>
|
||||
<div class="commands">
|
||||
<p>Stable</p>
|
||||
<code>flatpak install flathub space.bigrat.mocktail</code>
|
||||
<p>Nightly</p>
|
||||
<code>flatpak install --user https://mocktail.bigrat.space/mocktail.flatpakref</code>
|
||||
</div>
|
||||
<h2>AUR packages</h2>
|
||||
<p>Available from the AUR. Choose one package:</p>
|
||||
<p class="links">
|
||||
<a href="https://aur.archlinux.org/packages/mocktail">mocktail</a>
|
||||
<a href="https://aur.archlinux.org/packages/mocktail-bin">mocktail-bin</a>
|
||||
<a href="https://aur.archlinux.org/packages/mocktail-git">mocktail-git</a>
|
||||
</p>
|
||||
<code>yay -S mocktail
|
||||
yay -S mocktail-bin
|
||||
yay -S mocktail-git</code>
|
||||
<h2>DNF repository</h2>
|
||||
<p>Stable and nightly packages for Fedora 44. Choose one package.</p>
|
||||
<code>sudo curl -fsSL https://mocktail.bigrat.space/rpm/mocktail.repo -o /etc/yum.repos.d/mocktail.repo</code>
|
||||
<div class="commands">
|
||||
<p>Stable</p>
|
||||
<code>sudo dnf install mocktail</code>
|
||||
<p>Nightly</p>
|
||||
<code>sudo dnf install mocktail-nightly</code>
|
||||
</div>
|
||||
<h2>APT repository</h2>
|
||||
<p>Stable and nightly packages for Ubuntu 26.04. Choose one package.</p>
|
||||
<code>sudo install -d -m 0755 /etc/apt/keyrings
|
||||
sudo curl -fsSL https://mocktail.bigrat.space/mocktail-packages.gpg -o /etc/apt/keyrings/mocktail.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/mocktail.gpg] https://mocktail.bigrat.space/apt mocktail main" | sudo tee /etc/apt/sources.list.d/mocktail.list >/dev/null
|
||||
sudo apt update</code>
|
||||
<div class="commands">
|
||||
<p>Stable</p>
|
||||
<code>sudo apt install mocktail</code>
|
||||
<p>Nightly</p>
|
||||
<code>sudo apt install mocktail-nightly</code>
|
||||
</div>
|
||||
<h2>Direct downloads</h2>
|
||||
<p>Prebuilt packages for Linux x86_64.</p>
|
||||
<p class="download-label">Stable</p>
|
||||
<p class="links">
|
||||
<a href="https://github.com/komaruworld/mocktail/releases/latest/download/Mocktail-x86_64.AppImage">AppImage</a>
|
||||
<a href="https://mocktail.bigrat.space/downloads/mocktail.deb">DEB</a>
|
||||
<a href="https://mocktail.bigrat.space/downloads/mocktail.rpm">RPM</a>
|
||||
</p>
|
||||
<p class="download-label">Nightly</p>
|
||||
<p class="links">
|
||||
<a href="https://github.com/komaruworld/mocktail/releases/download/continuous/mocktail-nightly.AppImage">AppImage</a>
|
||||
<a href="https://github.com/komaruworld/mocktail/releases/download/continuous/mocktail-nightly.deb">DEB</a>
|
||||
<a href="https://github.com/komaruworld/mocktail/releases/download/continuous/mocktail-nightly.rpm">RPM</a>
|
||||
</p>
|
||||
<p><a class="source" href="https://github.com/komaruworld/mocktail">Source code</a></p>
|
||||
<p>Mocktail is an independent community project and is not affiliated with Roblox Corporation.</p>
|
||||
</main></body>
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
:root {
|
||||
font: 17px/1.55 system-ui, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 680px;
|
||||
margin: 12vh auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: .2rem 0;
|
||||
font-size: clamp(2.5rem, 8vw, 4.5rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 32px 0 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #9da7b3;
|
||||
}
|
||||
|
||||
a.button {
|
||||
display: inline-block;
|
||||
margin: 16px 8px 16px 0;
|
||||
padding: 12px 18px;
|
||||
border-radius: 9px;
|
||||
background: #238636;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
background: #21262d;
|
||||
}
|
||||
|
||||
.commands {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.commands p,
|
||||
.download-label {
|
||||
margin: 14px 0 5px;
|
||||
color: #e6edf3;
|
||||
font-size: .82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
padding: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
background: #161b22;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin: 10px 0 14px;
|
||||
}
|
||||
|
||||
.links a {
|
||||
display: inline-block;
|
||||
margin: 0 14px 6px 0;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
a.source {
|
||||
color: #e6edf3;
|
||||
}
|
||||
@@ -18,8 +18,13 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <limits>
|
||||
#include <new>
|
||||
|
||||
#if defined(__GLIBC__)
|
||||
#include <dlfcn.h>
|
||||
#endif
|
||||
|
||||
namespace mocktail::compat {
|
||||
namespace {
|
||||
|
||||
@@ -31,6 +36,34 @@ struct ThreadStartContext {
|
||||
|
||||
std::atomic<NativeThreadInitializer> g_thread_initializer{nullptr};
|
||||
|
||||
size_t HostStackSizeForGuest(size_t guest_stack_size) noexcept {
|
||||
#if defined(__GLIBC__)
|
||||
using GetStaticTlsInfo = void (*)(size_t*, size_t*);
|
||||
static const auto get_static_tls_info =
|
||||
reinterpret_cast<GetStaticTlsInfo>(
|
||||
dlsym(RTLD_DEFAULT, "_dl_get_tls_static_info"));
|
||||
size_t tls_size = 0;
|
||||
size_t tls_alignment = 0;
|
||||
if (get_static_tls_info != nullptr) {
|
||||
get_static_tls_info(&tls_size, &tls_alignment);
|
||||
}
|
||||
|
||||
// glibc places static TLS inside the requested mapping. Compensate only
|
||||
// unusually large blocks; Mocktail's compatibility TLS is several MiB.
|
||||
constexpr size_t kLargeStaticTlsThreshold = 1U * 1024U * 1024U;
|
||||
if (tls_size > kLargeStaticTlsThreshold && tls_alignment != 0 &&
|
||||
(tls_alignment & (tls_alignment - 1)) == 0 &&
|
||||
tls_size <= std::numeric_limits<size_t>::max() - (tls_alignment - 1)) {
|
||||
const size_t reserve =
|
||||
(tls_size + tls_alignment - 1) & ~(tls_alignment - 1);
|
||||
if (guest_stack_size <= std::numeric_limits<size_t>::max() - reserve) {
|
||||
return guest_stack_size + reserve;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return guest_stack_size;
|
||||
}
|
||||
|
||||
int CopySupportedThreadAttributes(const pthread_attr_t& source,
|
||||
pthread_attr_t* destination) noexcept {
|
||||
int detach_state = PTHREAD_CREATE_JOINABLE;
|
||||
@@ -62,7 +95,8 @@ int CopySupportedThreadAttributes(const pthread_attr_t& source,
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
result = pthread_attr_setstacksize(destination, stack_size);
|
||||
result = pthread_attr_setstacksize(destination,
|
||||
HostStackSizeForGuest(stack_size));
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
@@ -112,7 +146,8 @@ int CopyRequiredThreadAttributes(const pthread_attr_t& source,
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
return pthread_attr_setstacksize(destination, stack_size);
|
||||
return pthread_attr_setstacksize(destination,
|
||||
HostStackSizeForGuest(stack_size));
|
||||
}
|
||||
|
||||
int ConfigureHostSafeStackFallback(const pthread_attr_t* source,
|
||||
@@ -139,9 +174,11 @@ int ConfigureHostSafeStackFallback(const pthread_attr_t* source,
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
const size_t guest_floor =
|
||||
std::max(guest_stack_size, kBionicLp64FallbackThreadStackSize);
|
||||
return pthread_attr_setstacksize(
|
||||
destination, std::max({guest_stack_size, host_default_stack_size,
|
||||
kBionicLp64FallbackThreadStackSize}));
|
||||
destination,
|
||||
std::max(host_default_stack_size, HostStackSizeForGuest(guest_floor)));
|
||||
}
|
||||
|
||||
void* RunGuestThread(void* raw_context) noexcept {
|
||||
@@ -183,8 +220,9 @@ int CreateBionicPthread(pthread_t* thread, const pthread_attr_t* attr,
|
||||
if (result == 0) {
|
||||
normalized_attr_initialized = true;
|
||||
if (attr == nullptr) {
|
||||
result = pthread_attr_setstacksize(&normalized_attr,
|
||||
kBionicLp64DefaultThreadStackSize);
|
||||
result = pthread_attr_setstacksize(
|
||||
&normalized_attr,
|
||||
HostStackSizeForGuest(kBionicLp64DefaultThreadStackSize));
|
||||
} else {
|
||||
result = CopySupportedThreadAttributes(*attr, &normalized_attr);
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ InstallHostAbiExperiment(uintptr_t library_base, const HostAbiProfile &profile,
|
||||
result.uses_native_mimalloc = !options.install_allocator_bridges;
|
||||
if (library_base == 0 || profile.bridge_entry_count == 0 ||
|
||||
profile.bridge_entry_count > profile.bridge_entries.size()) {
|
||||
result.error = "invalid host ABI experiment profile";
|
||||
result.error = "invalid host ABI profile";
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ InstallHostAbiExperiment(uintptr_t library_base, const HostAbiProfile &profile,
|
||||
std::cout << std::flush;
|
||||
result.installed = ok;
|
||||
if (!ok) {
|
||||
result.error = "one or more host ABI experiment actions failed";
|
||||
result.error = "one or more host ABI actions failed";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -953,21 +953,14 @@ bool ValidateReceipt(const std::string& receipt_path,
|
||||
return false;
|
||||
}
|
||||
|
||||
const BuildIdResult runtime_build_id = ReadElfBuildId("/proc/self/exe");
|
||||
const FileSha256Result runtime_hash = ComputeFileSha256("/proc/self/exe");
|
||||
const FileSha256Result manifest_hash =
|
||||
ComputeFileSha256(compatibility_manifest_path);
|
||||
if (!runtime_build_id || !runtime_hash || !manifest_hash) {
|
||||
*error =
|
||||
"cannot identify runtime or hash compatibility manifest for approval "
|
||||
"receipt";
|
||||
if (!manifest_hash) {
|
||||
*error = "cannot hash compatibility manifest for approval receipt";
|
||||
return false;
|
||||
}
|
||||
if (receipt_runtime_build_id != runtime_build_id.build_id ||
|
||||
receipt_runtime_sha256 != runtime_hash.sha256 ||
|
||||
receipt_manifest_sha256 != manifest_hash.sha256) {
|
||||
*error =
|
||||
"approval receipt belongs to different runtime or compatibility bytes";
|
||||
if (receipt_manifest_sha256 != manifest_hash.sha256) {
|
||||
*error = "approval receipt belongs to different compatibility bytes";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -990,7 +983,7 @@ bool ValidateReceipt(const std::string& receipt_path,
|
||||
if (!ValidateCanaryAttestation(
|
||||
*canonical_attestation, index + 1, payload_id, payload_fingerprint,
|
||||
profile_sha256, manifest_hash.sha256, canary_runtime_sha256,
|
||||
runtime_build_id.build_id, &canary_evidence[index], error)) {
|
||||
receipt_runtime_build_id, &canary_evidence[index], error)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1018,7 +1011,7 @@ bool ValidateReceipt(const std::string& receipt_path,
|
||||
|
||||
std::string generation_evidence;
|
||||
generation_evidence.append("runtime_build_id=")
|
||||
.append(runtime_build_id.build_id)
|
||||
.append(receipt_runtime_build_id)
|
||||
.append("\n");
|
||||
generation_evidence.append("payload=")
|
||||
.append(payload_fingerprint)
|
||||
|
||||
@@ -62,7 +62,7 @@ PayloadCompatibilityResult CheckPayloadCompatibility(
|
||||
result.error = "This Roblox build is not enabled for normal runs: " +
|
||||
result.profile.reason +
|
||||
"\n Use --allow-unverified-build only for an explicit "
|
||||
"compatibility experiment.";
|
||||
"compatibility run.";
|
||||
result.profile = {};
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ BackendSelection SelectGraphicsBackend(
|
||||
reason};
|
||||
}
|
||||
|
||||
// Fallback order remains Vulkan-first and deterministic.
|
||||
constexpr GraphicsBackendKind kFallbackOrder[] = {
|
||||
GraphicsBackendKind::kAngleVulkan,
|
||||
GraphicsBackendKind::kDirectVulkan,
|
||||
|
||||
+6
-24
@@ -12,8 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// src/jnivm/jnivm.cc — Implementation of the Pseudo Java Native Interface VM.
|
||||
|
||||
#include "jnivm/jnivm.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -35,8 +33,6 @@
|
||||
|
||||
namespace jnivm {
|
||||
|
||||
// Class implementation
|
||||
|
||||
void Class::RegisterMethod(const std::string& method_name,
|
||||
const std::string& signature,
|
||||
MethodCallback callback) {
|
||||
@@ -71,7 +67,6 @@ thread_local JNIEnv* g_thread_local_env = nullptr;
|
||||
thread_local JNIEnv g_thread_env_storage = {};
|
||||
thread_local VM* g_thread_vm_instance = nullptr;
|
||||
|
||||
// Thread-local pointer to the active VM instance for JNI callback resolution.
|
||||
VM* g_vm_instance = nullptr;
|
||||
std::recursive_mutex g_jni_state_mutex;
|
||||
|
||||
@@ -495,8 +490,7 @@ RobloxAuthIdentity ResearchRobloxIdentityFromEnvironment() {
|
||||
return identity;
|
||||
}
|
||||
|
||||
// Temporary research-only compatibility path. Production startup should
|
||||
// inject a resolved identity through VM::SetRobloxAuthIdentity instead.
|
||||
// Normal startup injects identity through VM::SetRobloxAuthIdentity.
|
||||
identity.user_id = static_cast<int64_t>(parsed);
|
||||
const char* username = std::getenv("MOCKTAIL_ROBLOX_USERNAME");
|
||||
if (username && username[0] != '\0') {
|
||||
@@ -557,8 +551,7 @@ std::shared_ptr<Class> ClassFromJClass(jclass clazz) {
|
||||
return FallbackClassForName(cls->GetName());
|
||||
}
|
||||
|
||||
// Stores a shared_ptr into my_segment, keeping it alive in g_segment_owners.
|
||||
// Returns the encoded segment-table handle cast to jclass.
|
||||
// g_segment_owners keeps the encoded class handle alive.
|
||||
static jclass StoreClass(std::shared_ptr<Class> cls) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
Class* raw_ptr = cls.get();
|
||||
@@ -593,9 +586,8 @@ PseudoJavaObject* PseudoObjectFromRef(jobject obj) {
|
||||
raw_ptr = reinterpret_cast<Object*>(obj);
|
||||
}
|
||||
if (!raw_ptr) return nullptr;
|
||||
// Perform a static cast instead of dynamic_cast to avoid calling the host
|
||||
// __dynamic_cast against potentially-Bionic RTTI structures. All objects
|
||||
// stored in our segment table are PseudoJavaObjects by construction.
|
||||
// Avoid host __dynamic_cast on potentially Bionic RTTI; segment entries are
|
||||
// PseudoJavaObjects by construction.
|
||||
return static_cast<PseudoJavaObject*>(raw_ptr);
|
||||
}
|
||||
|
||||
@@ -840,8 +832,7 @@ std::string JavaStringUtf8Bytes(const std::vector<jchar>& utf16) {
|
||||
code_point = 0x10000 + ((code_point - 0xd800) << 10) +
|
||||
(utf16[++index] - 0xdc00);
|
||||
} else if (code_point >= 0xd800 && code_point <= 0xdfff) {
|
||||
// java.nio.charset.CharsetEncoder replaces each malformed UTF-16 input
|
||||
// unit with its configured UTF-8 replacement, the single byte '?'.
|
||||
// CharsetEncoder replaces each malformed UTF-16 unit with '?'.
|
||||
output.push_back('?');
|
||||
continue;
|
||||
}
|
||||
@@ -5082,9 +5073,7 @@ JNIEnv* VM::GetJNIEnv() {
|
||||
}
|
||||
|
||||
void VM::RestoreFunctions() {
|
||||
// Force env->functions back to our pseudo-VM table on ALL known JNIEnv*.
|
||||
// libroblox.so replaces env->functions with a Bionic JNI table during
|
||||
// JNI_OnLoad; we need to restore it before Stage 6 calls.
|
||||
// JNI_OnLoad replaces env->functions; restore all known environments.
|
||||
java_vm_storage_.functions = &invoke_interface_;
|
||||
java_vm_ = &java_vm_storage_;
|
||||
if (jni_env_) {
|
||||
@@ -5121,10 +5110,6 @@ std::shared_ptr<Class> VM::FindClass(const std::string& class_name) const {
|
||||
}
|
||||
|
||||
void VM::InitJNIFunctionTables() {
|
||||
// JNIInvokeInterface_ — the JavaVM function table.
|
||||
// Most functions are no-ops in this pseudo-VM; only AttachCurrentThread
|
||||
// is meaningful for single-threaded prototype use.
|
||||
|
||||
invoke_interface_.AttachCurrentThread =
|
||||
[](JavaVM* vm, void** env, void* /*args*/) -> jint {
|
||||
if (JniVmTraceEnabled()) {
|
||||
@@ -5207,9 +5192,6 @@ void VM::InitJNIFunctionTables() {
|
||||
java_vm_storage_.functions = &invoke_interface_;
|
||||
java_vm_ = &java_vm_storage_;
|
||||
|
||||
// JNINativeInterface_ — the JNIEnv function table.
|
||||
// Only the minimal subset required by Roblox's JNI_OnLoad is stubbed here.
|
||||
|
||||
native_interface_.FindClass =
|
||||
[](JNIEnv* /*env*/, const char* name) -> jclass {
|
||||
if (JniVmTraceEnabled()) {
|
||||
|
||||
+60
-136
@@ -12,18 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Transitional Sober compatibility runtime. This file preserves the current
|
||||
// reverse-engineered startup path while upstream-backed modules replace
|
||||
// it one boundary at a time.
|
||||
//
|
||||
// Implements the preserved 5-stage initialisation pipeline:
|
||||
//
|
||||
// Stage 1: Initialise Pseudo-JVM (libjnivm).
|
||||
// Stage 2: Register mandatory Android SDK JNI classes.
|
||||
// Stage 3: Load host system libraries under Bionic alias names.
|
||||
// Stage 4: Map libroblox.so into process address space via the linker.
|
||||
// Stage 5: Invoke JNI_OnLoad and hand control to the game engine.
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
@@ -76,6 +64,7 @@
|
||||
#include "linker/linker.h"
|
||||
#include "mocktail/graphics/bionic_egl_bridge.h"
|
||||
#include "runtime/environment.h"
|
||||
#include "runtime/discord_rpc.h"
|
||||
#include "runtime/jnivm_platform_web_callbacks.h"
|
||||
#include "runtime/owned_pthread.h"
|
||||
#include "runtime/roblox_app_lifecycle.h"
|
||||
@@ -399,7 +388,6 @@ extern "C" int mocktail_recover_stack_chk_fail() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// JNI_OnLoad function pointer type as specified by the JNI standard.
|
||||
using JniOnLoadFn = jint (*)(JavaVM*, void*);
|
||||
using NativeGameGlobalInitFn = void (*)(JNIEnv*, jclass);
|
||||
using NativeInitClientSettingsFn = jint (*)(JNIEnv*, jclass, jstring, jstring,
|
||||
@@ -2518,10 +2506,7 @@ static void* GetThreadScratchBuffer(pid_t tid) {
|
||||
return g_thread_scratch_pool[0].buffer;
|
||||
}
|
||||
|
||||
// Fallback vtable used when a null/invalid object pointer is dereferenced
|
||||
// during a vtable dispatch in Stage 6. Each slot is a no-op stub that
|
||||
// returns 0 so that the caller can continue execution. The table covers
|
||||
// all vtable offsets seen in libroblox (currently up to +0xf8 = slot 31).
|
||||
// Covers every libroblox vtable offset observed through slot 31 (+0xf8).
|
||||
static uintptr_t NullVtableStub() { return 0; }
|
||||
static const uintptr_t kFallbackVtable[32] = {
|
||||
reinterpret_cast<uintptr_t>(&NullVtableStub),
|
||||
@@ -2557,8 +2542,7 @@ static const uintptr_t kFallbackVtable[32] = {
|
||||
reinterpret_cast<uintptr_t>(&NullVtableStub),
|
||||
reinterpret_cast<uintptr_t>(&NullVtableStub),
|
||||
};
|
||||
// Fake object whose first 8 bytes point to kFallbackVtable.
|
||||
// Used to satisfy `mov rax,[rsi]; call [rax+offset]` when RSI=null.
|
||||
// Lets a recovered `mov rax,[rsi]` continue through the fallback vtable.
|
||||
static const uintptr_t kFallbackObject[4] = {
|
||||
reinterpret_cast<uintptr_t>(kFallbackVtable), 0, 0, 0,
|
||||
};
|
||||
@@ -2809,12 +2793,10 @@ void ApplyRuntimeDefaults() {
|
||||
SetEnvDefault("MOCKTAIL_ENGINE_DETACH", "0");
|
||||
SetEnvDefault("MOCKTAIL_INIT_CLIENT_SETTINGS", "0");
|
||||
SetEnvDefault("MOCKTAIL_POST_CLIENT_SETTINGS", "0");
|
||||
// Sober-compatible fallback: allow unauthenticated startup (LuaApp path)
|
||||
// unless explicitly disabled.
|
||||
SetEnvDefault("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1");
|
||||
// Current Roblox Android builds drive app startup through NativeGLInterface's
|
||||
// V2 app bridge path. The legacy NativeAppBridgeInterface entry point stays
|
||||
// available for experiments, but running it in parallel can block V2 init.
|
||||
// available by opt-in, but running it in parallel can block V2 init.
|
||||
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START", "0");
|
||||
SetEnvDefault("MOCKTAIL_APP_BRIDGE_APP_START_THREAD", "0");
|
||||
SetEnvDefault("MOCKTAIL_START_LUA_APP_DM", "1");
|
||||
@@ -5823,8 +5805,8 @@ extern "C" void* mocktail_roblox_allocator_object_alloc_bridge(void*,
|
||||
return mocktail_roblox_aligned_alloc_bridge(size, align);
|
||||
}
|
||||
|
||||
// Retained only for the legacy fixed-offset allocator patch below. Current
|
||||
// Build-ID experiments own their seed storage in compat/host_abi_experiment.
|
||||
// Retained for the legacy fixed-offset allocator patch. Build-ID profiles keep
|
||||
// their seed storage in compat/host_abi_experiment.
|
||||
uintptr_t g_mocktail_roblox_allocator_object_vtable[4] = {};
|
||||
uintptr_t g_mocktail_roblox_allocator_object[1] = {
|
||||
reinterpret_cast<uintptr_t>(g_mocktail_roblox_allocator_object_vtable)};
|
||||
@@ -5901,7 +5883,7 @@ bool InstallActiveHostAbiExperiment(uintptr_t libroblox_base) {
|
||||
}
|
||||
g_host_abi_install_attempted = true;
|
||||
if (!HostAbiExperimentRequested()) {
|
||||
std::cout << " [compat] host ABI experiment disabled by policy\n"
|
||||
std::cout << " [compat] host ABI profile disabled by policy\n"
|
||||
<< std::flush;
|
||||
return false;
|
||||
}
|
||||
@@ -5909,7 +5891,7 @@ bool InstallActiveHostAbiExperiment(uintptr_t libroblox_base) {
|
||||
const mocktail::compat::HostAbiProfile* profile =
|
||||
g_active_host_abi_profile.load(std::memory_order_acquire);
|
||||
if (libroblox_base == 0 || profile == nullptr) {
|
||||
std::cerr << " [compat] host ABI experiment has no active profile/base\n"
|
||||
std::cerr << " [compat] host ABI install has no active profile/base\n"
|
||||
<< std::flush;
|
||||
return false;
|
||||
}
|
||||
@@ -6533,7 +6515,7 @@ extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
|
||||
|
||||
// Host alloc bridges must be in place before any constructor executes.
|
||||
if (HostAbiExperimentRequested() && !InstallActiveHostAbiExperiment(base)) {
|
||||
std::cerr << " [compat] host ABI experiment failed before constructors\n"
|
||||
std::cerr << " [compat] host ABI install failed before constructors\n"
|
||||
<< std::flush;
|
||||
return;
|
||||
}
|
||||
@@ -6633,7 +6615,6 @@ extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
|
||||
uintptr_t* init_array =
|
||||
reinterpret_cast<uintptr_t*>(base + init_array_offset);
|
||||
|
||||
// Make the page writable just in case
|
||||
long ctor_page_size = sysconf(_SC_PAGESIZE);
|
||||
if (ctor_page_size > 0) {
|
||||
uintptr_t addr = reinterpret_cast<uintptr_t>(init_array);
|
||||
@@ -6647,7 +6628,6 @@ extern "C" void mocktail_before_soinfo_constructors(const char* realpath,
|
||||
g_original_ctors.reserve(kCtorCount);
|
||||
for (size_t i = 0; i < kCtorCount; ++i) {
|
||||
g_original_ctors.push_back(init_array[i]);
|
||||
// Skip the first constructor if env flag is set, otherwise wrap it
|
||||
if (i == 0 && IsEnabled("MOCKTAIL_PATCH_FIRST_CONSTRUCTOR")) {
|
||||
static void (*noop_fn)() = []() {};
|
||||
g_original_ctors[i] = reinterpret_cast<uintptr_t>(noop_fn);
|
||||
@@ -26925,26 +26905,21 @@ void HeadlessSegvHandler(int signo, siginfo_t* info, void* context) {
|
||||
return;
|
||||
}
|
||||
|
||||
// libGLdispatch crash recovery (handles Bionic TLS incompatibility in Glibc threads).
|
||||
// Matches any SIGSEGV occurring inside libGLdispatch.so.0.
|
||||
// libGLdispatch may access Bionic TLS keys through glibc thread state.
|
||||
if (g_current_stage >= 6 && module_name && std::strstr(module_name, "libGLdispatch") != nullptr) {
|
||||
// If the instruction is a 64-bit store (mov [reg], reg) or load (mov reg, [reg]):
|
||||
if (instruction[0] == 0x48 && (instruction[1] == 0x89 || instruction[1] == 0x8b)) {
|
||||
int rm = instruction[2] & 7;
|
||||
int reg_map[] = { REG_RAX, REG_RCX, REG_RDX, REG_RBX, -1, -1, REG_RSI, REG_RDI };
|
||||
if (rm < 8 && reg_map[rm] != -1) {
|
||||
uintptr_t old_val = ucontext->uc_mcontext.gregs[reg_map[rm]];
|
||||
// Only redirect if the register is currently NULL, very low, or points to a read-only stub/vtable.
|
||||
if (old_val < kStage5LowAddressThreshold) {
|
||||
// Get or allocate a thread-specific writable buffer from our pool.
|
||||
// This avoids calling malloc (which corrupts the heap if interrupted)
|
||||
// and avoids sharing the same static buffer across multiple threads (which causes corruption).
|
||||
// Signal handlers cannot call malloc; each thread gets fixed scratch
|
||||
// storage to avoid sharing writes across recovered threads.
|
||||
pid_t tid = static_cast<pid_t>(syscall(SYS_gettid));
|
||||
void* thread_scratch = GetThreadScratchBuffer(tid);
|
||||
ucontext->uc_mcontext.gregs[reg_map[rm]] = reinterpret_cast<greg_t>(thread_scratch);
|
||||
} else if (old_val == reinterpret_cast<uintptr_t>(&NullVtableStub) ||
|
||||
old_val == reinterpret_cast<uintptr_t>(kFallbackVtable)) {
|
||||
// If it was redirected to a read-only stub/vtable, redirect it to a thread-specific writable buffer.
|
||||
pid_t tid = static_cast<pid_t>(syscall(SYS_gettid));
|
||||
void* thread_scratch = GetThreadScratchBuffer(tid);
|
||||
ucontext->uc_mcontext.gregs[reg_map[rm]] = reinterpret_cast<greg_t>(thread_scratch);
|
||||
@@ -27015,7 +26990,7 @@ void HeadlessSegvHandler(int signo, siginfo_t* info, void* context) {
|
||||
}
|
||||
skip_stage6_null_object_immediate_store:
|
||||
|
||||
// Broader: any `48 89 ??` store with low dest-addr (catches all register combos).
|
||||
// Catch the remaining `48 89 ??` stores to low addresses.
|
||||
if (g_current_stage >= 6 && info &&
|
||||
reinterpret_cast<uintptr_t>(info->si_addr) < kStage5LowAddressThreshold &&
|
||||
instruction[0] == 0x48 && instruction[1] == 0x89) {
|
||||
@@ -27025,10 +27000,9 @@ skip_stage6_null_object_immediate_store:
|
||||
write(2, msg, sizeof(msg) - 1);
|
||||
}
|
||||
++g_skipped_headless_null_writes;
|
||||
// Determine instruction length (modrm) — skip the store.
|
||||
// 48 89 /r — 3 or 4 bytes depending on displacement.
|
||||
uint8_t modrm = instruction[2];
|
||||
uint8_t mod = (modrm >> 6) & 0x3;
|
||||
// The ModR/M displacement determines how many instruction bytes to skip.
|
||||
int instr_len = (mod == 1) ? 4 : (mod == 2) ? 7 : 3;
|
||||
ucontext->uc_mcontext.gregs[REG_RIP] += instr_len;
|
||||
return;
|
||||
@@ -27053,9 +27027,7 @@ skip_stage6_null_object_immediate_store:
|
||||
reinterpret_cast<uintptr_t>(info->si_addr) == 0 &&
|
||||
instruction[0] == 0x48 && instruction[1] == 0x8b &&
|
||||
instruction[2] == 0x06) {
|
||||
// Pattern: mov rax,[rsi] with RSI=null, often followed by call [rax+N].
|
||||
// Point RSI at a fake object whose vtable is kFallbackVtable so that the
|
||||
// load succeeds and subsequent indirect calls land in NullVtableStub.
|
||||
// Redirect the null receiver so a following vtable call reaches the stub.
|
||||
if (g_skipped_headless_null_writes == 0) {
|
||||
const char msg[] =
|
||||
" [patch] redirected Stage6 mov rax,[rsi] (RSI=null) to fallback object\n";
|
||||
@@ -27064,13 +27036,11 @@ skip_stage6_null_object_immediate_store:
|
||||
++g_skipped_headless_null_writes;
|
||||
ucontext->uc_mcontext.gregs[REG_RSI] =
|
||||
reinterpret_cast<greg_t>(kFallbackObject);
|
||||
// Do NOT advance RIP — re-execute the mov against the now-valid RSI.
|
||||
// Re-run the load against the repaired receiver.
|
||||
return;
|
||||
}
|
||||
|
||||
// Broader fallback: any Stage6 null/low-address load through RSI into RAX
|
||||
// that is the exact 3-byte form `48 8b 06` — recover by pointing RSI at
|
||||
// fallback vtable object so the call chain can continue.
|
||||
// The same recovery applies to nonzero addresses below the valid threshold.
|
||||
if (g_current_stage >= 6 && info &&
|
||||
reinterpret_cast<uintptr_t>(info->si_addr) < kStage5LowAddressThreshold &&
|
||||
instruction[0] == 0x48 && instruction[1] == 0x8b &&
|
||||
@@ -27106,8 +27076,7 @@ skip_stage6_null_object_immediate_store:
|
||||
return;
|
||||
}
|
||||
|
||||
// Also handle `mov rax,[rax]` (48 8b 00) / `mov rax,[rax+N]` (48 8b 40 NN)
|
||||
// with low RAX — redirect RAX to kFallbackVtable.
|
||||
// Redirect low `mov rax,[rax+N]` bases to the fallback vtable.
|
||||
if (g_current_stage >= 6 && info &&
|
||||
reinterpret_cast<uintptr_t>(info->si_addr) < kStage5LowAddressThreshold &&
|
||||
instruction[0] == 0x48 && instruction[1] == 0x8b &&
|
||||
@@ -27279,12 +27248,8 @@ skip_stage6_null_object_immediate_store:
|
||||
return;
|
||||
}
|
||||
|
||||
// SI_KERNEL SIGTRAP (si_code=128): kernel-generated trap, commonly a
|
||||
// shadow-stack (CET/SHSTK) return-address mismatch on Roblox's internal
|
||||
// threads. The instruction at RIP is not an int3 — it's the instruction
|
||||
// that would have executed after the bad return. We cannot safely continue
|
||||
// from here; if a recovery jmp_buf is armed on this thread use it, otherwise
|
||||
// just exit the offending thread so the rest of the engine keeps running.
|
||||
// SI_KERNEL SIGTRAP commonly follows a CET shadow-stack return mismatch.
|
||||
// Use a thread-local recovery target when one is armed.
|
||||
if (signo == SIGTRAP && info && info->si_code == SI_KERNEL &&
|
||||
g_current_stage >= 6) {
|
||||
if (libroblox_base != 0 &&
|
||||
@@ -27323,17 +27288,11 @@ skip_stage6_null_object_immediate_store:
|
||||
write(2, msg, sizeof(msg) - 1);
|
||||
siglongjmp(g_start_app_with_params_jmp_buf, 1);
|
||||
}
|
||||
// No recovery target on this thread — skip the trap by advancing RIP past
|
||||
// the current instruction and returning. Killing the thread via
|
||||
// pthread_exit would hang the engine since Roblox waits on these threads.
|
||||
// SI_KERNEL SIGTRAP here is a shadow-stack (CET) mismatch on Roblox's
|
||||
// internal threads; skipping it lets execution continue from the instruction
|
||||
// after the bad return address.
|
||||
// Roblox may wait on this thread, so exiting it can hang the engine.
|
||||
const char msg[] =
|
||||
" [patch] SI_KERNEL SIGTRAP: no recovery, skipping instruction\n";
|
||||
write(2, msg, sizeof(msg) - 1);
|
||||
// Advance past the current instruction — for SI_KERNEL traps the faulting
|
||||
// instruction is still at RIP (not executed yet), so +1 moves past it.
|
||||
// SI_KERNEL leaves RIP on the instruction that has not run yet.
|
||||
ucontext->uc_mcontext.gregs[REG_RIP] += 1;
|
||||
return;
|
||||
}
|
||||
@@ -28487,9 +28446,8 @@ bool PatchStartAppDebugTrap(void* native_start_app_with_params) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// NOTE: We intentionally do NOT patch the function entry point with `ret`.
|
||||
// Doing so would silently kill the real startup path. Only the known-bad
|
||||
// diagnostic tail block inside the function is bypassed here.
|
||||
// Patch only the diagnostic tail; replacing the entry point would disable
|
||||
// real startup.
|
||||
constexpr uintptr_t kNativeStartAppOffset = 0x250436d;
|
||||
constexpr uintptr_t kTailPatchOffset = 0x2504567;
|
||||
constexpr unsigned char kTailJumpPatch[] = {
|
||||
@@ -30532,9 +30490,7 @@ void* EngineStartupThread(void* arg) {
|
||||
<< std::flush;
|
||||
mocktail::window::MakeCurrentOnThread();
|
||||
}
|
||||
// Force our pseudo-VM function table on the env that EngineStartupThread will use.
|
||||
// libroblox.so replaces env->functions during JNI_OnLoad; we restore it here so
|
||||
// that our NewStringUTF / GetStringUTFChars helpers work correctly in Stage 6.
|
||||
// JNI_OnLoad replaces env->functions; Stage 6 needs the pseudo-VM table.
|
||||
if (env && context->vm) {
|
||||
context->vm->RestoreFunctions();
|
||||
env = context->vm->GetJNIEnv();
|
||||
@@ -31922,6 +31878,17 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
const mocktail::runtime::ProcessEnvironment process_environment;
|
||||
const mocktail::runtime::RuntimeConfig runtime_config =
|
||||
mocktail::runtime::RuntimeConfig::FromEnvironment(process_environment);
|
||||
mocktail::runtime::DiscordRpcSession discord_rpc(
|
||||
runtime_config.discord_rpc());
|
||||
if (runtime_config.discord_rpc().enabled) {
|
||||
std::string discord_rpc_detail;
|
||||
if (discord_rpc.Start(&discord_rpc_detail)) {
|
||||
std::cout << " [discord-rpc] enabled; browsing activity queued\n";
|
||||
} else {
|
||||
std::cerr << " [discord-rpc] unavailable: " << discord_rpc_detail
|
||||
<< "; continuing without Rich Presence\n";
|
||||
}
|
||||
}
|
||||
const mocktail::runtime::InputCapabilityConfig& input_capabilities =
|
||||
runtime_config.input_capabilities();
|
||||
if (!runtime_config.frame_rate().valid()) {
|
||||
@@ -32038,7 +32005,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
std::cerr << "[FATAL] This Roblox build is not enabled for normal runs: "
|
||||
<< build_profile.reason << '\n'
|
||||
<< " Use --allow-unverified-build only for an explicit "
|
||||
"compatibility experiment.\n";
|
||||
"compatibility check.\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
@@ -32052,10 +32019,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
std::cout << " [trace] full tracing enabled\n" << std::flush;
|
||||
}
|
||||
|
||||
// Disable shadow-stack (CET/SHSTK) for this process before loading
|
||||
// libroblox. Roblox's internal threads trigger SIGTRAP SI_KERNEL from
|
||||
// return-address mismatches on the shadow stack; disabling it here prevents
|
||||
// those spurious traps. This is a no-op on kernels that don't support CET.
|
||||
// Roblox internal threads trigger SI_KERNEL traps from CET shadow-stack
|
||||
// return mismatches. Unsupported kernels ignore this request.
|
||||
{
|
||||
long r = syscall(SYS_arch_prctl, ARCH_SHSTK_DISABLE, ARCH_SHSTK_SHSTK);
|
||||
if (r == 0) {
|
||||
@@ -32074,7 +32039,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
setenv("MOCKTAIL_APP_BRIDGE_HEADLESS_INIT_PARAMS", is_headless ? "1" : "0",
|
||||
1);
|
||||
}
|
||||
// Stage 1: Initialise the Bionic-compatible ELF linker + Pseudo-JVM.
|
||||
PrintStage(1, "Initialising Bionic linker + Pseudo-JVM");
|
||||
|
||||
#ifdef MOCKTAIL_USE_BIONIC_LINKER
|
||||
@@ -32089,8 +32053,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
}
|
||||
std::cout << " Pseudo-JVM instance created successfully.\n";
|
||||
|
||||
// Stage 2: Register mandatory Android SDK JNI classes.
|
||||
// Roblox's JNI_OnLoad will call FindClass for these at startup.
|
||||
// FindClass needs these descriptors before JNI_OnLoad.
|
||||
PrintStage(2, "Registering Android SDK JNI classes");
|
||||
|
||||
auto context_class = jni_vm->RegisterClass("android/content/Context");
|
||||
@@ -32104,7 +32067,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
std::cout << " [JNI callback] nativeInitClientSettings invoked\n";
|
||||
});
|
||||
|
||||
// Classes required by Stage 6 startup entry points.
|
||||
auto native_gl_interface_class =
|
||||
jni_vm->RegisterClass("com/roblox/engine/jni/NativeGLInterface");
|
||||
g_native_gl_class_for_main_thread =
|
||||
@@ -32178,10 +32140,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
std::cout << " Registered " << jni_vm->GetClassCount()
|
||||
<< " JNI class(es)\n";
|
||||
|
||||
// Window init (non-headless mode): create the SDL3 + EGL window before
|
||||
// Android/EGL stubs are preloaded, so SDL resolves the real host graphics
|
||||
// backend. By default the window stays hidden until Roblox presents a real
|
||||
// frame.
|
||||
// Create SDL/EGL before loading Android stubs so SDL binds the host graphics
|
||||
// backend. The window stays hidden until the first real frame.
|
||||
bool window_initialised = false;
|
||||
if (!is_headless) {
|
||||
const int win_w = runtime_config.window().width;
|
||||
@@ -32238,15 +32198,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: Populate the Bionic symbol table with all stub + system
|
||||
// symbols so that mcpelauncher-linker can resolve libroblox.so's
|
||||
// DT_NEEDED entries without calling the host Glibc dynamic linker.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. dlopen each stub .so with RTLD_GLOBAL (host linker finds them
|
||||
// via RPATH=$ORIGIN).
|
||||
// 2. Iterate over known stub symbols and register them into the
|
||||
// Bionic symbol table via linker::RegisterSymbol().
|
||||
PrintStage(3, "Building Bionic symbol table from stubs");
|
||||
|
||||
libc_shim::Install();
|
||||
@@ -32287,7 +32238,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
}
|
||||
}
|
||||
|
||||
// Stub libraries to preload. All located via RPATH=$ORIGIN.
|
||||
std::vector<const char*> stub_names = {
|
||||
"libc.so", "libdl.so", "libm.so", "libz.so",
|
||||
"libandroid.so", "liblog.so", "libmediandk.so",
|
||||
@@ -32331,15 +32281,9 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
real_gles_handle = OpenRealGlesLibrary();
|
||||
}
|
||||
|
||||
// Register every known Android NDK / system symbol into the Bionic
|
||||
// symbol table so mcpelauncher-linker resolves them correctly.
|
||||
//
|
||||
// We prioritize resolving from our loaded stub libraries first.
|
||||
// This ensures Bionic shims (like custom pthread structure handlers
|
||||
// or __sF stdio compatibility) are preferred over host glibc ones,
|
||||
// preventing layout incompatibility and crashes.
|
||||
// Prefer Bionic shims over glibc for guest layouts such as pthread and FILE.
|
||||
static const char* kSymbolsToRegister[] = {
|
||||
// Exported stub functions and structures
|
||||
// Symbols exported by the Android compatibility stubs.
|
||||
"AAsset_close",
|
||||
"AAsset_getBuffer",
|
||||
"AAsset_getLength",
|
||||
@@ -32597,7 +32541,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
"__system_property_get",
|
||||
"__write_chk",
|
||||
|
||||
// Binary-compatible standard pthread symbols not explicitly shimmed
|
||||
// pthread functions whose host ABI is already compatible.
|
||||
"pthread_once",
|
||||
"pthread_self",
|
||||
"pthread_equal",
|
||||
@@ -32614,7 +32558,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
"pthread_sigmask",
|
||||
"pthread_setname_np",
|
||||
|
||||
// Standard C library functions needed by libroblox.so
|
||||
// Standard C library functions resolved from host libc.
|
||||
"strcmp",
|
||||
"strncmp",
|
||||
"strcpy",
|
||||
@@ -32926,13 +32870,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 4: Load libroblox.so via the Bionic-compatible linker.
|
||||
|
||||
// Auto-register ALL undefined symbols from the target library BEFORE
|
||||
// loading stub libraries into the Bionic linker. This ensures the stubs'
|
||||
// injected symbol maps include every symbol libroblox.so will need at
|
||||
// relocation time. Auto-registration must run first because load_library
|
||||
// takes a snapshot of g_bionic_symbols at call time.
|
||||
// Register imports before loading synthetic SONAMEs; load_library snapshots
|
||||
// g_bionic_symbols.
|
||||
{
|
||||
int fd = ::open(library_path.c_str(), O_RDONLY);
|
||||
if (fd >= 0) {
|
||||
@@ -32942,7 +32881,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
PROT_READ, MAP_PRIVATE, fd, 0);
|
||||
if (map != MAP_FAILED) {
|
||||
auto* ehdr = reinterpret_cast<Elf64_Ehdr*>(map);
|
||||
// Walk section headers to find .dynsym and .dynstr
|
||||
auto* shdr = reinterpret_cast<Elf64_Shdr*>(
|
||||
reinterpret_cast<char*>(map) + ehdr->e_shoff);
|
||||
Elf64_Shdr* dynsym_hdr = nullptr;
|
||||
@@ -32963,7 +32901,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
if (syms[si].st_shndx != SHN_UNDEF) continue;
|
||||
const char* name = strtab + syms[si].st_name;
|
||||
if (!name || name[0] == '\0') continue;
|
||||
// Skip if already registered
|
||||
if (linker::GetBionicSymbols().count(name)) continue;
|
||||
auto result = ResolveSymbolForBionic(name, has_window,
|
||||
real_gles_handle,
|
||||
@@ -32984,9 +32921,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
}
|
||||
}
|
||||
|
||||
// Register stub libraries inside Bionic linker. This must happen AFTER
|
||||
// auto-registration so that every symbol libroblox needs is already in
|
||||
// g_bionic_symbols when load_library snapshots the map.
|
||||
// Load synthetic libraries only after the import map is complete.
|
||||
linker::RegisterBionicPthreadKeyRuntimeForLibc();
|
||||
linker::RegisterBionicSysconfRuntimeForLibc();
|
||||
linker::RegisterBionicSignalRuntimeForLibc();
|
||||
@@ -33026,12 +32961,11 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
std::cout << " [linker] Bionic unwind metadata validated for libroblox\n"
|
||||
<< std::flush;
|
||||
|
||||
// The pre-constructor linker hook is the only installation phase. A second
|
||||
// arena initialization here would not be idempotent; post-load only verifies
|
||||
// that the experiment completed.
|
||||
// The pre-constructor hook owns installation; repeating arena setup here is
|
||||
// not idempotent.
|
||||
if (HostAbiExperimentRequested() &&
|
||||
(!g_host_abi_install_attempted || !g_host_abi_install_result)) {
|
||||
std::cerr << "\n[FATAL] Host ABI experiment did not install before "
|
||||
std::cerr << "\n[FATAL] Host ABI profile did not install before "
|
||||
"libroblox constructors: "
|
||||
<< (g_host_abi_install_result.error != nullptr
|
||||
? g_host_abi_install_result.error
|
||||
@@ -33040,7 +32974,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
// Stage 5: Invoke JNI_OnLoad — hand control to the game engine.
|
||||
PrintStage(5, "Invoking JNI_OnLoad in libroblox.so");
|
||||
|
||||
auto* jni_onload =
|
||||
@@ -33218,9 +33151,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
}
|
||||
}
|
||||
|
||||
// libroblox.so may have replaced env->functions during JNI_OnLoad with a
|
||||
// Bionic JNI table. Restore our pseudo-VM function table so that our Stage 6
|
||||
// helpers (NewStringUTF, GetStringUTFChars, etc.) work correctly.
|
||||
// Restore the pseudo-JNI table if JNI_OnLoad replaced it.
|
||||
{
|
||||
JNIEnv* main_env = jni_vm->GetJNIEnv();
|
||||
if (main_env) {
|
||||
@@ -33843,13 +33774,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
std::cout << " [compat] skipped fixed-offset Stage 6 patch set\n"
|
||||
<< std::flush;
|
||||
}
|
||||
// By default, all startup steps are enabled when MOCKTAIL_START_ENGINE=1.
|
||||
// Real (non-mock) calls to nativeSetAssetPath,
|
||||
// nativeAppBridgeV2InitWithParams and nativeAppBridgeV2StartAppWithParams
|
||||
// are now the default path — the mock/bypass path only activates when the
|
||||
// real call crashes and recovery fires via siglongjmp. The old
|
||||
// MOCKTAIL_UNSAFE_NATIVE_STARTUP guard is removed so users do not need to
|
||||
// set it explicitly.
|
||||
// Real startup calls run by default; signal recovery falls back to the
|
||||
// Mocktail implementations.
|
||||
const bool requested_set_asset_path =
|
||||
ShouldRunStartupStep("MOCKTAIL_STEP_SET_ASSET_PATH", true);
|
||||
const bool requested_init_with_params =
|
||||
@@ -33857,8 +33783,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
const bool requested_start_app_with_params =
|
||||
ShouldRunStartupStep("MOCKTAIL_STEP_START_APP_WITH_PARAMS", true);
|
||||
const bool run_set_asset_path = requested_set_asset_path;
|
||||
// call_real_set_asset_path: enabled by default unless explicitly disabled.
|
||||
// The signal handler will catch any crash and fall back to MocktailSetAssetPath.
|
||||
const bool call_real_set_asset_path =
|
||||
run_set_asset_path &&
|
||||
!IsDisabled("MOCKTAIL_CALL_REAL_NATIVE_SET_ASSET_PATH");
|
||||
@@ -33876,7 +33800,6 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
IsEnabled("MOCKTAIL_APP_BRIDGE_APP_START")) &&
|
||||
native_app_bridge_app_start != nullptr;
|
||||
const bool run_init_with_params = requested_init_with_params;
|
||||
// call_real_init_with_params: real call is the default; mock only on crash.
|
||||
const bool call_real_init_with_params =
|
||||
run_init_with_params &&
|
||||
!IsDisabled("MOCKTAIL_CALL_REAL_APP_BRIDGE_INIT");
|
||||
@@ -33922,8 +33845,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
(native_pass_current_display_refresh_rate != nullptr ||
|
||||
native_pass_supported_refresh_rates != nullptr);
|
||||
const bool run_start_app_with_params = requested_start_app_with_params;
|
||||
// call_real_start_app_with_params: opt-in because the real call can block
|
||||
// startup before any rendering path becomes visible.
|
||||
// Keep the real call opt-in because it can block before rendering starts.
|
||||
const bool call_real_start_app_with_params =
|
||||
run_start_app_with_params &&
|
||||
IsEnabled("MOCKTAIL_CALL_REAL_APP_BRIDGE_START");
|
||||
@@ -34052,7 +33974,9 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
environment, message_bus_symbols, platform_web_symbols.web_view,
|
||||
platform_web_symbols.browser_service, *experience_game_symbols,
|
||||
jni_factory, present_boundary, std::move(surface_config),
|
||||
&dependencies.roblox_credential());
|
||||
&dependencies.roblox_credential(),
|
||||
mocktail::runtime::RobloxExperienceSurfaceProvider{},
|
||||
discord_rpc.observer());
|
||||
const mocktail::Status platform_protocol_status =
|
||||
experience_composition->InitializePlatformProtocols();
|
||||
if (!platform_protocol_status.ok()) {
|
||||
@@ -34533,7 +34457,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
|
||||
if (!build_profile.default_allowed) {
|
||||
std::cerr
|
||||
<< "[FATAL] Compatibility experiment returned, but Build ID "
|
||||
<< "[FATAL] Compatibility run returned, but Build ID "
|
||||
<< build_profile.elf_build_id
|
||||
<< " has not passed the readiness gates; refusing a success exit.\n";
|
||||
return EXIT_FAILURE;
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// src/libc_shim/libc_shim.cc — Bionic libc → Glibc shim implementation.
|
||||
|
||||
#include "libc_shim/libc_shim.h"
|
||||
|
||||
#include <array>
|
||||
@@ -33,7 +31,6 @@ namespace libc_shim {
|
||||
|
||||
namespace {
|
||||
|
||||
// Registered prefix mappings: android_prefix → host_prefix.
|
||||
std::unordered_map<std::string, std::string> g_path_mappings;
|
||||
|
||||
bool g_installed = false;
|
||||
@@ -504,10 +501,8 @@ extern "C" ssize_t mocktail_readlink(const char* path, char* buf,
|
||||
|
||||
extern "C" ssize_t mocktail___readlink_chk(const char* path, char* buf,
|
||||
size_t len, size_t buf_len) {
|
||||
// Match Bionic's fortify.cpp contract: both an impossible ssize_t result
|
||||
// and a compiler-proven destination overrun are fatal programming errors,
|
||||
// not recoverable readlink failures. Returning EINVAL here used to let the
|
||||
// guest continue after a detected buffer overflow.
|
||||
// Bionic treats impossible ssize_t results and proven overruns as fatal;
|
||||
// returning EINVAL would continue after a detected buffer overflow.
|
||||
if (len > static_cast<size_t>(SSIZE_MAX)) {
|
||||
std::fprintf(stderr,
|
||||
"FORTIFY: readlink: size %zu > SSIZE_MAX\n", len);
|
||||
|
||||
+9
-25
@@ -6,11 +6,7 @@
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// src/linker/linker.cc — ELF loader implementation.
|
||||
//
|
||||
// Stage A (no mcpelauncher-linker): delegates to host dlopen/dlsym.
|
||||
// Stage B (MOCKTAIL_USE_BIONIC_LINKER=1): delegates to mcpelauncher-linker
|
||||
// which loads Bionic-ABI ELF files without Glibc version conflicts.
|
||||
// Without the Bionic linker, loading falls back to host dlopen/dlsym.
|
||||
|
||||
#include "linker/linker.h"
|
||||
|
||||
@@ -50,16 +46,13 @@ namespace linker {
|
||||
|
||||
namespace {
|
||||
|
||||
// Global alias → handle map maintained across all LoadLibrary calls.
|
||||
std::unordered_map<std::string, LibraryHandle> g_symbol_table;
|
||||
|
||||
// New registrations retain symbol ownership per Android SONAME. The legacy
|
||||
// global map remains isolated below until src/main.cc is migrated.
|
||||
// Synthetic exports retain ownership per Android SONAME.
|
||||
std::unordered_map<std::string, SymbolMap> g_synthetic_symbols;
|
||||
std::unordered_map<std::string, LibraryHandle> g_synthetic_libraries;
|
||||
std::unordered_set<LibraryHandle> g_synthetic_handles;
|
||||
|
||||
// Compatibility symbol map used only by RegisterSymbol/LoadLibrary.
|
||||
SymbolMap g_legacy_bionic_symbols;
|
||||
|
||||
// Keep string memory alive for Bionic linker sonames to avoid dangling pointers.
|
||||
@@ -121,11 +114,8 @@ int BionicDlcloseBoundary(void* handle) {
|
||||
char* BionicDlerrorBoundary() { return ::linker::dlerror(); }
|
||||
#endif
|
||||
|
||||
// The legacy startup path still discovers most imports in one untyped map.
|
||||
// Bionic libc APIs with host-incompatible signatures or integer values must
|
||||
// have exactly one owner, synthetic libc.so. Re-exporting those host functions
|
||||
// from an earlier DT_NEEDED SONAME lets the guest bind to glibc before it
|
||||
// reaches libc.so.
|
||||
// Host-incompatible Bionic APIs belong only to synthetic libc.so. Exporting
|
||||
// them from an earlier dependency could bind the guest to glibc instead.
|
||||
bool IsBionicLibcOnlySymbol(std::string_view symbol) {
|
||||
static constexpr std::string_view kStdioSymbols[] = {
|
||||
"__fread_chk", "__fwrite_chk", "__sF", "clearerr",
|
||||
@@ -189,8 +179,6 @@ void RemoveHandleFromRegistry(LibraryHandle handle) {
|
||||
|
||||
} // namespace
|
||||
|
||||
// Public API
|
||||
|
||||
void RegisterSymbol(const std::string& name, void* addr) {
|
||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||
g_legacy_bionic_symbols[name] = addr;
|
||||
@@ -351,9 +339,8 @@ void RegisterBionicHostLibcRuntimeForLibc() {
|
||||
RegisterSyntheticSymbol(
|
||||
"libc.so", "gethostbyname",
|
||||
reinterpret_cast<void*>(mocktail_bionic_gethostbyname));
|
||||
// The preserved startup path publishes older DNS wrappers through its
|
||||
// legacy global map. Replace those addresses before libc.so snapshots that
|
||||
// map so every supported payload crosses the upload deny boundary.
|
||||
// Replace legacy DNS wrappers before libc.so snapshots the global map, so
|
||||
// every payload crosses the upload deny boundary.
|
||||
RegisterSymbol("getaddrinfo",
|
||||
reinterpret_cast<void*>(mocktail_bionic_getaddrinfo));
|
||||
RegisterSymbol("freeaddrinfo",
|
||||
@@ -375,9 +362,8 @@ void RegisterBionicHostLibcRuntimeForLibc() {
|
||||
reinterpret_cast<void*>(mocktail_bionic_mallinfo));
|
||||
RegisterSyntheticSymbol("libc.so", "strerror_r",
|
||||
reinterpret_cast<void*>(mocktail_bionic_strerror_r));
|
||||
// The preserved startup path scans libroblox imports through the legacy
|
||||
// global map before relocation. Publish the same address there so it
|
||||
// cannot auto-register glibc's GNU char*-returning strerror_r instead.
|
||||
// Publish this before relocation to prevent binding glibc's incompatible
|
||||
// GNU char*-returning strerror_r.
|
||||
RegisterSymbol("strerror_r",
|
||||
reinterpret_cast<void*>(mocktail_bionic_strerror_r));
|
||||
RegisterSyntheticSymbol("libc.so", "strtoll_l",
|
||||
@@ -643,9 +629,7 @@ LibraryHandle LoadLibrary(const std::string& real_path,
|
||||
return OpenAndroidLibrary(real_path, host_alias);
|
||||
}
|
||||
|
||||
// The legacy composition already reaches this per-SONAME boundary for
|
||||
// every Android dependency. Register host adapters here so libc.so owns the
|
||||
// guest ABI without adding calls to the preserved legacy startup TU.
|
||||
// Register adapters here so libc.so owns the guest ABI per SONAME.
|
||||
if (real_path == "libc.so") {
|
||||
RegisterBionicAtForkRuntimeForLibc();
|
||||
RegisterBionicHostLibcRuntimeForLibc();
|
||||
|
||||
+68
-59
@@ -6,11 +6,8 @@
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
// Mocktail's composition root stays intentionally small. During the
|
||||
// upstream-first migration it delegates to the isolated legacy runtime; new
|
||||
// platform, JNI, loader, and service modules are linked as explicit targets.
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
@@ -48,6 +45,7 @@
|
||||
#include "runtime/runtime_config_bootstrap.h"
|
||||
#include "runtime/runtime_config_file.h"
|
||||
#include "runtime/runtime_paths.h"
|
||||
#include "runtime/session_log.h"
|
||||
#include "runtime/single_instance_lock.h"
|
||||
#include "runtime/support_bundle.h"
|
||||
#include "runtime/supported_launch_policy.h"
|
||||
@@ -113,6 +111,7 @@ mocktail::Status ShutdownPlatformBridges(jnivm::VM* vm) {
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
const auto process_started_at = std::chrono::system_clock::now();
|
||||
mocktail::runtime::CommandLineParseResult command_line =
|
||||
mocktail::runtime::ParseCommandLine(argc, argv);
|
||||
if (!command_line) {
|
||||
@@ -168,6 +167,7 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
mocktail::runtime::ScrubCommandLineLaunchArguments(&command_line.options,
|
||||
argc, argv);
|
||||
mocktail::runtime::SessionLog session_log;
|
||||
mocktail::runtime::FailureSupportBundleGuard support_bundle_guard(
|
||||
environment, paths,
|
||||
command_line.options.mode == mocktail::runtime::CommandMode::kRun);
|
||||
@@ -225,10 +225,6 @@ int main(int argc, char* argv[]) {
|
||||
}
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (config_bootstrap.created()) {
|
||||
std::cout << " [runtime] created first-run configuration: "
|
||||
<< paths.config_file() << '\n';
|
||||
}
|
||||
mocktail::runtime::RuntimeConfigLoadResult runtime_config =
|
||||
mocktail::runtime::LoadRuntimeConfig(environment, paths.config_file());
|
||||
if (!runtime_config) {
|
||||
@@ -270,17 +266,33 @@ int main(int argc, char* argv[]) {
|
||||
use_memory_limit
|
||||
? runtime_config.config.performance().memory_limit_bytes()
|
||||
: 0;
|
||||
mocktail::runtime::CgroupMemoryLimitResult cgroup_limit;
|
||||
if (use_memory_limit) {
|
||||
cgroup_limit = mocktail::runtime::MaybeReexecWithCgroupMemoryLimit(
|
||||
argc, argv, memory_limit_bytes, &cgroup_reexec_arguments);
|
||||
}
|
||||
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
|
||||
session_log = mocktail::runtime::SessionLog::Start(
|
||||
environment, paths, process_started_at);
|
||||
if (session_log) {
|
||||
std::cout << session_log.Header(
|
||||
environment, paths,
|
||||
runtime_config.config.graphics_backend_name())
|
||||
<< std::flush;
|
||||
} else if (session_log.attempted()) {
|
||||
std::cerr << " [session] automatic logging unavailable: "
|
||||
<< session_log.error() << '\n';
|
||||
}
|
||||
}
|
||||
if (config_bootstrap.created()) {
|
||||
std::cout << " [runtime] created first-run configuration: "
|
||||
<< paths.config_file() << '\n';
|
||||
}
|
||||
if (use_memory_limit) {
|
||||
const mocktail::runtime::CgroupMemoryLimitResult cgroup_limit =
|
||||
mocktail::runtime::MaybeReexecWithCgroupMemoryLimit(
|
||||
argc, argv, memory_limit_bytes, &cgroup_reexec_arguments);
|
||||
if (cgroup_limit.active()) {
|
||||
std::cout << " [memory] hard process-tree limit active: "
|
||||
<< runtime_config.config.performance().memory_limit_mb
|
||||
<< " MiB RAM, swap disabled\n";
|
||||
} else {
|
||||
std::cerr << " [memory] cgroup hard limit unavailable: "
|
||||
<< cgroup_limit.detail << "; using the RSS+swap watchdog\n";
|
||||
}
|
||||
}
|
||||
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
|
||||
@@ -292,6 +304,42 @@ int main(int argc, char* argv[]) {
|
||||
: "")
|
||||
<< '\n';
|
||||
}
|
||||
|
||||
// Register before starting helper processes or loading the Android payload.
|
||||
// libgamemode caches its shared
|
||||
// D-Bus connection, so the first use must happen in the final game process.
|
||||
const mocktail::runtime::GameModePolicy game_mode_policy =
|
||||
runtime_config.config.performance().game_mode;
|
||||
mocktail::runtime::GameModeSession game_mode_session =
|
||||
command_line.options.mode == mocktail::runtime::CommandMode::kRun
|
||||
? mocktail::runtime::GameModeSession::Start(game_mode_policy)
|
||||
: mocktail::runtime::GameModeSession();
|
||||
switch (game_mode_session.state()) {
|
||||
case mocktail::runtime::GameModeSessionState::kActive:
|
||||
std::cout << " [gamemode] performance request active\n";
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kAlreadyActive:
|
||||
std::cout << " [gamemode] already active for this process\n";
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kUnavailable:
|
||||
case mocktail::runtime::GameModeSessionState::kRequestFailed:
|
||||
if (game_mode_policy == mocktail::runtime::GameModePolicy::kOn) {
|
||||
std::cerr << " [gamemode] requested but unavailable: "
|
||||
<< game_mode_session.detail()
|
||||
<< "; continuing without GameMode\n";
|
||||
} else {
|
||||
std::cout << " [gamemode] unavailable; continuing normally\n";
|
||||
}
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kDisabled:
|
||||
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
|
||||
std::cout << " [gamemode] disabled by runtime policy\n";
|
||||
}
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kStopped:
|
||||
case mocktail::runtime::GameModeSessionState::kStopFailed:
|
||||
break;
|
||||
}
|
||||
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
|
||||
failure_dialog = mocktail::runtime::FailureDialogMonitor::Start(
|
||||
environment,
|
||||
@@ -418,10 +466,8 @@ int main(int argc, char* argv[]) {
|
||||
<< active_build_id.error << '\n';
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
// An old local probation receipt can outlive the point where the
|
||||
// exact Build ID becomes shipped support. Built-in profiles are
|
||||
// immutable and must then use the repository manifest instead of
|
||||
// attempting an external override.
|
||||
// Built-in profiles supersede stale probation receipts and never
|
||||
// use external overrides.
|
||||
use_external_profile = mocktail::compat::FindHostAbiProfile(
|
||||
active_build_id.build_id) == nullptr;
|
||||
}
|
||||
@@ -721,45 +767,10 @@ int main(int argc, char* argv[]) {
|
||||
dependencies = mocktail::legacy::RuntimeDependencies(
|
||||
std::move(composition), &ShutdownPlatformBridges);
|
||||
}
|
||||
const mocktail::runtime::GameModePolicy game_mode_policy =
|
||||
runtime_config.config.performance().game_mode;
|
||||
mocktail::runtime::GameModeSession game_mode_session =
|
||||
command_line.options.mode == mocktail::runtime::CommandMode::kRun
|
||||
? mocktail::runtime::GameModeSession::Start(game_mode_policy)
|
||||
: mocktail::runtime::GameModeSession();
|
||||
switch (game_mode_session.state()) {
|
||||
case mocktail::runtime::GameModeSessionState::kActive:
|
||||
std::cout << " [gamemode] performance request active\n";
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kAlreadyActive:
|
||||
std::cout << " [gamemode] already active for this process\n";
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kUnavailable:
|
||||
case mocktail::runtime::GameModeSessionState::kRequestFailed:
|
||||
if (game_mode_policy == mocktail::runtime::GameModePolicy::kOn) {
|
||||
std::cerr << " [gamemode] requested but unavailable: "
|
||||
<< game_mode_session.detail()
|
||||
<< "; continuing without GameMode\n";
|
||||
} else {
|
||||
std::cout << " [gamemode] unavailable; continuing normally\n";
|
||||
}
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kDisabled:
|
||||
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
|
||||
std::cout << " [gamemode] disabled by runtime policy\n";
|
||||
}
|
||||
break;
|
||||
case mocktail::runtime::GameModeSessionState::kStopped:
|
||||
case mocktail::runtime::GameModeSessionState::kStopFailed:
|
||||
break;
|
||||
}
|
||||
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun &&
|
||||
!isolated_canary) {
|
||||
// Start accepting browser requests only after cgroup re-exec, payload
|
||||
// preflight and host bridge setup have succeeded. An ACK therefore cannot
|
||||
// be invalidated by a known host-side
|
||||
// startup or exec boundary. Isolated canaries cannot receive browser
|
||||
// launches and own no public per-user endpoint.
|
||||
// Listen only after re-exec, preflight, and bridge setup, so an ACK cannot
|
||||
// precede a known startup failure. Isolated canaries expose no endpoint.
|
||||
mocktail::Status broker_status =
|
||||
mocktail::runtime::ExternalLaunchBroker::StartOwnerAfterLockAcquired(
|
||||
broker_options, &external_launch_broker.broker(),
|
||||
@@ -804,10 +815,8 @@ int main(int argc, char* argv[]) {
|
||||
command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
|
||||
failure_dialog.MarkSuccessful();
|
||||
support_bundle_guard.Disarm();
|
||||
// The embedded Android payload registers process-global atexit handlers
|
||||
// for workers that it does not expose for joining. The supported runtime
|
||||
// reaches this boundary only after explicit lifecycle, audio, input, and
|
||||
// SDL shutdown; do not re-enter guest teardown from the host C runtime.
|
||||
// Guest atexit handlers target workers that cannot be joined. Host
|
||||
// shutdown is already complete here, so do not re-enter guest teardown.
|
||||
std::cout.flush();
|
||||
std::cerr.flush();
|
||||
std::_Exit(EXIT_SUCCESS);
|
||||
|
||||
@@ -264,8 +264,8 @@ std::string CommandLineUsage(const std::string& program_name) {
|
||||
"and\n"
|
||||
<< " uses its native sign-in flow. New credentials are stored "
|
||||
"privately.\n\n"
|
||||
<< "Debug and experimental toggles remain available as MOCKTAIL_* env "
|
||||
"vars.\n";
|
||||
<< "Additional runtime options are available as MOCKTAIL_* environment "
|
||||
"variables.\n";
|
||||
return usage.str();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
// Copyright 2026 Mocktail Project Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
#include "runtime/discord_rpc.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "runtime/owned_pthread.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
using Json = nlohmann::json;
|
||||
|
||||
constexpr std::size_t kMaximumDiscordFrameBytes = 1024U * 1024U;
|
||||
constexpr std::size_t kMaximumRobloxApiBytes = 512U * 1024U;
|
||||
constexpr std::size_t kDiscordWorkerStackBytes = 16U * 1024U * 1024U;
|
||||
constexpr std::chrono::milliseconds kReconnectDelay{5000};
|
||||
constexpr std::chrono::seconds kDiscordActivityRateWindow{20};
|
||||
constexpr std::size_t kDiscordActivityRateLimit = 5;
|
||||
constexpr std::chrono::seconds kPlaceMetadataRetryBase{10};
|
||||
constexpr std::chrono::seconds kPlaceMetadataRetryMaximum{300};
|
||||
constexpr char kJoinPage[] = "https://komaruworld.github.io/mocktail/join.html";
|
||||
|
||||
const char* PresencePhaseName(RobloxExperiencePresencePhase phase) {
|
||||
switch (phase) {
|
||||
case RobloxExperiencePresencePhase::kBrowsing:
|
||||
return "browsing";
|
||||
case RobloxExperiencePresencePhase::kJoining:
|
||||
return "joining";
|
||||
case RobloxExperiencePresencePhase::kPlaying:
|
||||
return "playing";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
enum class DiscordOpcode : uint32_t {
|
||||
kHandshake = 0,
|
||||
kFrame = 1,
|
||||
kClose = 2,
|
||||
kPing = 3,
|
||||
kPong = 4,
|
||||
};
|
||||
|
||||
struct DiscordFrame {
|
||||
DiscordOpcode opcode = DiscordOpcode::kClose;
|
||||
std::string payload;
|
||||
};
|
||||
|
||||
std::string TruncateUtf8(std::string value, std::size_t maximum) {
|
||||
if (value.size() <= maximum) {
|
||||
return value;
|
||||
}
|
||||
std::size_t boundary = maximum;
|
||||
while (boundary != 0 &&
|
||||
(static_cast<unsigned char>(value[boundary]) & 0xc0U) == 0x80U) {
|
||||
--boundary;
|
||||
}
|
||||
value.resize(boundary);
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string DumpJson(const Json& value) {
|
||||
return value.dump(-1, ' ', false, Json::error_handler_t::replace);
|
||||
}
|
||||
|
||||
std::string RenderPlaceTemplate(std::string text, std::string_view place_name) {
|
||||
constexpr std::string_view placeholder = "{place_name}";
|
||||
std::size_t offset = 0;
|
||||
while ((offset = text.find(placeholder, offset)) != std::string::npos) {
|
||||
text.replace(offset, placeholder.size(), place_name);
|
||||
offset += place_name.size();
|
||||
}
|
||||
return TruncateUtf8(std::move(text), 128);
|
||||
}
|
||||
|
||||
std::string PercentEncode(std::string_view value) {
|
||||
constexpr char hex[] = "0123456789ABCDEF";
|
||||
std::string encoded;
|
||||
encoded.reserve(value.size() * 3);
|
||||
for (const unsigned char byte : value) {
|
||||
if ((byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z') ||
|
||||
(byte >= '0' && byte <= '9') || byte == '-' || byte == '_' ||
|
||||
byte == '.' || byte == '~') {
|
||||
encoded.push_back(static_cast<char>(byte));
|
||||
} else {
|
||||
encoded.push_back('%');
|
||||
encoded.push_back(hex[byte >> 4U]);
|
||||
encoded.push_back(hex[byte & 0x0fU]);
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
bool HasDiscordJoinTarget(const RobloxExperienceLaunchRequest& request) {
|
||||
return request.place_id > 0 &&
|
||||
(request.game_instance_id.empty() ||
|
||||
(request.game_instance_id.size() <= 128 &&
|
||||
std::none_of(request.game_instance_id.begin(),
|
||||
request.game_instance_id.end(),
|
||||
[](unsigned char byte) {
|
||||
return byte < 0x20 || byte == 0x7f;
|
||||
})));
|
||||
}
|
||||
|
||||
bool WriteAll(int descriptor, const void* bytes, std::size_t size) {
|
||||
const auto* next = static_cast<const unsigned char*>(bytes);
|
||||
while (size != 0) {
|
||||
const ssize_t written =
|
||||
send(descriptor, next, size, MSG_NOSIGNAL | MSG_DONTWAIT);
|
||||
if (written > 0) {
|
||||
next += written;
|
||||
size -= static_cast<std::size_t>(written);
|
||||
continue;
|
||||
}
|
||||
if (written < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||
pollfd event{descriptor, POLLOUT, 0};
|
||||
if (poll(&event, 1, 1500) > 0 && (event.revents & POLLOUT) != 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadAll(int descriptor, void* bytes, std::size_t size) {
|
||||
auto* next = static_cast<unsigned char*>(bytes);
|
||||
while (size != 0) {
|
||||
const ssize_t received = recv(descriptor, next, size, 0);
|
||||
if (received > 0) {
|
||||
next += received;
|
||||
size -= static_cast<std::size_t>(received);
|
||||
continue;
|
||||
}
|
||||
if (received < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void EncodeLittleEndian(uint32_t value, unsigned char* destination) {
|
||||
destination[0] = static_cast<unsigned char>(value & 0xffU);
|
||||
destination[1] = static_cast<unsigned char>((value >> 8U) & 0xffU);
|
||||
destination[2] = static_cast<unsigned char>((value >> 16U) & 0xffU);
|
||||
destination[3] = static_cast<unsigned char>((value >> 24U) & 0xffU);
|
||||
}
|
||||
|
||||
uint32_t DecodeLittleEndian(const unsigned char* source) {
|
||||
return static_cast<uint32_t>(source[0]) |
|
||||
(static_cast<uint32_t>(source[1]) << 8U) |
|
||||
(static_cast<uint32_t>(source[2]) << 16U) |
|
||||
(static_cast<uint32_t>(source[3]) << 24U);
|
||||
}
|
||||
|
||||
bool WriteFrame(int descriptor, DiscordOpcode opcode,
|
||||
std::string_view payload) {
|
||||
if (payload.size() > kMaximumDiscordFrameBytes ||
|
||||
payload.size() > std::numeric_limits<uint32_t>::max()) {
|
||||
return false;
|
||||
}
|
||||
std::array<unsigned char, 8> header{};
|
||||
EncodeLittleEndian(static_cast<uint32_t>(opcode), header.data());
|
||||
EncodeLittleEndian(static_cast<uint32_t>(payload.size()), header.data() + 4);
|
||||
return WriteAll(descriptor, header.data(), header.size()) &&
|
||||
WriteAll(descriptor, payload.data(), payload.size());
|
||||
}
|
||||
|
||||
bool ReadFrame(int descriptor, DiscordFrame* frame) {
|
||||
std::array<unsigned char, 8> header{};
|
||||
if (frame == nullptr || !ReadAll(descriptor, header.data(), header.size())) {
|
||||
return false;
|
||||
}
|
||||
const uint32_t length = DecodeLittleEndian(header.data() + 4);
|
||||
if (length > kMaximumDiscordFrameBytes) {
|
||||
return false;
|
||||
}
|
||||
frame->opcode = static_cast<DiscordOpcode>(DecodeLittleEndian(header.data()));
|
||||
frame->payload.resize(length);
|
||||
return length == 0 ||
|
||||
ReadAll(descriptor, frame->payload.data(), frame->payload.size());
|
||||
}
|
||||
|
||||
void LogDiscordRpcError(const DiscordFrame& frame) {
|
||||
if (frame.opcode != DiscordOpcode::kFrame) {
|
||||
return;
|
||||
}
|
||||
const Json response = Json::parse(frame.payload, nullptr, false);
|
||||
if (!response.is_object()) {
|
||||
return;
|
||||
}
|
||||
const auto event = response.find("evt");
|
||||
if (event == response.end() || !event->is_string() ||
|
||||
event->get_ref<const std::string&>() != "ERROR") {
|
||||
return;
|
||||
}
|
||||
int64_t code = 0;
|
||||
std::string message = "Discord rejected an RPC request";
|
||||
const auto data = response.find("data");
|
||||
if (data != response.end() && data->is_object()) {
|
||||
const auto code_value = data->find("code");
|
||||
if (code_value != data->end() && code_value->is_number_integer()) {
|
||||
code = code_value->get<int64_t>();
|
||||
}
|
||||
const auto message_value = data->find("message");
|
||||
if (message_value != data->end() && message_value->is_string()) {
|
||||
message = TruncateUtf8(message_value->get<std::string>(), 256);
|
||||
for (char& byte : message) {
|
||||
if (static_cast<unsigned char>(byte) < 0x20U || byte == 0x7f) {
|
||||
byte = ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fprintf(stderr, " [discord-rpc] request rejected code=%lld: %s\n",
|
||||
static_cast<long long>(code), message.c_str());
|
||||
}
|
||||
|
||||
std::vector<std::string> DiscordSocketDirectories() {
|
||||
std::vector<std::string> directories;
|
||||
const auto append = [&directories](const char* path) {
|
||||
if (path != nullptr && path[0] != '\0') {
|
||||
directories.emplace_back(path);
|
||||
}
|
||||
};
|
||||
const char* runtime = std::getenv("XDG_RUNTIME_DIR");
|
||||
append(runtime);
|
||||
if (runtime != nullptr && runtime[0] != '\0') {
|
||||
directories.emplace_back(std::string(runtime) +
|
||||
"/app/com.discordapp.Discord");
|
||||
directories.emplace_back(std::string(runtime) +
|
||||
"/.flatpak/com.discordapp.Discord/xdg-run");
|
||||
directories.emplace_back(std::string(runtime) + "/snap.discord");
|
||||
}
|
||||
append(std::getenv("TMPDIR"));
|
||||
directories.emplace_back("/tmp");
|
||||
directories.emplace_back("/run/user/" + std::to_string(getuid()));
|
||||
std::unordered_set<std::string> unique;
|
||||
std::vector<std::string> result;
|
||||
for (std::string& directory : directories) {
|
||||
if (unique.insert(directory).second) {
|
||||
result.push_back(std::move(directory));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int ConnectDiscord() {
|
||||
for (const std::string& directory : DiscordSocketDirectories()) {
|
||||
for (int index = 0; index < 10; ++index) {
|
||||
const std::string path =
|
||||
directory + "/discord-ipc-" + std::to_string(index);
|
||||
sockaddr_un address{};
|
||||
address.sun_family = AF_UNIX;
|
||||
if (path.size() >= sizeof(address.sun_path)) {
|
||||
continue;
|
||||
}
|
||||
std::memcpy(address.sun_path, path.c_str(), path.size() + 1);
|
||||
const int descriptor = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
if (descriptor < 0) {
|
||||
continue;
|
||||
}
|
||||
timeval timeout{2, 0};
|
||||
(void)setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout,
|
||||
sizeof(timeout));
|
||||
(void)setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout,
|
||||
sizeof(timeout));
|
||||
if (connect(descriptor, reinterpret_cast<const sockaddr*>(&address),
|
||||
sizeof(address)) == 0) {
|
||||
return descriptor;
|
||||
}
|
||||
(void)close(descriptor);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool HandshakeDiscord(int descriptor, std::string_view application_id) {
|
||||
Json handshake = {{"v", 1}, {"client_id", application_id}};
|
||||
if (!WriteFrame(descriptor, DiscordOpcode::kHandshake, DumpJson(handshake))) {
|
||||
return false;
|
||||
}
|
||||
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||
DiscordFrame frame;
|
||||
if (!ReadFrame(descriptor, &frame)) {
|
||||
return false;
|
||||
}
|
||||
if (frame.opcode == DiscordOpcode::kPing) {
|
||||
if (!WriteFrame(descriptor, DiscordOpcode::kPong, frame.payload)) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (frame.opcode != DiscordOpcode::kFrame) {
|
||||
return false;
|
||||
}
|
||||
const Json response = Json::parse(frame.payload, nullptr, false);
|
||||
const auto event =
|
||||
response.is_object() ? response.find("evt") : response.end();
|
||||
return event != response.end() && event->is_string() &&
|
||||
event->get_ref<const std::string&>() == "READY";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Json ActivityJson(const DiscordRpcActivity& activity) {
|
||||
Json result = {
|
||||
{"type", 0}, {"details", activity.details}, {"instance", true}};
|
||||
if (!activity.state.empty()) {
|
||||
result["state"] = activity.state;
|
||||
}
|
||||
if (activity.start_timestamp.has_value()) {
|
||||
result["timestamps"] = {{"start", *activity.start_timestamp}};
|
||||
}
|
||||
if (!activity.large_image.empty()) {
|
||||
result["assets"] = {{"large_image", activity.large_image}};
|
||||
if (!activity.large_text.empty()) {
|
||||
result["assets"]["large_text"] = activity.large_text;
|
||||
}
|
||||
}
|
||||
if (!activity.button_label.empty() && !activity.button_url.empty()) {
|
||||
result["buttons"] = Json::array(
|
||||
{{{"label", activity.button_label}, {"url", activity.button_url}}});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool SetDiscordActivity(int descriptor, const DiscordRpcActivity* activity,
|
||||
uint64_t nonce) {
|
||||
Json args = {{"pid", getpid()}};
|
||||
args["activity"] = activity != nullptr ? ActivityJson(*activity) : Json();
|
||||
const Json request = {{"cmd", "SET_ACTIVITY"},
|
||||
{"args", std::move(args)},
|
||||
{"nonce", std::to_string(nonce)}};
|
||||
return WriteFrame(descriptor, DiscordOpcode::kFrame, DumpJson(request));
|
||||
}
|
||||
|
||||
struct BoundedResponse {
|
||||
std::string body;
|
||||
bool exceeded = false;
|
||||
};
|
||||
|
||||
std::once_flag g_curl_init_once;
|
||||
CURLcode g_curl_init_status = CURLE_FAILED_INIT;
|
||||
|
||||
void InitializeCurl() {
|
||||
g_curl_init_status = curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||
}
|
||||
|
||||
size_t WriteHttpResponse(char* data, size_t size, size_t count,
|
||||
void* user_data) {
|
||||
auto* response = static_cast<BoundedResponse*>(user_data);
|
||||
if (response == nullptr || data == nullptr ||
|
||||
(count != 0 && size > std::numeric_limits<size_t>::max() / count)) {
|
||||
return 0;
|
||||
}
|
||||
const std::size_t bytes = size * count;
|
||||
if (bytes > kMaximumRobloxApiBytes ||
|
||||
response->body.size() > kMaximumRobloxApiBytes - bytes) {
|
||||
response->exceeded = true;
|
||||
return 0;
|
||||
}
|
||||
response->body.append(data, bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
std::optional<Json> GetRobloxJson(std::string url) {
|
||||
std::call_once(g_curl_init_once, InitializeCurl);
|
||||
if (g_curl_init_status != CURLE_OK) {
|
||||
return std::nullopt;
|
||||
}
|
||||
CURL* curl = curl_easy_init();
|
||||
if (curl == nullptr) {
|
||||
return std::nullopt;
|
||||
}
|
||||
BoundedResponse response;
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 2000L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 3500L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "Mocktail/0.1");
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteHttpResponse);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
|
||||
std::string proxy_url;
|
||||
const char* proxy_host = std::getenv("MOCKTAIL_HTTP_PROXY_HOST");
|
||||
const char* proxy_port = std::getenv("MOCKTAIL_HTTP_PROXY_PORT");
|
||||
if (proxy_host != nullptr && proxy_port != nullptr) {
|
||||
const std::optional<NetworkProxyConfig> proxy =
|
||||
ParseNetworkProxyConfig(proxy_host, proxy_port);
|
||||
if (proxy.has_value()) {
|
||||
proxy_url = BuildNetworkProxyUrl(*proxy);
|
||||
curl_easy_setopt(curl, CURLOPT_PROXY, proxy_url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_PROXYTYPE, CURLPROXY_HTTP);
|
||||
}
|
||||
}
|
||||
const CURLcode status = curl_easy_perform(curl);
|
||||
long http_status = 0;
|
||||
if (status == CURLE_OK) {
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_status);
|
||||
}
|
||||
curl_easy_cleanup(curl);
|
||||
if (status != CURLE_OK || http_status != 200 || response.exceeded) {
|
||||
return std::nullopt;
|
||||
}
|
||||
Json parsed = Json::parse(response.body, nullptr, false);
|
||||
if (parsed.is_discarded()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
struct ResolvedPlaceMetadata {
|
||||
std::string name;
|
||||
std::string icon_url;
|
||||
};
|
||||
|
||||
bool IsSafeExternalImageUrl(std::string_view url) {
|
||||
return url.size() <= 512 && url.substr(0, 8) == "https://" &&
|
||||
std::none_of(url.begin(), url.end(), [](unsigned char byte) {
|
||||
return byte < 0x20U || byte == 0x7fU;
|
||||
});
|
||||
}
|
||||
|
||||
ResolvedPlaceMetadata ResolvePlaceMetadata(int64_t place_id) {
|
||||
ResolvedPlaceMetadata metadata;
|
||||
if (place_id <= 0) {
|
||||
return metadata;
|
||||
}
|
||||
const std::optional<Json> universe =
|
||||
GetRobloxJson("https://apis.roblox.com/universes/v1/places/" +
|
||||
std::to_string(place_id) + "/universe");
|
||||
if (!universe.has_value() || !universe->contains("universeId")) {
|
||||
return metadata;
|
||||
}
|
||||
const Json& identifier = (*universe)["universeId"];
|
||||
int64_t universe_id = 0;
|
||||
if (identifier.is_number_unsigned()) {
|
||||
const uint64_t unsigned_id = identifier.get<uint64_t>();
|
||||
if (unsigned_id >
|
||||
static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) {
|
||||
return metadata;
|
||||
}
|
||||
universe_id = static_cast<int64_t>(unsigned_id);
|
||||
} else if (identifier.is_number_integer()) {
|
||||
universe_id = identifier.get<int64_t>();
|
||||
} else {
|
||||
return metadata;
|
||||
}
|
||||
if (universe_id <= 0) {
|
||||
return metadata;
|
||||
}
|
||||
const std::optional<Json> game =
|
||||
GetRobloxJson("https://games.roblox.com/v1/games?universeIds=" +
|
||||
std::to_string(universe_id));
|
||||
if (game.has_value() && game->contains("data") &&
|
||||
(*game)["data"].is_array() && !(*game)["data"].empty()) {
|
||||
const Json& first = (*game)["data"][0];
|
||||
if (first.contains("name") && first["name"].is_string()) {
|
||||
metadata.name =
|
||||
TruncateUtf8(first["name"].get<std::string>(), 128);
|
||||
}
|
||||
}
|
||||
|
||||
const std::optional<Json> thumbnail = GetRobloxJson(
|
||||
"https://thumbnails.roblox.com/v1/games/icons?universeIds=" +
|
||||
std::to_string(universe_id) +
|
||||
"&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false");
|
||||
if (thumbnail.has_value() && thumbnail->contains("data") &&
|
||||
(*thumbnail)["data"].is_array() && !(*thumbnail)["data"].empty()) {
|
||||
const Json& first = (*thumbnail)["data"][0];
|
||||
if (first.contains("imageUrl") && first["imageUrl"].is_string()) {
|
||||
std::string icon_url = first["imageUrl"].get<std::string>();
|
||||
if (IsSafeExternalImageUrl(icon_url)) {
|
||||
metadata.icon_url = std::move(icon_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
std::chrono::seconds PlaceMetadataRetryDelay(unsigned failures) {
|
||||
const unsigned shift = std::min(failures, 5U);
|
||||
return std::min(kPlaceMetadataRetryBase * (1U << shift),
|
||||
kPlaceMetadataRetryMaximum);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool IsPublicDiscordJoin(const RobloxExperienceLaunchRequest& request) {
|
||||
return HasDiscordJoinTarget(request) &&
|
||||
request.reserved_server_access_code.empty() &&
|
||||
request.access_code.empty() && request.link_code.empty();
|
||||
}
|
||||
|
||||
std::string BuildDiscordJoinUrl(const RobloxExperienceLaunchRequest& request) {
|
||||
if (!HasDiscordJoinTarget(request)) {
|
||||
return {};
|
||||
}
|
||||
std::string url =
|
||||
std::string(kJoinPage) + "#placeId=" + std::to_string(request.place_id);
|
||||
if (!request.game_instance_id.empty()) {
|
||||
url += "&gameInstanceId=" + PercentEncode(request.game_instance_id);
|
||||
}
|
||||
return url.size() <= 512 ? std::move(url) : std::string();
|
||||
}
|
||||
|
||||
DiscordRpcActivity BuildDiscordRpcActivity(
|
||||
const DiscordRpcConfig& config, RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request, std::string place_name,
|
||||
int64_t session_started_at, std::string place_icon_url) {
|
||||
DiscordRpcActivity activity;
|
||||
activity.state = TruncateUtf8(config.text.state, 128);
|
||||
switch (phase) {
|
||||
case RobloxExperiencePresencePhase::kBrowsing:
|
||||
activity.details = TruncateUtf8(config.text.browsing, 128);
|
||||
break;
|
||||
case RobloxExperiencePresencePhase::kJoining:
|
||||
case RobloxExperiencePresencePhase::kPlaying: {
|
||||
if (place_name.empty()) {
|
||||
if (phase == RobloxExperiencePresencePhase::kJoining) {
|
||||
activity.details = TruncateUtf8(config.text.joining, 128);
|
||||
break;
|
||||
}
|
||||
place_name = config.text.unknown_place;
|
||||
}
|
||||
if (config.show_place_name) {
|
||||
activity.details = RenderPlaceTemplate(config.text.playing, place_name);
|
||||
} else {
|
||||
activity.details = activity.state;
|
||||
activity.state.clear();
|
||||
}
|
||||
if (phase == RobloxExperiencePresencePhase::kPlaying &&
|
||||
config.show_elapsed_time && session_started_at > 0) {
|
||||
activity.start_timestamp = session_started_at;
|
||||
}
|
||||
if (IsSafeExternalImageUrl(place_icon_url)) {
|
||||
activity.large_image = std::move(place_icon_url);
|
||||
activity.large_text = TruncateUtf8(place_name, 128);
|
||||
}
|
||||
if (phase == RobloxExperiencePresencePhase::kPlaying &&
|
||||
config.join_enabled && request != nullptr &&
|
||||
(!config.public_servers_only || IsPublicDiscordJoin(*request))) {
|
||||
activity.button_url = BuildDiscordJoinUrl(*request);
|
||||
if (!activity.button_url.empty()) {
|
||||
activity.button_label = config.join_button_label;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
class DiscordRpcSession::Impl final {
|
||||
public:
|
||||
explicit Impl(DiscordRpcConfig config) : config_(std::move(config)) {}
|
||||
|
||||
~Impl() { Stop(); }
|
||||
|
||||
bool Start(std::string* detail) {
|
||||
if (!config_.enabled) {
|
||||
if (detail != nullptr) {
|
||||
*detail = "disabled by runtime policy";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (config_.application_id.empty()) {
|
||||
if (detail != nullptr) {
|
||||
*detail = "no Discord application ID is bundled";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
std::call_once(g_curl_init_once, InitializeCurl);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (started_) {
|
||||
return true;
|
||||
}
|
||||
phase_ = RobloxExperiencePresencePhase::kBrowsing;
|
||||
request_ = {};
|
||||
revision_ = 1;
|
||||
stopping_ = false;
|
||||
}
|
||||
const int error =
|
||||
worker_.Start(&Impl::RunWorker, this, kDiscordWorkerStackBytes);
|
||||
if (error != 0) {
|
||||
if (detail != nullptr) {
|
||||
*detail =
|
||||
"could not start Discord RPC worker: " + std::to_string(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
started_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Stop() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!started_ && !worker_.joinable()) {
|
||||
return;
|
||||
}
|
||||
stopping_ = true;
|
||||
condition_.notify_all();
|
||||
}
|
||||
if (worker_.joinable()) {
|
||||
(void)worker_.WaitFor(-1, 1);
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
started_ = false;
|
||||
}
|
||||
|
||||
void Update(RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!started_ || stopping_) {
|
||||
return;
|
||||
}
|
||||
phase_ = phase;
|
||||
request_ = {};
|
||||
if (request != nullptr) {
|
||||
// Never pass access codes or canonical launch JSON to the Discord worker.
|
||||
request_.place_id = request->place_id;
|
||||
request_.game_instance_id = request->game_instance_id;
|
||||
if (!request->reserved_server_access_code.empty()) {
|
||||
request_.reserved_server_access_code = "present";
|
||||
}
|
||||
if (!request->access_code.empty()) {
|
||||
request_.access_code = "present";
|
||||
}
|
||||
if (!request->link_code.empty()) {
|
||||
request_.link_code = "present";
|
||||
}
|
||||
}
|
||||
if (phase == RobloxExperiencePresencePhase::kPlaying) {
|
||||
session_started_at_ =
|
||||
std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
} else {
|
||||
session_started_at_ = 0;
|
||||
}
|
||||
++revision_;
|
||||
std::fprintf(stderr,
|
||||
" [discord-rpc] queued phase=%s revision=%llu "
|
||||
"place_id=%lld\n",
|
||||
PresencePhaseName(phase_),
|
||||
static_cast<unsigned long long>(revision_),
|
||||
static_cast<long long>(request_.place_id));
|
||||
condition_.notify_all();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Desired {
|
||||
RobloxExperiencePresencePhase phase =
|
||||
RobloxExperiencePresencePhase::kBrowsing;
|
||||
RobloxExperienceLaunchRequest request;
|
||||
int64_t started_at = 0;
|
||||
uint64_t revision = 0;
|
||||
};
|
||||
|
||||
struct CachedPlaceMetadata {
|
||||
std::string name;
|
||||
std::string icon_url;
|
||||
std::chrono::steady_clock::time_point next_attempt{};
|
||||
unsigned failures = 0;
|
||||
};
|
||||
|
||||
static void* RunWorker(void* context) {
|
||||
static_cast<Impl*>(context)->Run();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool IsStopping() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return stopping_;
|
||||
}
|
||||
|
||||
Desired Snapshot() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return {phase_, request_, session_started_at_, revision_};
|
||||
}
|
||||
|
||||
bool WaitForChange(uint64_t revision, std::chrono::milliseconds duration) {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
condition_.wait_for(lock, duration, [this, revision]() {
|
||||
return stopping_ || revision_ != revision;
|
||||
});
|
||||
return !stopping_;
|
||||
}
|
||||
|
||||
void Run() {
|
||||
int descriptor = -1;
|
||||
uint64_t published_revision = 0;
|
||||
uint64_t nonce = 1;
|
||||
std::deque<std::chrono::steady_clock::time_point> activity_updates;
|
||||
while (!IsStopping()) {
|
||||
Desired desired = Snapshot();
|
||||
std::string place_name;
|
||||
std::string place_icon_url;
|
||||
bool resolve_metadata_after_publish = false;
|
||||
if ((desired.phase == RobloxExperiencePresencePhase::kJoining ||
|
||||
desired.phase == RobloxExperiencePresencePhase::kPlaying) &&
|
||||
desired.request.place_id > 0) {
|
||||
CachedPlaceMetadata& cached =
|
||||
place_metadata_[desired.request.place_id];
|
||||
place_name = cached.name;
|
||||
place_icon_url = cached.icon_url;
|
||||
if ((cached.name.empty() || cached.icon_url.empty()) &&
|
||||
std::chrono::steady_clock::now() >= cached.next_attempt) {
|
||||
resolve_metadata_after_publish = true;
|
||||
}
|
||||
}
|
||||
if (descriptor < 0) {
|
||||
descriptor = ConnectDiscord();
|
||||
if (descriptor < 0 ||
|
||||
!HandshakeDiscord(descriptor, config_.application_id)) {
|
||||
if (descriptor >= 0) {
|
||||
(void)close(descriptor);
|
||||
descriptor = -1;
|
||||
}
|
||||
if (!WaitForChange(desired.revision, kReconnectDelay)) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
std::fprintf(stderr, " [discord-rpc] connected to Discord Desktop\n");
|
||||
published_revision = 0;
|
||||
}
|
||||
const bool wait_for_place_metadata =
|
||||
(desired.phase == RobloxExperiencePresencePhase::kJoining ||
|
||||
desired.phase == RobloxExperiencePresencePhase::kPlaying) &&
|
||||
desired.request.place_id > 0 && place_name.empty();
|
||||
if (published_revision != desired.revision &&
|
||||
!wait_for_place_metadata) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
while (!activity_updates.empty() &&
|
||||
now - activity_updates.front() >= kDiscordActivityRateWindow) {
|
||||
activity_updates.pop_front();
|
||||
}
|
||||
if (activity_updates.size() >= kDiscordActivityRateLimit) {
|
||||
const auto delay =
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
activity_updates.front() + kDiscordActivityRateWindow - now);
|
||||
(void)WaitForChange(desired.revision, delay);
|
||||
continue;
|
||||
}
|
||||
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
|
||||
config_, desired.phase,
|
||||
desired.phase == RobloxExperiencePresencePhase::kBrowsing
|
||||
? nullptr
|
||||
: &desired.request,
|
||||
std::move(place_name), desired.started_at,
|
||||
std::move(place_icon_url));
|
||||
if (!SetDiscordActivity(descriptor, &activity, nonce++)) {
|
||||
(void)close(descriptor);
|
||||
descriptor = -1;
|
||||
continue;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [discord-rpc] sent phase=%s revision=%llu\n",
|
||||
PresencePhaseName(desired.phase),
|
||||
static_cast<unsigned long long>(desired.revision));
|
||||
activity_updates.push_back(std::chrono::steady_clock::now());
|
||||
published_revision = desired.revision;
|
||||
}
|
||||
|
||||
if (resolve_metadata_after_publish) {
|
||||
const ResolvedPlaceMetadata resolved =
|
||||
ResolvePlaceMetadata(desired.request.place_id);
|
||||
CachedPlaceMetadata& cached =
|
||||
place_metadata_[desired.request.place_id];
|
||||
bool changed = false;
|
||||
if (!resolved.name.empty() && resolved.name != cached.name) {
|
||||
cached.name = resolved.name;
|
||||
changed = true;
|
||||
}
|
||||
if (!resolved.icon_url.empty() &&
|
||||
resolved.icon_url != cached.icon_url) {
|
||||
cached.icon_url = resolved.icon_url;
|
||||
changed = true;
|
||||
}
|
||||
if (cached.name.empty() || cached.icon_url.empty()) {
|
||||
const auto delay = PlaceMetadataRetryDelay(cached.failures++);
|
||||
cached.next_attempt = std::chrono::steady_clock::now() + delay;
|
||||
std::fprintf(stderr,
|
||||
" [discord-rpc] place metadata incomplete; retry in "
|
||||
"%llds place_id=%lld\n",
|
||||
static_cast<long long>(delay.count()),
|
||||
static_cast<long long>(desired.request.place_id));
|
||||
} else {
|
||||
cached.failures = 0;
|
||||
cached.next_attempt = {};
|
||||
}
|
||||
if (Snapshot().revision != desired.revision) {
|
||||
continue;
|
||||
}
|
||||
if (changed &&
|
||||
(desired.phase == RobloxExperiencePresencePhase::kJoining ||
|
||||
desired.phase == RobloxExperiencePresencePhase::kPlaying)) {
|
||||
// Publish cached metadata through the normal rate limiter.
|
||||
published_revision = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
pollfd event{descriptor, POLLIN, 0};
|
||||
const int ready = poll(&event, 1, 500);
|
||||
if (ready < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if (ready < 0 || (ready > 0 && (event.revents &
|
||||
(POLLERR | POLLHUP | POLLNVAL)) != 0)) {
|
||||
(void)close(descriptor);
|
||||
descriptor = -1;
|
||||
continue;
|
||||
}
|
||||
if (ready > 0 && (event.revents & POLLIN) != 0) {
|
||||
DiscordFrame frame;
|
||||
if (!ReadFrame(descriptor, &frame)) {
|
||||
(void)close(descriptor);
|
||||
descriptor = -1;
|
||||
} else if (frame.opcode == DiscordOpcode::kPing) {
|
||||
if (!WriteFrame(descriptor, DiscordOpcode::kPong, frame.payload)) {
|
||||
(void)close(descriptor);
|
||||
descriptor = -1;
|
||||
}
|
||||
} else if (frame.opcode == DiscordOpcode::kClose) {
|
||||
(void)close(descriptor);
|
||||
descriptor = -1;
|
||||
} else {
|
||||
LogDiscordRpcError(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (descriptor >= 0) {
|
||||
(void)SetDiscordActivity(descriptor, nullptr, nonce);
|
||||
(void)close(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
const DiscordRpcConfig config_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable condition_;
|
||||
OwnedPthread worker_;
|
||||
RobloxExperiencePresencePhase phase_ =
|
||||
RobloxExperiencePresencePhase::kBrowsing;
|
||||
RobloxExperienceLaunchRequest request_;
|
||||
std::unordered_map<int64_t, CachedPlaceMetadata> place_metadata_;
|
||||
int64_t session_started_at_ = 0;
|
||||
uint64_t revision_ = 0;
|
||||
bool started_ = false;
|
||||
bool stopping_ = false;
|
||||
};
|
||||
|
||||
DiscordRpcSession::DiscordRpcSession(DiscordRpcConfig config)
|
||||
: impl_(std::make_unique<Impl>(std::move(config))) {}
|
||||
|
||||
DiscordRpcSession::~DiscordRpcSession() { Stop(); }
|
||||
|
||||
bool DiscordRpcSession::Start(std::string* detail) {
|
||||
return impl_->Start(detail);
|
||||
}
|
||||
|
||||
void DiscordRpcSession::Stop() { impl_->Stop(); }
|
||||
|
||||
RobloxExperiencePresenceObserver DiscordRpcSession::observer() {
|
||||
return {this, &DiscordRpcSession::Notify};
|
||||
}
|
||||
|
||||
void DiscordRpcSession::Notify(void* context,
|
||||
RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request) {
|
||||
if (context != nullptr) {
|
||||
static_cast<DiscordRpcSession*>(context)->impl_->Update(phase, request);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -15,6 +15,9 @@
|
||||
#include "runtime/game_mode.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#if defined(__GLIBC__)
|
||||
#include <link.h>
|
||||
#endif
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
@@ -54,6 +57,19 @@ std::string ClientError(const GameModeClientApi& api,
|
||||
return std::string(fallback);
|
||||
}
|
||||
|
||||
void* OpenClientLibrary(const char* name) {
|
||||
#if defined(__GLIBC__)
|
||||
// Mocktail intentionally exports Bionic compatibility symbols for the
|
||||
// Android payload. A normal dlopen() lets host libraries such as libdbus
|
||||
// bind to those ABI-compatible-looking exports instead of glibc, which can
|
||||
// corrupt the GameMode portal exchange. A new link-map namespace keeps the
|
||||
// host client and all of its dependencies on the host ABI.
|
||||
return dlmopen(LM_ID_NEWLM, name, RTLD_NOW | RTLD_LOCAL);
|
||||
#else
|
||||
return dlopen(name, RTLD_NOW | RTLD_LOCAL);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ParseGameModePolicy(std::string_view value, GameModePolicy* policy) {
|
||||
@@ -124,9 +140,9 @@ GameModeSession GameModeSession::Start(GameModePolicy policy) {
|
||||
return StartBound(policy, {}, nullptr);
|
||||
}
|
||||
|
||||
void* library = dlopen("libgamemode.so.0", RTLD_NOW | RTLD_LOCAL);
|
||||
void* library = OpenClientLibrary("libgamemode.so.0");
|
||||
if (library == nullptr) {
|
||||
library = dlopen("libgamemode.so", RTLD_NOW | RTLD_LOCAL);
|
||||
library = OpenClientLibrary("libgamemode.so");
|
||||
}
|
||||
if (library == nullptr) {
|
||||
GameModeSession session;
|
||||
|
||||
+4
-162
@@ -15,7 +15,6 @@
|
||||
#include "runtime/memory_limit.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <sys/types.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
@@ -27,9 +26,7 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -38,7 +35,6 @@ namespace {
|
||||
constexpr std::uint64_t kBytesPerKibibyte = 1024U;
|
||||
constexpr std::uint64_t kBytesPerMebibyte = 1024U * 1024U;
|
||||
constexpr int kWatchdogPollMilliseconds = 100;
|
||||
constexpr char kCgroupMarker[] = "MOCKTAIL_MEMORY_CGROUP_LIMIT_BYTES";
|
||||
|
||||
void WriteAllBestEffort(int descriptor, std::string_view bytes) {
|
||||
std::size_t offset = 0;
|
||||
@@ -115,90 +111,6 @@ ProcessMemoryUsage ReadCurrentProcessMemory() {
|
||||
std::string_view(buffer.data(), static_cast<std::size_t>(total)));
|
||||
}
|
||||
|
||||
bool ReadTextFile(const std::string& path, std::string* value) {
|
||||
std::ifstream input(path);
|
||||
if (!input) {
|
||||
return false;
|
||||
}
|
||||
return static_cast<bool>(input >> *value);
|
||||
}
|
||||
|
||||
bool ReadUnifiedCgroupPath(std::string* path) {
|
||||
std::ifstream input("/proc/self/cgroup");
|
||||
std::string line;
|
||||
while (std::getline(input, line)) {
|
||||
if (line.rfind("0::", 0) == 0) {
|
||||
*path = line.substr(3);
|
||||
return !path->empty() && path->front() == '/';
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ActiveCgroupMatches(std::uint64_t expected_bytes,
|
||||
std::string* detail) {
|
||||
std::string cgroup_path;
|
||||
if (!ReadUnifiedCgroupPath(&cgroup_path)) {
|
||||
*detail = "cannot resolve the current unified cgroup";
|
||||
return false;
|
||||
}
|
||||
const std::string root = "/sys/fs/cgroup" + cgroup_path;
|
||||
std::string memory_max;
|
||||
std::string swap_max;
|
||||
if (!ReadTextFile(root + "/memory.max", &memory_max) ||
|
||||
!ReadTextFile(root + "/memory.swap.max", &swap_max)) {
|
||||
*detail = "cannot read current cgroup memory limits";
|
||||
return false;
|
||||
}
|
||||
std::uint64_t configured_bytes = 0;
|
||||
const std::from_chars_result parsed = std::from_chars(
|
||||
memory_max.data(), memory_max.data() + memory_max.size(),
|
||||
configured_bytes);
|
||||
if (parsed.ec != std::errc() ||
|
||||
parsed.ptr != memory_max.data() + memory_max.size() ||
|
||||
configured_bytes > expected_bytes) {
|
||||
*detail = "current cgroup memory.max does not match the requested limit";
|
||||
return false;
|
||||
}
|
||||
if (swap_max != "0") {
|
||||
*detail = "current cgroup still permits swap";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string CurrentExecutable(char* const argv[]) {
|
||||
std::array<char, PATH_MAX + 1> executable{};
|
||||
const ssize_t length =
|
||||
readlink("/proc/self/exe", executable.data(), PATH_MAX);
|
||||
if (length > 0) {
|
||||
executable[static_cast<std::size_t>(length)] = '\0';
|
||||
return executable.data();
|
||||
}
|
||||
return argv != nullptr && argv[0] != nullptr ? argv[0] : std::string();
|
||||
}
|
||||
|
||||
bool SystemdUserManagerAvailable(std::string* detail) {
|
||||
if (access("/sys/fs/cgroup/cgroup.controllers", R_OK) != 0) {
|
||||
*detail = "cgroup v2 is not mounted";
|
||||
return false;
|
||||
}
|
||||
if (access("/usr/bin/systemd-run", X_OK) != 0) {
|
||||
*detail = "systemd-run is not installed";
|
||||
return false;
|
||||
}
|
||||
const char* runtime_directory = getenv("XDG_RUNTIME_DIR");
|
||||
const std::string user_runtime =
|
||||
runtime_directory != nullptr && runtime_directory[0] != '\0'
|
||||
? runtime_directory
|
||||
: "/run/user/" + std::to_string(geteuid());
|
||||
if (access((user_runtime + "/systemd/private").c_str(), F_OK) != 0) {
|
||||
*detail = "systemd user manager is not running";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ProcessMemoryUsage ParseProcessStatusMemory(std::string_view status) {
|
||||
@@ -221,84 +133,14 @@ CgroupMemoryLimitResult MaybeReexecWithCgroupMemoryLimit(
|
||||
int argc, char* const argv[], std::uint64_t limit_bytes,
|
||||
const std::vector<std::string>* reexec_arguments) {
|
||||
CgroupMemoryLimitResult result;
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
(void)reexec_arguments;
|
||||
if (limit_bytes == 0) {
|
||||
result.detail = "memory limit is disabled";
|
||||
return result;
|
||||
}
|
||||
|
||||
const char* marker = getenv(kCgroupMarker);
|
||||
if (marker != nullptr) {
|
||||
std::uint64_t marked_bytes = 0;
|
||||
const std::string_view marker_value(marker);
|
||||
const std::from_chars_result parsed = std::from_chars(
|
||||
marker_value.data(), marker_value.data() + marker_value.size(),
|
||||
marked_bytes);
|
||||
if (parsed.ec == std::errc() &&
|
||||
parsed.ptr == marker_value.data() + marker_value.size() &&
|
||||
marked_bytes == limit_bytes &&
|
||||
ActiveCgroupMatches(limit_bytes, &result.detail)) {
|
||||
result.status = CgroupMemoryLimitStatus::kActive;
|
||||
result.detail = "cgroup v2 MemoryMax is active and swap is disabled";
|
||||
} else if (result.detail.empty()) {
|
||||
result.detail = "cgroup activation marker is invalid";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!SystemdUserManagerAvailable(&result.detail)) {
|
||||
return result;
|
||||
}
|
||||
const std::string executable = CurrentExecutable(argv);
|
||||
if (executable.empty()) {
|
||||
result.status = CgroupMemoryLimitStatus::kExecFailed;
|
||||
result.detail = "cannot resolve the Mocktail executable";
|
||||
return result;
|
||||
}
|
||||
|
||||
const std::string limit = std::to_string(limit_bytes);
|
||||
std::vector<std::string> arguments;
|
||||
arguments.reserve((reexec_arguments != nullptr
|
||||
? reexec_arguments->size()
|
||||
: static_cast<std::size_t>(argc)) +
|
||||
10U);
|
||||
arguments.emplace_back("/usr/bin/systemd-run");
|
||||
arguments.emplace_back("--user");
|
||||
arguments.emplace_back("--scope");
|
||||
arguments.emplace_back("--quiet");
|
||||
arguments.emplace_back("--same-dir");
|
||||
arguments.emplace_back("--expand-environment=no");
|
||||
arguments.emplace_back("--property=MemoryMax=" + limit);
|
||||
arguments.emplace_back("--property=MemorySwapMax=0");
|
||||
arguments.emplace_back("--");
|
||||
arguments.push_back(executable);
|
||||
if (reexec_arguments != nullptr) {
|
||||
arguments.insert(arguments.end(), reexec_arguments->begin(),
|
||||
reexec_arguments->end());
|
||||
} else {
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
arguments.emplace_back(argv[index]);
|
||||
}
|
||||
}
|
||||
std::vector<char*> exec_arguments;
|
||||
exec_arguments.reserve(arguments.size() + 1U);
|
||||
for (std::string& argument : arguments) {
|
||||
exec_arguments.push_back(argument.data());
|
||||
}
|
||||
exec_arguments.push_back(nullptr);
|
||||
|
||||
if (setenv(kCgroupMarker, limit.c_str(), 1) != 0) {
|
||||
result.status = CgroupMemoryLimitStatus::kExecFailed;
|
||||
result.detail = std::string("cannot mark cgroup re-exec: ") +
|
||||
std::strerror(errno);
|
||||
return result;
|
||||
}
|
||||
(void)fflush(nullptr);
|
||||
execv(arguments.front().c_str(), exec_arguments.data());
|
||||
const int exec_error = errno;
|
||||
(void)unsetenv(kCgroupMarker);
|
||||
result.status = CgroupMemoryLimitStatus::kExecFailed;
|
||||
result.detail = std::string("cannot execute systemd-run: ") +
|
||||
std::strerror(exec_error);
|
||||
result.detail = "automatic cgroup scopes are disabled";
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,7 @@ namespace runtime {
|
||||
namespace {
|
||||
|
||||
constexpr size_t kMaxPendingLaunchRequests = 8;
|
||||
// Loading libroblox contributes a large static-TLS image. A one-megabyte
|
||||
// pthread stack is accepted in unit tests before the guest is loaded, but
|
||||
// pthread_create returns EINVAL in production once that TLS image is present.
|
||||
// Keep this above the runtime's proven 16 MiB guest-thread floor.
|
||||
// libroblox's static TLS requires the proven 16 MiB guest-thread stack floor.
|
||||
constexpr size_t kLaunchWorkerStackSize = 64ULL * 1024 * 1024;
|
||||
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{3000};
|
||||
constexpr char kRobloxBaseUrl[] = "https://www.roblox.com/";
|
||||
@@ -56,8 +53,7 @@ Status FailedPrecondition(std::string message) {
|
||||
GameSurface SnapshotProductionSurface(void*) {
|
||||
const window::WindowSurfaceSnapshot snapshot =
|
||||
window::GetWindowSurfaceSnapshot();
|
||||
return {snapshot.generation,
|
||||
snapshot.available ? snapshot.native_window : 0,
|
||||
return {snapshot.generation, snapshot.available ? snapshot.native_window : 0,
|
||||
snapshot.available ? snapshot.width : 0,
|
||||
snapshot.available ? snapshot.height : 0};
|
||||
}
|
||||
@@ -278,7 +274,8 @@ RobloxExperienceComposition::RobloxExperienceComposition(
|
||||
RobloxFreshLaunchPresentBoundary present_boundary,
|
||||
RobloxGameSurfaceJniConfig surface_config,
|
||||
const SecureRobloxCredential* initial_web_view_credential,
|
||||
RobloxExperienceSurfaceProvider surface_provider)
|
||||
RobloxExperienceSurfaceProvider surface_provider,
|
||||
RobloxExperiencePresenceObserver presence_observer)
|
||||
: environment_(environment),
|
||||
message_bus_symbols_(message_bus_symbols),
|
||||
web_view_symbols_(web_view_symbols),
|
||||
@@ -293,6 +290,7 @@ RobloxExperienceComposition::RobloxExperienceComposition(
|
||||
? surface_provider
|
||||
: RobloxExperienceSurfaceProvider{nullptr,
|
||||
&SnapshotProductionSurface}),
|
||||
presence_observer_(presence_observer),
|
||||
web_surface_exit_target_(std::make_shared<WebSurfaceExitTarget>()),
|
||||
lifecycle_target_(std::make_shared<LifecycleTarget>()) {
|
||||
web_surface_exit_target_->composition = this;
|
||||
@@ -426,7 +424,9 @@ Status RobloxExperienceComposition::OnLuaAppReady(
|
||||
}
|
||||
|
||||
auto controller = std::make_shared<RobloxFreshGameLaunchController>(
|
||||
environment_, game_symbols_, present_boundary_, surface_config_);
|
||||
environment_, game_symbols_, present_boundary_, surface_config_,
|
||||
RobloxGamePresentedObserver{this,
|
||||
&RobloxExperienceComposition::GamePresented});
|
||||
RobloxExperienceMessageBusObjects bridge_objects;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
@@ -509,9 +509,7 @@ Status RobloxExperienceComposition::OpenWebSurface(
|
||||
}
|
||||
}
|
||||
|
||||
// Opening and configuring the singleton is one serialized transaction.
|
||||
// Optional fields are normalized before this point, so presentation state
|
||||
// from the previous route can never leak into the new page.
|
||||
// Serialize setup so presentation state cannot leak between routes.
|
||||
if (!current->SetTitle(presentation.title) ||
|
||||
!current->SetVisible(presentation.visible) ||
|
||||
!current->SetBackNavigationDisabled(
|
||||
@@ -623,9 +621,8 @@ Status RobloxExperienceComposition::DispatchWebViewOpen(
|
||||
presentation.visible = request.is_visible.value_or(true);
|
||||
presentation.show_domain_as_title =
|
||||
request.show_domain_as_title.value_or(false);
|
||||
// The host helper has no Android toolbar. Mapping a hidden Roblox back
|
||||
// button to disabled host back navigation preserves the protocol's only
|
||||
// actionable navigation policy without inventing a replacement header.
|
||||
// The host helper has no toolbar, so a hidden Roblox back button disables
|
||||
// host back navigation too.
|
||||
presentation.back_navigation_disabled =
|
||||
request.back_button_visible.has_value() && !*request.back_button_visible;
|
||||
return composition->OpenWebSurface(request.url, "webview",
|
||||
@@ -799,11 +796,8 @@ Status RobloxExperienceComposition::RouteWebSurfaceEvent(
|
||||
RobloxWebViewBridge* web_view_bridge) {
|
||||
switch (event.type) {
|
||||
case WebViewHelperEventType::kExecuteRoblox:
|
||||
// EnableAndroidWebViewService4 installs WebViewProtocol's listener on
|
||||
// the shared gi.a fragment regardless of which protocol opened it. The
|
||||
// MemStorage callback in gi.a is only the APK's disabled legacy
|
||||
// fallback. Keeping the raw command intact is required for
|
||||
// RequestGameJob fields such as instanceId and joinAttemptId.
|
||||
// WebViewProtocol listens on the shared gi.a fragment. Preserve the raw
|
||||
// command, including RequestGameJob instance and attempt IDs.
|
||||
if (route == WebSurfaceRoute::kNone) {
|
||||
return FailedPrecondition(
|
||||
"WebView helper event has no active logical route");
|
||||
@@ -839,9 +833,7 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent(
|
||||
logical_generation == 0 ||
|
||||
logical_generation != web_surface_logical_generation_ ||
|
||||
web_surface_route_ == WebSurfaceRoute::kNone) {
|
||||
// A close, helper replacement, or logical page reuse invalidates every
|
||||
// event already buffered for the old surface. This is normal teardown,
|
||||
// not a platform delivery failure.
|
||||
// Closing or replacing the helper invalidates buffered surface events.
|
||||
return Status::Ok();
|
||||
}
|
||||
route = web_surface_route_;
|
||||
@@ -931,11 +923,10 @@ Status RobloxExperienceComposition::PromoteAuthenticatedSession() {
|
||||
return Status::Ok();
|
||||
}
|
||||
SecurelyClearString(&credential);
|
||||
const GameSurface surface = surface_provider_.snapshot(
|
||||
surface_provider_.context);
|
||||
const GameSurface surface =
|
||||
surface_provider_.snapshot(surface_provider_.context);
|
||||
RobloxLuaAppExperienceReadiness readiness;
|
||||
readiness.principal.kind =
|
||||
GameSessionPrincipalKind::kAuthenticated;
|
||||
readiness.principal.kind = GameSessionPrincipalKind::kAuthenticated;
|
||||
readiness.principal.generation = 1;
|
||||
readiness.principal.principal_id = std::to_string(identity.user_id);
|
||||
readiness.principal.base_url = kRobloxBaseUrl;
|
||||
@@ -952,8 +943,7 @@ Status RobloxExperienceComposition::PromoteAuthenticatedSession() {
|
||||
if (consumes_window_surface_events_) {
|
||||
window::WindowSurfaceEvent stale_event;
|
||||
while (window::PollWindowSurfaceEvent(&stale_event)) {
|
||||
// Readiness already captured the latest snapshot. Older events were
|
||||
// queued before a GAME consumer existed and must not replay stale state.
|
||||
// Readiness already captured the latest snapshot; discard older events.
|
||||
}
|
||||
}
|
||||
vm->SetRobloxExperienceLifecycleCallbacks(
|
||||
@@ -1070,8 +1060,7 @@ Status RobloxExperienceComposition::Dispatch(
|
||||
}
|
||||
if (!request.canonical_json.empty()) {
|
||||
const bool active_game_matches =
|
||||
game_active_ &&
|
||||
request.canonical_json == active_game_canonical_json_;
|
||||
game_active_ && request.canonical_json == active_game_canonical_json_;
|
||||
const bool active_launch_matches =
|
||||
active_launch_ != nullptr &&
|
||||
request.canonical_json == active_launch_->request.canonical_json;
|
||||
@@ -1111,9 +1100,8 @@ Status RobloxExperienceComposition::DrainExternalLaunchRequests() {
|
||||
Status status = DrainActiveExternalLaunchRequests(
|
||||
ExternalLaunchSink{this, &RobloxExperienceComposition::DispatchLaunch},
|
||||
available);
|
||||
// The MessageBus can fill the composition queue after the capacity snapshot
|
||||
// above. Keep the external request at the broker's front and retry on the
|
||||
// next drain instead of turning ordinary backpressure into runtime failure.
|
||||
// A MessageBus race can fill the queue; leave the request at the broker's
|
||||
// front and retry on the next drain.
|
||||
return status.code() == StatusCode::kUnavailable ? Status::Ok() : status;
|
||||
}
|
||||
|
||||
@@ -1143,9 +1131,16 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
: std::string();
|
||||
active_launch_.reset();
|
||||
}
|
||||
if (!completed_result.ok()) pending_launch_requests_.clear();
|
||||
if (!completed_result.ok()) {
|
||||
pending_launch_requests_.clear();
|
||||
presence_request_.reset();
|
||||
playing_presence_published_ = false;
|
||||
}
|
||||
}
|
||||
if (!completed_result.ok()) {
|
||||
NotifyPresence(RobloxExperiencePresencePhase::kBrowsing, nullptr);
|
||||
return completed_result;
|
||||
}
|
||||
if (!completed_result.ok()) return completed_result;
|
||||
}
|
||||
|
||||
std::shared_ptr<RobloxFreshGameLaunchController> returned_controller;
|
||||
@@ -1153,29 +1148,34 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (lua_app_return_pending_ && !launch_in_progress_ &&
|
||||
active_launch_ == nullptr) {
|
||||
// Consume before invoking the controller so a concurrent callback sets
|
||||
// a new pending observation instead of being overwritten on return.
|
||||
// Consume first so a concurrent callback can set a new observation.
|
||||
lua_app_return_pending_ = false;
|
||||
returned_controller = controller_;
|
||||
}
|
||||
}
|
||||
if (returned_controller != nullptr) {
|
||||
const Status observed = returned_controller->ObserveLuaAppReturn();
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!observed.ok()) {
|
||||
lua_app_return_pending_ = true;
|
||||
pending_launch_requests_.clear();
|
||||
return observed;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!observed.ok()) {
|
||||
lua_app_return_pending_ = true;
|
||||
pending_launch_requests_.clear();
|
||||
return observed;
|
||||
}
|
||||
game_active_ = false;
|
||||
active_game_canonical_json_.clear();
|
||||
presence_request_.reset();
|
||||
playing_presence_published_ = false;
|
||||
controlled_switch_waiting_for_return_ = false;
|
||||
}
|
||||
game_active_ = false;
|
||||
active_game_canonical_json_.clear();
|
||||
controlled_switch_waiting_for_return_ = false;
|
||||
NotifyPresence(RobloxExperiencePresencePhase::kBrowsing, nullptr);
|
||||
}
|
||||
|
||||
Status external_launch_status = DrainExternalLaunchRequests();
|
||||
if (!external_launch_status.ok()) return external_launch_status;
|
||||
|
||||
bool switch_active_game = false;
|
||||
std::optional<RobloxExperienceLaunchRequest> joining_request;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
|
||||
@@ -1184,9 +1184,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
if (controlled_switch_waiting_for_return_) return Status::Ok();
|
||||
if (!launch_in_progress_ && active_launch_ == nullptr && game_active_ &&
|
||||
!pending_launch_requests_.empty()) {
|
||||
// Prevent a stale gameDidLeave callback from being applied to the next
|
||||
// controller. The queued launch starts only after the exact LuaApp
|
||||
// return callback for this controlled leave has been consumed.
|
||||
// Start the queued launch only after consuming this leave callback, so
|
||||
// it cannot reach the next controller.
|
||||
controlled_switch_waiting_for_return_ = true;
|
||||
switch_active_game = true;
|
||||
}
|
||||
@@ -1235,8 +1234,13 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
task->context.join_request_type);
|
||||
active_launch_ = std::move(task);
|
||||
launch_in_progress_ = true;
|
||||
presence_request_ = active_launch_->request;
|
||||
playing_presence_published_ = false;
|
||||
joining_request = active_launch_->request;
|
||||
}
|
||||
|
||||
NotifyPresence(RobloxExperiencePresencePhase::kJoining, &*joining_request);
|
||||
|
||||
const int start_error =
|
||||
launch_worker_.Start(&RobloxExperienceComposition::RunLaunchWorker, this,
|
||||
kLaunchWorkerStackSize);
|
||||
@@ -1245,9 +1249,12 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
launch_in_progress_ = false;
|
||||
active_launch_.reset();
|
||||
presence_request_.reset();
|
||||
active_game_canonical_json_.clear();
|
||||
playing_presence_published_ = false;
|
||||
pending_launch_requests_.clear();
|
||||
}
|
||||
NotifyPresence(RobloxExperiencePresencePhase::kBrowsing, nullptr);
|
||||
return Status::Error(StatusCode::kPlatformError,
|
||||
"could not start experience launch worker: " +
|
||||
std::to_string(start_error));
|
||||
@@ -1260,6 +1267,34 @@ void* RobloxExperienceComposition::RunLaunchWorker(void* context) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void RobloxExperienceComposition::GamePresented(void* context,
|
||||
uint64_t frame_serial) {
|
||||
if (context != nullptr && frame_serial != 0) {
|
||||
static_cast<RobloxExperienceComposition*>(context)
|
||||
->PublishPresentedPresence(frame_serial);
|
||||
}
|
||||
}
|
||||
|
||||
void RobloxExperienceComposition::PublishPresentedPresence(
|
||||
uint64_t frame_serial) {
|
||||
std::optional<RobloxExperienceLaunchRequest> request;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!subscribed_ || playing_presence_published_ ||
|
||||
!presence_request_.has_value()) {
|
||||
return;
|
||||
}
|
||||
playing_presence_published_ = true;
|
||||
request = presence_request_;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [experience] first game frame published to presence "
|
||||
"frame=%llu place_id=%lld\n",
|
||||
static_cast<unsigned long long>(frame_serial),
|
||||
static_cast<long long>(request->place_id));
|
||||
NotifyPresence(RobloxExperiencePresencePhase::kPlaying, &*request);
|
||||
}
|
||||
|
||||
void RobloxExperienceComposition::RunActiveLaunch() {
|
||||
LaunchTask* task = nullptr;
|
||||
{
|
||||
@@ -1401,6 +1436,8 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
subscribed_ = false;
|
||||
game_active_ = false;
|
||||
active_game_canonical_json_.clear();
|
||||
presence_request_.reset();
|
||||
playing_presence_published_ = false;
|
||||
lua_app_return_pending_ = false;
|
||||
controlled_switch_waiting_for_return_ = false;
|
||||
lua_app_surface_recreation_pending_ = false;
|
||||
@@ -1481,6 +1518,14 @@ GameSessionSnapshot RobloxExperienceComposition::Snapshot() const {
|
||||
return controller != nullptr ? controller->Snapshot() : GameSessionSnapshot{};
|
||||
}
|
||||
|
||||
void RobloxExperienceComposition::NotifyPresence(
|
||||
RobloxExperiencePresencePhase phase,
|
||||
const RobloxExperienceLaunchRequest* request) const {
|
||||
if (presence_observer_.valid()) {
|
||||
presence_observer_.notify(presence_observer_.context, phase, request);
|
||||
}
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
@@ -1863,6 +1908,8 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
|
||||
platform_protocols_initialized_ = false;
|
||||
readiness_ = {};
|
||||
active_game_canonical_json_.clear();
|
||||
presence_request_.reset();
|
||||
playing_presence_published_ = false;
|
||||
controlled_switch_waiting_for_return_ = false;
|
||||
lua_app_surface_recreation_pending_ = false;
|
||||
pending_launch_requests_.clear();
|
||||
@@ -1894,6 +1941,8 @@ Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
|
||||
}
|
||||
readiness_ = {};
|
||||
active_game_canonical_json_.clear();
|
||||
presence_request_.reset();
|
||||
playing_presence_published_ = false;
|
||||
controlled_switch_waiting_for_return_ = false;
|
||||
pending_launch_requests_.clear();
|
||||
next_request_id_ = 1;
|
||||
|
||||
@@ -361,11 +361,13 @@ Status BuildFreshStartGameParams(JNIEnv* env,
|
||||
RobloxFreshGameLaunchController::RobloxFreshGameLaunchController(
|
||||
JniEnvironmentProvider environment, RobloxGameSessionSymbols symbols,
|
||||
RobloxFreshLaunchPresentBoundary present_boundary,
|
||||
RobloxGameSurfaceJniConfig surface_config)
|
||||
RobloxGameSurfaceJniConfig surface_config,
|
||||
RobloxGamePresentedObserver presented_observer)
|
||||
: environment_(environment),
|
||||
symbols_(symbols),
|
||||
present_boundary_(present_boundary),
|
||||
surface_config_(std::move(surface_config)) {}
|
||||
surface_config_(std::move(surface_config)),
|
||||
presented_observer_(presented_observer) {}
|
||||
|
||||
RobloxFreshGameLaunchController::~RobloxFreshGameLaunchController() {
|
||||
Shutdown();
|
||||
@@ -402,8 +404,8 @@ Status RobloxFreshGameLaunchController::Launch(
|
||||
return status;
|
||||
}
|
||||
|
||||
runtime_ = std::make_unique<RobloxGameSessionRuntime>(environment_, symbols_,
|
||||
surface_config_);
|
||||
runtime_ = std::make_unique<RobloxGameSessionRuntime>(
|
||||
environment_, symbols_, surface_config_, presented_observer_);
|
||||
observer_registered_ = present_boundary_.register_observer(
|
||||
present_boundary_.context,
|
||||
&RobloxGameSessionRuntime::SuccessfulPresentCallback, runtime_.get());
|
||||
|
||||
@@ -796,9 +796,11 @@ void RobloxGameSessionNativeAdapter::ReleaseGlobalReferenceLocked(
|
||||
|
||||
RobloxGameSessionRuntime::RobloxGameSessionRuntime(
|
||||
JniEnvironmentProvider environment, RobloxGameSessionSymbols symbols,
|
||||
RobloxGameSurfaceJniConfig surface_config)
|
||||
RobloxGameSurfaceJniConfig surface_config,
|
||||
RobloxGamePresentedObserver presented_observer)
|
||||
: adapter_(environment, symbols, std::move(surface_config)),
|
||||
coordinator_(adapter_.Capabilities()) {}
|
||||
coordinator_(adapter_.Capabilities()),
|
||||
presented_observer_(presented_observer) {}
|
||||
|
||||
Status RobloxGameSessionRuntime::InitializeAndStart(
|
||||
const RobloxGameSessionBinding& binding, GameSessionPrincipal principal,
|
||||
@@ -910,14 +912,16 @@ void RobloxGameSessionRuntime::SuccessfulPresentCallback(
|
||||
if (runtime->present_evidence_recorded_.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
const GameSessionUpdateResult result =
|
||||
runtime->RecordPresent(frame_serial);
|
||||
if (result.ok()) {
|
||||
const GameSessionUpdateResult result = runtime->RecordPresent(frame_serial);
|
||||
if (result.changed()) {
|
||||
runtime->present_evidence_recorded_.store(true, std::memory_order_release);
|
||||
}
|
||||
if (result.changed() && runtime->presented_observer_.valid()) {
|
||||
runtime->presented_observer_.notify(runtime->presented_observer_.context,
|
||||
frame_serial);
|
||||
}
|
||||
if (result.changed()) {
|
||||
std::fprintf(stderr,
|
||||
" [game-session] host present accepted frame=%llu\n",
|
||||
std::fprintf(stderr, " [game-session] host present accepted frame=%llu\n",
|
||||
static_cast<unsigned long long>(frame_serial));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,7 @@ namespace runtime {
|
||||
|
||||
class RobloxWindowInputRuntime;
|
||||
|
||||
// Narrow seam between the cross-thread JNI queue and its main-thread owners.
|
||||
// Production uses RobloxWindowInputRuntime and the SDL window module. Tests
|
||||
// provide a deterministic implementation without creating an SDL window.
|
||||
// Testable boundary between the JNI queue and its main-thread owners.
|
||||
class RobloxTextInputJniBridgeBackend {
|
||||
public:
|
||||
virtual ~RobloxTextInputJniBridgeBackend() = default;
|
||||
@@ -59,19 +57,15 @@ class RobloxTextInputJniBridgeBackend {
|
||||
virtual bool RequestHideTextInput(uint64_t generation) = 0;
|
||||
};
|
||||
|
||||
// Connects pseudo-JVM keyboard callbacks to the input runtime without
|
||||
// invoking SDL or Roblox native functions from an engine callback thread.
|
||||
// Guest callbacks enqueue owned commands; the registered window pre-pump
|
||||
// callback drains them on SDL's main thread.
|
||||
// Guest callbacks enqueue commands that the SDL pre-pump drains on its main
|
||||
// thread.
|
||||
class RobloxTextInputJniBridge final {
|
||||
public:
|
||||
static Status Create(jnivm::VM* vm,
|
||||
std::shared_ptr<RobloxWindowInputRuntime> input_runtime,
|
||||
std::unique_ptr<RobloxTextInputJniBridge>* bridge);
|
||||
|
||||
// Deterministic construction seam. The backend is retained through every
|
||||
// in-flight VM callback and must obey the same main-thread contract as the
|
||||
// production window implementation.
|
||||
// The test backend remains alive through in-flight VM callbacks.
|
||||
static Status CreateForTesting(
|
||||
jnivm::VM* vm, std::shared_ptr<RobloxTextInputJniBridgeBackend> backend,
|
||||
std::unique_ptr<RobloxTextInputJniBridge>* bridge);
|
||||
@@ -81,8 +75,7 @@ class RobloxTextInputJniBridge final {
|
||||
RobloxTextInputJniBridge(const RobloxTextInputJniBridge&) = delete;
|
||||
RobloxTextInputJniBridge& operator=(const RobloxTextInputJniBridge&) = delete;
|
||||
|
||||
// Idempotently stops callbacks and clears pending sensitive text before the
|
||||
// input runtime and SDL window are destroyed.
|
||||
// Stops callbacks and clears pending sensitive text before window teardown.
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
#include <cstdlib>
|
||||
#include <optional>
|
||||
|
||||
#ifndef MOCKTAIL_DISCORD_APPLICATION_ID
|
||||
#define MOCKTAIL_DISCORD_APPLICATION_ID ""
|
||||
#endif
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
@@ -41,6 +45,37 @@ bool LegacyEnabled(const Environment& environment, std::string_view name) {
|
||||
return value.has_value() && !value->empty() && *value != "0";
|
||||
}
|
||||
|
||||
bool IsDiscordApplicationId(std::string_view value) {
|
||||
return value.empty() ||
|
||||
(value.size() >= 17 && value.size() <= 20 &&
|
||||
std::all_of(value.begin(), value.end(), [](unsigned char byte) {
|
||||
return byte >= '0' && byte <= '9';
|
||||
}));
|
||||
}
|
||||
|
||||
bool IsDiscordText(std::string_view value, std::size_t maximum) {
|
||||
return !value.empty() && value.size() <= maximum &&
|
||||
std::none_of(value.begin(), value.end(), [](unsigned char byte) {
|
||||
return byte < 0x20 || byte == 0x7f;
|
||||
});
|
||||
}
|
||||
|
||||
bool ReadBoolean(const Environment& environment, std::string_view name,
|
||||
bool default_value, bool* valid) {
|
||||
const std::optional<std::string> value = environment.Get(name);
|
||||
if (!value.has_value()) {
|
||||
return default_value;
|
||||
}
|
||||
if (*value == "1" || *value == "true" || *value == "on") {
|
||||
return true;
|
||||
}
|
||||
if (*value == "0" || *value == "false" || *value == "off") {
|
||||
return false;
|
||||
}
|
||||
*valid = false;
|
||||
return default_value;
|
||||
}
|
||||
|
||||
std::optional<bool> InputEnabled(const Environment& environment,
|
||||
std::string_view name) {
|
||||
const std::optional<std::string> value = environment.Get(name);
|
||||
@@ -241,6 +276,47 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) {
|
||||
config.audio_output_device_valid_ =
|
||||
IsValidDeviceProfileValue(config.audio_output_device_, 512);
|
||||
config.network_proxy_ = ReadNetworkProxy(environment);
|
||||
bool discord_booleans_valid = true;
|
||||
config.discord_rpc_.enabled = ReadBoolean(
|
||||
environment, "MOCKTAIL_DISCORD_RPC_ENABLED", false,
|
||||
&discord_booleans_valid);
|
||||
config.discord_rpc_.show_place_name = ReadBoolean(
|
||||
environment, "MOCKTAIL_DISCORD_RPC_SHOW_PLACE_NAME", true,
|
||||
&discord_booleans_valid);
|
||||
config.discord_rpc_.show_elapsed_time = ReadBoolean(
|
||||
environment, "MOCKTAIL_DISCORD_RPC_SHOW_ELAPSED_TIME", true,
|
||||
&discord_booleans_valid);
|
||||
config.discord_rpc_.join_enabled = ReadBoolean(
|
||||
environment, "MOCKTAIL_DISCORD_RPC_JOIN_ENABLED", true,
|
||||
&discord_booleans_valid);
|
||||
config.discord_rpc_.public_servers_only = ReadBoolean(
|
||||
environment, "MOCKTAIL_DISCORD_RPC_PUBLIC_SERVERS_ONLY", true,
|
||||
&discord_booleans_valid);
|
||||
config.discord_rpc_.join_button_label = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_RPC_JOIN_BUTTON_LABEL",
|
||||
config.discord_rpc_.join_button_label);
|
||||
config.discord_rpc_.application_id = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_APPLICATION_ID", MOCKTAIL_DISCORD_APPLICATION_ID);
|
||||
config.discord_rpc_.text.browsing = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_BROWSING",
|
||||
config.discord_rpc_.text.browsing);
|
||||
config.discord_rpc_.text.joining = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_JOINING", config.discord_rpc_.text.joining);
|
||||
config.discord_rpc_.text.playing = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_PLAYING", config.discord_rpc_.text.playing);
|
||||
config.discord_rpc_.text.state = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_STATE", config.discord_rpc_.text.state);
|
||||
config.discord_rpc_.text.unknown_place = environment.GetOr(
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_UNKNOWN_PLACE",
|
||||
config.discord_rpc_.text.unknown_place);
|
||||
config.discord_rpc_valid_ = discord_booleans_valid &&
|
||||
IsDiscordApplicationId(config.discord_rpc_.application_id) &&
|
||||
IsDiscordText(config.discord_rpc_.join_button_label, 32) &&
|
||||
IsDiscordText(config.discord_rpc_.text.browsing, 128) &&
|
||||
IsDiscordText(config.discord_rpc_.text.joining, 128) &&
|
||||
IsDiscordText(config.discord_rpc_.text.playing, 128) &&
|
||||
IsDiscordText(config.discord_rpc_.text.state, 128) &&
|
||||
IsDiscordText(config.discord_rpc_.text.unknown_place, 128);
|
||||
for (const std::string_view name : kUnsafeDetachedThreadOverrides) {
|
||||
if (LegacyEnabled(environment, name)) {
|
||||
config.unsafe_detached_thread_overrides_.emplace_back(name);
|
||||
|
||||
@@ -96,6 +96,32 @@ audio:
|
||||
# To pin output, copy an exact SDL device name printed during startup.
|
||||
output_device: default
|
||||
|
||||
integrations:
|
||||
discord_rpc:
|
||||
# Boolean (default: false): publish Mocktail activity to Discord Desktop.
|
||||
# This never signs in to Discord and never reads an account token.
|
||||
enabled: false
|
||||
# Boolean (default: true): show the current Roblox experience name.
|
||||
show_place_name: true
|
||||
# Boolean (default: true): show how long the current session has run.
|
||||
show_elapsed_time: true
|
||||
join:
|
||||
# Boolean (default: true): let friends open the current experience. When
|
||||
# Roblox provides a public server ID, the button targets that server.
|
||||
enabled: true
|
||||
# Boolean (default: true): never expose private or reserved joins.
|
||||
public_servers_only: true
|
||||
# button_label: Join Server
|
||||
# English defaults. Uncomment only the lines you want to customize.
|
||||
# text:
|
||||
# browsing: Browsing experiences
|
||||
# joining: Joining an experience
|
||||
# playing: "{place_name}"
|
||||
# state: Playing Roblox
|
||||
# unknown_place: Unknown experience
|
||||
# Advanced: overrides the application bundled by the package maintainer.
|
||||
# application_id: 123456789012345678
|
||||
|
||||
window:
|
||||
# Integer (default: 1280): initial window width in pixels.
|
||||
width: 1280
|
||||
@@ -118,6 +144,11 @@ updates:
|
||||
# backend, and promote only on success. An existing current payload is
|
||||
# preserved when probation fails.
|
||||
automatic: true
|
||||
# Boolean (default: true): testing track. Always try provider latest, even
|
||||
# when it has no verified payload profile, and do not download or activate
|
||||
# an older supported payload in its place. Exact HostAbi derivation and two
|
||||
# canaries are still required before latest can become current.
|
||||
testing_latest_only: true
|
||||
# String (default: apk-pure): direct x86_64 APK provider.
|
||||
source: apk-pure
|
||||
# Reserved for desktop update integrations. `mocktail_updater` itself never
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include <unistd.h>
|
||||
#include <yaml.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
@@ -97,6 +98,45 @@ bool ReadMapping(yaml_document_t* document, const yaml_node_t* mapping,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadNestedMapping(yaml_document_t* document, const yaml_node_t* mapping,
|
||||
const std::string& section, int remaining_depth,
|
||||
ValueMap* values, std::string* error) {
|
||||
if (mapping == nullptr || mapping->type != YAML_MAPPING_NODE) {
|
||||
*error = section + " must be a mapping";
|
||||
return false;
|
||||
}
|
||||
std::unordered_set<std::string> keys;
|
||||
for (yaml_node_pair_t* pair = mapping->data.mapping.pairs.start;
|
||||
pair != mapping->data.mapping.pairs.top; ++pair) {
|
||||
const yaml_node_t* key_node = yaml_document_get_node(document, pair->key);
|
||||
const yaml_node_t* value_node =
|
||||
yaml_document_get_node(document, pair->value);
|
||||
const std::string key = Scalar(key_node);
|
||||
if (key.empty() || value_node == nullptr) {
|
||||
*error = section + " contains a non-scalar or empty key";
|
||||
return false;
|
||||
}
|
||||
if (!keys.insert(key).second) {
|
||||
*error = section + " contains duplicate key: " + key;
|
||||
return false;
|
||||
}
|
||||
const std::string path = section + "." + key;
|
||||
if (value_node->type == YAML_SCALAR_NODE) {
|
||||
(*values)[path] = Scalar(value_node);
|
||||
continue;
|
||||
}
|
||||
if (value_node->type != YAML_MAPPING_NODE || remaining_depth <= 0) {
|
||||
*error = path + " must be a scalar";
|
||||
return false;
|
||||
}
|
||||
if (!ReadNestedMapping(document, value_node, path, remaining_depth - 1,
|
||||
values, error)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParseBoolean(std::string_view value, bool* parsed) {
|
||||
if (value == "true") {
|
||||
*parsed = true;
|
||||
@@ -157,6 +197,18 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
|
||||
"compatibility.desktop_playability",
|
||||
"network.proxy_host",
|
||||
"network.proxy_port",
|
||||
"integrations.discord_rpc.enabled",
|
||||
"integrations.discord_rpc.show_place_name",
|
||||
"integrations.discord_rpc.show_elapsed_time",
|
||||
"integrations.discord_rpc.application_id",
|
||||
"integrations.discord_rpc.join.enabled",
|
||||
"integrations.discord_rpc.join.public_servers_only",
|
||||
"integrations.discord_rpc.join.button_label",
|
||||
"integrations.discord_rpc.text.browsing",
|
||||
"integrations.discord_rpc.text.joining",
|
||||
"integrations.discord_rpc.text.playing",
|
||||
"integrations.discord_rpc.text.state",
|
||||
"integrations.discord_rpc.text.unknown_place",
|
||||
};
|
||||
for (const auto& [key, ignored] : yaml) {
|
||||
if (supported.find(key) == supported.end()) {
|
||||
@@ -387,6 +439,78 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
|
||||
(*environment)["MOCKTAIL_HTTP_PROXY_HOST"] = *proxy_host;
|
||||
(*environment)["MOCKTAIL_HTTP_PROXY_PORT"] = *proxy_port;
|
||||
}
|
||||
for (const auto& [key, variable] : {
|
||||
std::pair<std::string_view, std::string_view>(
|
||||
"integrations.discord_rpc.enabled",
|
||||
"MOCKTAIL_DISCORD_RPC_ENABLED"),
|
||||
{"integrations.discord_rpc.show_place_name",
|
||||
"MOCKTAIL_DISCORD_RPC_SHOW_PLACE_NAME"},
|
||||
{"integrations.discord_rpc.show_elapsed_time",
|
||||
"MOCKTAIL_DISCORD_RPC_SHOW_ELAPSED_TIME"},
|
||||
{"integrations.discord_rpc.join.enabled",
|
||||
"MOCKTAIL_DISCORD_RPC_JOIN_ENABLED"},
|
||||
{"integrations.discord_rpc.join.public_servers_only",
|
||||
"MOCKTAIL_DISCORD_RPC_PUBLIC_SERVERS_ONLY"},
|
||||
}) {
|
||||
const std::optional<std::string> configured = value(key);
|
||||
if (!configured.has_value()) {
|
||||
continue;
|
||||
}
|
||||
bool parsed = false;
|
||||
if (!ParseBoolean(*configured, &parsed)) {
|
||||
*error = std::string(key) + " must be true or false";
|
||||
return false;
|
||||
}
|
||||
(*environment)[std::string(variable)] = parsed ? "1" : "0";
|
||||
}
|
||||
if (const auto application_id =
|
||||
value("integrations.discord_rpc.application_id");
|
||||
application_id.has_value()) {
|
||||
if (application_id->size() < 17 || application_id->size() > 20 ||
|
||||
!std::all_of(application_id->begin(), application_id->end(),
|
||||
[](unsigned char byte) {
|
||||
return byte >= '0' && byte <= '9';
|
||||
})) {
|
||||
*error = "integrations.discord_rpc.application_id must be a Discord "
|
||||
"snowflake";
|
||||
return false;
|
||||
}
|
||||
(*environment)["MOCKTAIL_DISCORD_APPLICATION_ID"] = *application_id;
|
||||
}
|
||||
struct DiscordStringField {
|
||||
std::string_view yaml;
|
||||
std::string_view variable;
|
||||
std::size_t maximum;
|
||||
};
|
||||
for (const DiscordStringField& field : {
|
||||
DiscordStringField{"integrations.discord_rpc.join.button_label",
|
||||
"MOCKTAIL_DISCORD_RPC_JOIN_BUTTON_LABEL", 32},
|
||||
{"integrations.discord_rpc.text.browsing",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_BROWSING", 128},
|
||||
{"integrations.discord_rpc.text.joining",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_JOINING", 128},
|
||||
{"integrations.discord_rpc.text.playing",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_PLAYING", 128},
|
||||
{"integrations.discord_rpc.text.state",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_STATE", 128},
|
||||
{"integrations.discord_rpc.text.unknown_place",
|
||||
"MOCKTAIL_DISCORD_RPC_TEXT_UNKNOWN_PLACE", 128},
|
||||
}) {
|
||||
const std::optional<std::string> configured = value(field.yaml);
|
||||
if (!configured.has_value()) {
|
||||
continue;
|
||||
}
|
||||
if (configured->empty() || configured->size() > field.maximum ||
|
||||
std::any_of(configured->begin(), configured->end(),
|
||||
[](unsigned char byte) {
|
||||
return byte < 0x20 || byte == 0x7f;
|
||||
})) {
|
||||
*error = std::string(field.yaml) +
|
||||
" must be non-empty, bounded, and contain no control bytes";
|
||||
return false;
|
||||
}
|
||||
(*environment)[std::string(field.variable)] = *configured;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -496,6 +620,8 @@ bool LoadYaml(const std::filesystem::path& path, ValueMap* values, bool* loaded,
|
||||
key == "performance" || key == "audio" || key == "window" ||
|
||||
key == "input" || key == "compatibility" || key == "network") {
|
||||
valid = ReadMapping(&document, value_node, key, values, error);
|
||||
} else if (key == "integrations") {
|
||||
valid = ReadNestedMapping(&document, value_node, key, 2, values, error);
|
||||
}
|
||||
// Other top-level sections are owned by subsystems such as the
|
||||
// updater. This loader ignores rather than misparses them.
|
||||
@@ -575,6 +701,8 @@ RuntimeConfigLoadResult LoadRuntimeConfig(
|
||||
result.error = "GameMode policy is invalid";
|
||||
} else if (!result.config.performance().physics_worker_mode_valid) {
|
||||
result.error = "physics worker policy is invalid";
|
||||
} else if (!result.config.discord_rpc_valid()) {
|
||||
result.error = "Discord Rich Presence configuration is invalid";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -611,6 +739,12 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config,
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!config.discord_rpc_valid()) {
|
||||
if (error != nullptr) {
|
||||
*error = "cannot export an invalid Discord Rich Presence policy";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const DeviceProfile& device = config.device_profile();
|
||||
const bool base_exported =
|
||||
SetEnvironmentValue("MOCKTAIL_HEADLESS", config.headless() ? "1" : "0",
|
||||
@@ -673,7 +807,35 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config,
|
||||
GameModePolicyName(config.performance().game_mode),
|
||||
error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_AUDIO_OUTPUT_DEVICE",
|
||||
config.audio_output_device(), error);
|
||||
config.audio_output_device(), error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_ENABLED",
|
||||
config.discord_rpc().enabled ? "1" : "0", error) &&
|
||||
SetEnvironmentValue(
|
||||
"MOCKTAIL_DISCORD_RPC_SHOW_PLACE_NAME",
|
||||
config.discord_rpc().show_place_name ? "1" : "0", error) &&
|
||||
SetEnvironmentValue(
|
||||
"MOCKTAIL_DISCORD_RPC_SHOW_ELAPSED_TIME",
|
||||
config.discord_rpc().show_elapsed_time ? "1" : "0", error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_JOIN_ENABLED",
|
||||
config.discord_rpc().join_enabled ? "1" : "0",
|
||||
error) &&
|
||||
SetEnvironmentValue(
|
||||
"MOCKTAIL_DISCORD_RPC_PUBLIC_SERVERS_ONLY",
|
||||
config.discord_rpc().public_servers_only ? "1" : "0", error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_JOIN_BUTTON_LABEL",
|
||||
config.discord_rpc().join_button_label, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_APPLICATION_ID",
|
||||
config.discord_rpc().application_id, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_BROWSING",
|
||||
config.discord_rpc().text.browsing, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_JOINING",
|
||||
config.discord_rpc().text.joining, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_PLAYING",
|
||||
config.discord_rpc().text.playing, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_STATE",
|
||||
config.discord_rpc().text.state, error) &&
|
||||
SetEnvironmentValue("MOCKTAIL_DISCORD_RPC_TEXT_UNKNOWN_PLACE",
|
||||
config.discord_rpc().text.unknown_place, error);
|
||||
if (!base_exported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,732 @@
|
||||
// Copyright 2026 Mocktail Project Authors
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
#include "runtime/session_log.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/utsname.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#ifndef MOCKTAIL_PROJECT_VERSION
|
||||
#define MOCKTAIL_PROJECT_VERSION "unknown"
|
||||
#endif
|
||||
#ifndef MOCKTAIL_BUILD_GIT_COMMIT
|
||||
#define MOCKTAIL_BUILD_GIT_COMMIT "unknown"
|
||||
#endif
|
||||
#ifndef MOCKTAIL_BUILD_GIT_DIRTY
|
||||
#define MOCKTAIL_BUILD_GIT_DIRTY "unknown"
|
||||
#endif
|
||||
#ifndef MOCKTAIL_BUILD_TYPE
|
||||
#define MOCKTAIL_BUILD_TYPE "unknown"
|
||||
#endif
|
||||
#ifndef MOCKTAIL_BUILD_COMPILER_ID
|
||||
#define MOCKTAIL_BUILD_COMPILER_ID "unknown"
|
||||
#endif
|
||||
#ifndef MOCKTAIL_BUILD_COMPILER_VERSION
|
||||
#define MOCKTAIL_BUILD_COMPILER_VERSION "unknown"
|
||||
#endif
|
||||
#ifndef MOCKTAIL_BUILD_TARGET_SYSTEM
|
||||
#define MOCKTAIL_BUILD_TARGET_SYSTEM "unknown"
|
||||
#endif
|
||||
#ifndef MOCKTAIL_BUILD_TARGET_PROCESSOR
|
||||
#define MOCKTAIL_BUILD_TARGET_PROCESSOR "unknown"
|
||||
#endif
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
bool Enabled(const Environment& environment, std::string_view name) {
|
||||
const auto value = environment.Get(name);
|
||||
return value.has_value() && !value->empty() && *value != "0";
|
||||
}
|
||||
|
||||
std::string LocalTimestamp(std::chrono::system_clock::time_point timestamp,
|
||||
std::string_view format) {
|
||||
const std::time_t raw = std::chrono::system_clock::to_time_t(timestamp);
|
||||
std::tm local = {};
|
||||
if (localtime_r(&raw, &local) == nullptr) return "unknown";
|
||||
std::array<char, 64> output{};
|
||||
const std::string owned_format(format);
|
||||
if (std::strftime(output.data(), output.size(), owned_format.c_str(),
|
||||
&local) == 0) {
|
||||
return "unknown";
|
||||
}
|
||||
return output.data();
|
||||
}
|
||||
|
||||
std::string IsoLocalTimestamp(std::chrono::system_clock::time_point timestamp) {
|
||||
std::string formatted = LocalTimestamp(timestamp, "%Y-%m-%dT%H:%M:%S%z");
|
||||
if (formatted.size() >= 5) {
|
||||
const std::size_t offset = formatted.size() - 5;
|
||||
if ((formatted[offset] == '+' || formatted[offset] == '-') &&
|
||||
formatted[offset + 1] >= '0' && formatted[offset + 1] <= '9' &&
|
||||
formatted[offset + 2] >= '0' && formatted[offset + 2] <= '9' &&
|
||||
formatted[offset + 3] >= '0' && formatted[offset + 3] <= '9' &&
|
||||
formatted[offset + 4] >= '0' && formatted[offset + 4] <= '9') {
|
||||
formatted.insert(formatted.size() - 2, ":");
|
||||
}
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
bool EnsurePrivateDirectory(const std::filesystem::path& directory,
|
||||
std::string* error) {
|
||||
std::error_code filesystem_error;
|
||||
std::filesystem::create_directories(directory, filesystem_error);
|
||||
if (filesystem_error) {
|
||||
*error =
|
||||
"cannot create session log directory: " + filesystem_error.message();
|
||||
return false;
|
||||
}
|
||||
const auto status =
|
||||
std::filesystem::symlink_status(directory, filesystem_error);
|
||||
if (filesystem_error || !std::filesystem::is_directory(status) ||
|
||||
std::filesystem::is_symlink(status)) {
|
||||
*error = "session log directory is invalid or a symlink";
|
||||
return false;
|
||||
}
|
||||
std::filesystem::permissions(directory, std::filesystem::perms::owner_all,
|
||||
std::filesystem::perm_options::replace,
|
||||
filesystem_error);
|
||||
if (filesystem_error) {
|
||||
*error = "cannot make session log directory private: " +
|
||||
filesystem_error.message();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int CreateSessionFile(const std::filesystem::path& sessions,
|
||||
std::chrono::system_clock::time_point started_at,
|
||||
std::filesystem::path* path, std::string* error) {
|
||||
const std::string timestamp = LocalTimestamp(started_at, "%Y-%m-%d_%H-%M-%S");
|
||||
for (int collision = 1; collision <= 999; ++collision) {
|
||||
const std::string suffix =
|
||||
collision == 1 ? std::string() : "-" + std::to_string(collision);
|
||||
const std::filesystem::path candidate =
|
||||
sessions / (timestamp + suffix + ".log");
|
||||
const int descriptor =
|
||||
open(candidate.c_str(),
|
||||
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
|
||||
if (descriptor >= 0) {
|
||||
*path = candidate;
|
||||
return descriptor;
|
||||
}
|
||||
if (errno != EEXIST) {
|
||||
*error =
|
||||
"cannot create session log: " + std::string(std::strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
*error = "too many Mocktail sessions started in the same second";
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool UpdateLatestLink(const std::filesystem::path& logs_root,
|
||||
const std::filesystem::path& session,
|
||||
std::string* warning) {
|
||||
const std::filesystem::path latest = logs_root / "latest.log";
|
||||
const std::filesystem::path temporary =
|
||||
logs_root / (".latest.log." + std::to_string(getpid()));
|
||||
const std::filesystem::path target =
|
||||
std::filesystem::path("sessions") / session.filename();
|
||||
(void)unlink(temporary.c_str());
|
||||
if (symlink(target.c_str(), temporary.c_str()) != 0) {
|
||||
if (link(session.c_str(), temporary.c_str()) != 0) {
|
||||
*warning =
|
||||
"cannot update latest.log: " + std::string(std::strerror(errno));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (rename(temporary.c_str(), latest.c_str()) != 0) {
|
||||
*warning =
|
||||
"cannot replace latest.log: " + std::string(std::strerror(errno));
|
||||
(void)unlink(temporary.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WriteAll(int descriptor, const char* bytes, std::size_t size) {
|
||||
std::size_t offset = 0;
|
||||
while (offset < size) {
|
||||
const ssize_t written = write(descriptor, bytes + offset, size - offset);
|
||||
if (written > 0) {
|
||||
offset += static_cast<std::size_t>(written);
|
||||
continue;
|
||||
}
|
||||
if (written < 0 && errno == EINTR) continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void RunTeeProcess(int stdout_reader, int stderr_reader, int log_descriptor,
|
||||
int original_stdout, int original_stderr) {
|
||||
(void)signal(SIGPIPE, SIG_IGN);
|
||||
(void)signal(SIGINT, SIG_IGN);
|
||||
(void)signal(SIGTERM, SIG_IGN);
|
||||
(void)signal(SIGHUP, SIG_IGN);
|
||||
|
||||
struct Source {
|
||||
int reader;
|
||||
int output;
|
||||
bool active;
|
||||
bool output_active;
|
||||
};
|
||||
std::array<Source, 2> sources = {
|
||||
{{stdout_reader, original_stdout, true, true},
|
||||
{stderr_reader, original_stderr, true, true}}};
|
||||
bool log_active = true;
|
||||
std::array<char, 32768> buffer{};
|
||||
while (sources[0].active || sources[1].active) {
|
||||
std::array<pollfd, 2> descriptors = {
|
||||
{{sources[0].reader, POLLIN, 0}, {sources[1].reader, POLLIN, 0}}};
|
||||
if (!sources[0].active) descriptors[0].fd = -1;
|
||||
if (!sources[1].active) descriptors[1].fd = -1;
|
||||
const int poll_result = poll(descriptors.data(), descriptors.size(), -1);
|
||||
if (poll_result < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
for (std::size_t index = 0; index < sources.size(); ++index) {
|
||||
Source& source = sources[index];
|
||||
if (!source.active ||
|
||||
(descriptors[index].revents & (POLLIN | POLLHUP | POLLERR)) == 0) {
|
||||
continue;
|
||||
}
|
||||
const ssize_t bytes = read(source.reader, buffer.data(), buffer.size());
|
||||
if (bytes > 0) {
|
||||
const std::size_t size = static_cast<std::size_t>(bytes);
|
||||
if (log_active && !WriteAll(log_descriptor, buffer.data(), size)) {
|
||||
log_active = false;
|
||||
}
|
||||
if (source.output_active &&
|
||||
!WriteAll(source.output, buffer.data(), size)) {
|
||||
source.output_active = false;
|
||||
}
|
||||
} else if (bytes == 0 || errno != EINTR) {
|
||||
close(source.reader);
|
||||
source.reader = -1;
|
||||
source.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (log_active) (void)fsync(log_descriptor);
|
||||
close(log_descriptor);
|
||||
close(original_stdout);
|
||||
close(original_stderr);
|
||||
}
|
||||
|
||||
std::filesystem::path ExecutablePath() {
|
||||
std::array<char, 4097> path{};
|
||||
const ssize_t size = readlink("/proc/self/exe", path.data(), path.size() - 1);
|
||||
if (size <= 0 || static_cast<std::size_t>(size) >= path.size()) return {};
|
||||
path[static_cast<std::size_t>(size)] = '\0';
|
||||
return path.data();
|
||||
}
|
||||
|
||||
bool IsWithin(const std::filesystem::path& child,
|
||||
const std::filesystem::path& parent) {
|
||||
const auto relative = child.lexically_relative(parent);
|
||||
return !relative.empty() && !relative.is_absolute() &&
|
||||
*relative.begin() != "..";
|
||||
}
|
||||
|
||||
std::string DisplayPath(const std::filesystem::path& path,
|
||||
const RuntimePaths& paths) {
|
||||
if (path.empty()) return "unknown";
|
||||
if (IsWithin(path, paths.home())) {
|
||||
return (std::filesystem::path("~") / path.lexically_relative(paths.home()))
|
||||
.string();
|
||||
}
|
||||
return path.string();
|
||||
}
|
||||
|
||||
std::string PackageKind(const Environment& environment,
|
||||
const std::filesystem::path& executable) {
|
||||
if (environment.HasNonEmpty("FLATPAK_ID")) return "flatpak";
|
||||
if (environment.HasNonEmpty("APPIMAGE")) return "appimage";
|
||||
if (environment.HasNonEmpty("APPDIR")) return "portable";
|
||||
const std::string path = executable.string();
|
||||
if (path.rfind("/usr/", 0) == 0 || path.rfind("/opt/", 0) == 0 ||
|
||||
path.rfind("/app/", 0) == 0) {
|
||||
return "system-install";
|
||||
}
|
||||
return "local-build";
|
||||
}
|
||||
|
||||
std::string DisplayServer(const Environment& environment) {
|
||||
if (environment.HasNonEmpty("WAYLAND_DISPLAY")) return "wayland";
|
||||
if (environment.HasNonEmpty("DISPLAY")) return "x11";
|
||||
return "none";
|
||||
}
|
||||
|
||||
std::string Trim(std::string value) {
|
||||
const auto first = std::find_if_not(value.begin(), value.end(), [](char c) {
|
||||
return std::isspace(static_cast<unsigned char>(c)) != 0;
|
||||
});
|
||||
const auto last = std::find_if_not(value.rbegin(), value.rend(), [](char c) {
|
||||
return std::isspace(static_cast<unsigned char>(c)) != 0;
|
||||
}).base();
|
||||
if (first >= last) return {};
|
||||
value = std::string(first, last);
|
||||
for (char& character : value) {
|
||||
const unsigned char byte = static_cast<unsigned char>(character);
|
||||
if (character == '"' || std::iscntrl(byte) != 0) character = ' ';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::optional<std::string> ReadFirstLine(const std::filesystem::path& path) {
|
||||
std::ifstream input(path);
|
||||
std::string value;
|
||||
if (!std::getline(input, value)) return std::nullopt;
|
||||
value = Trim(std::move(value));
|
||||
return value.empty() ? std::nullopt
|
||||
: std::optional<std::string>(std::move(value));
|
||||
}
|
||||
|
||||
std::optional<std::string> ReadKey(const std::filesystem::path& path,
|
||||
std::string_view key) {
|
||||
std::ifstream input(path);
|
||||
std::string line;
|
||||
while (std::getline(input, line)) {
|
||||
const std::size_t separator = line.find('=');
|
||||
if (separator == std::string::npos ||
|
||||
line.compare(0, separator, key) != 0 || separator != key.size()) {
|
||||
continue;
|
||||
}
|
||||
std::string value = Trim(line.substr(separator + 1));
|
||||
if (!value.empty()) return value;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string CpuModel() {
|
||||
std::ifstream input("/proc/cpuinfo");
|
||||
std::string fallback;
|
||||
std::string line;
|
||||
while (std::getline(input, line)) {
|
||||
const std::size_t separator = line.find(':');
|
||||
if (separator == std::string::npos) continue;
|
||||
const std::string key = Trim(line.substr(0, separator));
|
||||
std::string value = Trim(line.substr(separator + 1));
|
||||
if (value.empty()) continue;
|
||||
if (key == "model name") return value;
|
||||
if (fallback.empty() && (key == "Hardware" || key == "Processor")) {
|
||||
fallback = std::move(value);
|
||||
}
|
||||
}
|
||||
if (!fallback.empty()) return fallback;
|
||||
return ReadFirstLine("/sys/devices/soc0/machine").value_or("unknown");
|
||||
}
|
||||
|
||||
std::string TotalMemory() {
|
||||
std::ifstream input("/proc/meminfo");
|
||||
std::string key;
|
||||
std::uint64_t kibibytes = 0;
|
||||
while (input >> key) {
|
||||
if (key == "MemTotal:") {
|
||||
if (input >> kibibytes) {
|
||||
return std::to_string((kibibytes + 512U) / 1024U) + "MiB";
|
||||
}
|
||||
break;
|
||||
}
|
||||
input.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
std::string HexId(std::optional<std::string> value) {
|
||||
if (!value.has_value()) return {};
|
||||
std::string result = Trim(std::move(*value));
|
||||
if (result.size() > 2 && result[0] == '0' &&
|
||||
(result[1] == 'x' || result[1] == 'X')) {
|
||||
result.erase(0, 2);
|
||||
}
|
||||
std::transform(result.begin(), result.end(), result.begin(), [](char c) {
|
||||
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
});
|
||||
if (result.size() != 4 ||
|
||||
!std::all_of(result.begin(), result.end(), [](char c) {
|
||||
return std::isxdigit(static_cast<unsigned char>(c)) != 0;
|
||||
})) {
|
||||
return {};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
struct PciName {
|
||||
std::string vendor;
|
||||
std::string device;
|
||||
};
|
||||
|
||||
PciName FindPciName(std::string_view vendor_id, std::string_view device_id) {
|
||||
constexpr std::array<std::string_view, 3> kDatabases = {
|
||||
"/usr/share/hwdata/pci.ids",
|
||||
"/usr/share/misc/pci.ids",
|
||||
"/usr/share/pci.ids",
|
||||
};
|
||||
for (const std::string_view database : kDatabases) {
|
||||
std::ifstream input{std::string(database)};
|
||||
if (!input) continue;
|
||||
PciName result;
|
||||
bool matching_vendor = false;
|
||||
std::string line;
|
||||
while (std::getline(input, line)) {
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
if (line[0] != '\t') {
|
||||
if (matching_vendor) break;
|
||||
if (line.size() >= 6 && line.substr(0, 4) == vendor_id &&
|
||||
line[4] == ' ' && line[5] == ' ') {
|
||||
matching_vendor = true;
|
||||
result.vendor = Trim(line.substr(6));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!matching_vendor || line.size() < 7 || line[1] == '\t') continue;
|
||||
if (line.substr(1, 4) == device_id && line[5] == ' ' && line[6] == ' ') {
|
||||
result.device = Trim(line.substr(7));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
if (!result.vendor.empty()) return result;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string PciVendorFallback(std::string_view vendor_id) {
|
||||
if (vendor_id == "1002") return "AMD";
|
||||
if (vendor_id == "10de") return "NVIDIA";
|
||||
if (vendor_id == "8086") return "Intel";
|
||||
if (vendor_id == "13b5") return "Arm";
|
||||
if (vendor_id == "5143") return "Qualcomm";
|
||||
if (vendor_id == "14e4") return "Broadcom";
|
||||
if (vendor_id == "1af4") return "Virtio";
|
||||
if (vendor_id == "15ad") return "VMware";
|
||||
return "PCI " + std::string(vendor_id);
|
||||
}
|
||||
|
||||
bool DeviceNodeName(std::string_view name, std::string_view prefix) {
|
||||
if (name.size() <= prefix.size() || name.substr(0, prefix.size()) != prefix) {
|
||||
return false;
|
||||
}
|
||||
return std::all_of(
|
||||
name.begin() + static_cast<std::ptrdiff_t>(prefix.size()), name.end(),
|
||||
[](char character) {
|
||||
return std::isdigit(static_cast<unsigned char>(character)) != 0;
|
||||
});
|
||||
}
|
||||
|
||||
struct GraphicsHardware {
|
||||
std::string devices = "unknown";
|
||||
std::string drivers = "unknown";
|
||||
};
|
||||
|
||||
GraphicsHardware DetectGraphicsHardware() {
|
||||
std::error_code error;
|
||||
std::vector<std::filesystem::path> render_nodes;
|
||||
std::vector<std::filesystem::path> card_nodes;
|
||||
for (std::filesystem::directory_iterator iterator("/sys/class/drm", error),
|
||||
end;
|
||||
!error && iterator != end; iterator.increment(error)) {
|
||||
const std::string name = iterator->path().filename().string();
|
||||
if (DeviceNodeName(name, "renderD")) {
|
||||
render_nodes.push_back(iterator->path());
|
||||
} else if (DeviceNodeName(name, "card")) {
|
||||
card_nodes.push_back(iterator->path());
|
||||
}
|
||||
}
|
||||
std::vector<std::filesystem::path>& nodes =
|
||||
render_nodes.empty() ? card_nodes : render_nodes;
|
||||
std::sort(nodes.begin(), nodes.end());
|
||||
|
||||
std::vector<std::string> seen;
|
||||
std::vector<std::string> names;
|
||||
std::vector<std::string> drivers;
|
||||
for (const auto& node : nodes) {
|
||||
error.clear();
|
||||
const std::filesystem::path device =
|
||||
std::filesystem::canonical(node / "device", error);
|
||||
if (error) continue;
|
||||
const std::string identity = device.string();
|
||||
if (std::find(seen.begin(), seen.end(), identity) != seen.end()) continue;
|
||||
seen.push_back(identity);
|
||||
|
||||
error.clear();
|
||||
std::string driver = std::filesystem::read_symlink(device / "driver", error)
|
||||
.filename()
|
||||
.string();
|
||||
if (driver.empty()) {
|
||||
driver = ReadKey(device / "uevent", "DRIVER").value_or("unknown");
|
||||
}
|
||||
const std::string vendor_id = HexId(ReadFirstLine(device / "vendor"));
|
||||
const std::string device_id = HexId(ReadFirstLine(device / "device"));
|
||||
std::string name =
|
||||
ReadFirstLine(device / "product_name").value_or(std::string());
|
||||
if (name.empty() && !vendor_id.empty() && !device_id.empty()) {
|
||||
const PciName pci = FindPciName(vendor_id, device_id);
|
||||
const std::string vendor =
|
||||
pci.vendor.empty() ? PciVendorFallback(vendor_id) : pci.vendor;
|
||||
name = vendor +
|
||||
(pci.device.empty() ? " device " + device_id : " " + pci.device);
|
||||
}
|
||||
if (name.empty()) name = driver;
|
||||
names.push_back(Trim(std::move(name)));
|
||||
drivers.push_back(Trim(std::move(driver)));
|
||||
}
|
||||
|
||||
if (names.empty()) return {};
|
||||
GraphicsHardware result;
|
||||
result.devices.clear();
|
||||
result.drivers.clear();
|
||||
for (std::size_t index = 0; index < names.size(); ++index) {
|
||||
if (index != 0) {
|
||||
result.devices += "; ";
|
||||
result.drivers += "; ";
|
||||
}
|
||||
result.devices += names[index];
|
||||
result.drivers += drivers[index];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SessionBuildInformation CurrentSessionBuildInformation() {
|
||||
SessionBuildInformation information;
|
||||
information.version = MOCKTAIL_PROJECT_VERSION;
|
||||
information.git_commit = MOCKTAIL_BUILD_GIT_COMMIT;
|
||||
information.git_dirty = MOCKTAIL_BUILD_GIT_DIRTY;
|
||||
information.build_type = MOCKTAIL_BUILD_TYPE;
|
||||
information.compiler = std::string(MOCKTAIL_BUILD_COMPILER_ID) + " " +
|
||||
MOCKTAIL_BUILD_COMPILER_VERSION;
|
||||
information.target = std::string(MOCKTAIL_BUILD_TARGET_SYSTEM) + "/" +
|
||||
MOCKTAIL_BUILD_TARGET_PROCESSOR;
|
||||
return information;
|
||||
}
|
||||
|
||||
SessionLog::~SessionLog() { Stop(); }
|
||||
|
||||
SessionLog::SessionLog(SessionLog&& other) noexcept {
|
||||
*this = std::move(other);
|
||||
}
|
||||
|
||||
SessionLog& SessionLog::operator=(SessionLog&& other) noexcept {
|
||||
if (this == &other) return *this;
|
||||
Stop();
|
||||
attempted_ = other.attempted_;
|
||||
active_ = other.active_;
|
||||
original_stdout_ = other.original_stdout_;
|
||||
original_stderr_ = other.original_stderr_;
|
||||
logger_process_ = other.logger_process_;
|
||||
started_at_ = other.started_at_;
|
||||
path_ = std::move(other.path_);
|
||||
latest_path_ = std::move(other.latest_path_);
|
||||
error_ = std::move(other.error_);
|
||||
warning_ = std::move(other.warning_);
|
||||
other.attempted_ = false;
|
||||
other.active_ = false;
|
||||
other.original_stdout_ = -1;
|
||||
other.original_stderr_ = -1;
|
||||
other.logger_process_ = -1;
|
||||
return *this;
|
||||
}
|
||||
|
||||
SessionLog SessionLog::Start(const Environment& environment,
|
||||
const RuntimePaths& paths,
|
||||
std::chrono::system_clock::time_point started_at) {
|
||||
SessionLog result;
|
||||
result.started_at_ = started_at;
|
||||
if (Enabled(environment, "MOCKTAIL_DISABLE_SESSION_LOG") ||
|
||||
Enabled(environment, "MOCKTAIL_ISOLATED_CANARY")) {
|
||||
return result;
|
||||
}
|
||||
result.attempted_ = true;
|
||||
std::cout.flush();
|
||||
std::cerr.flush();
|
||||
(void)fflush(nullptr);
|
||||
|
||||
const std::filesystem::path sessions = paths.logs_root() / "sessions";
|
||||
if (!EnsurePrivateDirectory(paths.logs_root(), &result.error_) ||
|
||||
!EnsurePrivateDirectory(sessions, &result.error_)) {
|
||||
return result;
|
||||
}
|
||||
const int log_descriptor =
|
||||
CreateSessionFile(sessions, started_at, &result.path_, &result.error_);
|
||||
if (log_descriptor < 0) return result;
|
||||
result.latest_path_ = paths.logs_root() / "latest.log";
|
||||
|
||||
std::array<int, 2> stdout_pipe = {-1, -1};
|
||||
std::array<int, 2> stderr_pipe = {-1, -1};
|
||||
result.original_stdout_ = fcntl(STDOUT_FILENO, F_DUPFD_CLOEXEC, 3);
|
||||
result.original_stderr_ = fcntl(STDERR_FILENO, F_DUPFD_CLOEXEC, 3);
|
||||
if (result.original_stdout_ < 0 || result.original_stderr_ < 0 ||
|
||||
pipe2(stdout_pipe.data(), O_CLOEXEC) != 0 ||
|
||||
pipe2(stderr_pipe.data(), O_CLOEXEC) != 0) {
|
||||
result.error_ = "cannot prepare session log streams: " +
|
||||
std::string(std::strerror(errno));
|
||||
if (stdout_pipe[0] >= 0) close(stdout_pipe[0]);
|
||||
if (stdout_pipe[1] >= 0) close(stdout_pipe[1]);
|
||||
if (stderr_pipe[0] >= 0) close(stderr_pipe[0]);
|
||||
if (stderr_pipe[1] >= 0) close(stderr_pipe[1]);
|
||||
if (result.original_stdout_ >= 0) close(result.original_stdout_);
|
||||
if (result.original_stderr_ >= 0) close(result.original_stderr_);
|
||||
result.original_stdout_ = -1;
|
||||
result.original_stderr_ = -1;
|
||||
close(log_descriptor);
|
||||
(void)unlink(result.path_.c_str());
|
||||
return result;
|
||||
}
|
||||
|
||||
const pid_t child = fork();
|
||||
if (child < 0) {
|
||||
result.error_ =
|
||||
"cannot start session log writer: " + std::string(std::strerror(errno));
|
||||
close(stdout_pipe[0]);
|
||||
close(stdout_pipe[1]);
|
||||
close(stderr_pipe[0]);
|
||||
close(stderr_pipe[1]);
|
||||
close(result.original_stdout_);
|
||||
close(result.original_stderr_);
|
||||
result.original_stdout_ = -1;
|
||||
result.original_stderr_ = -1;
|
||||
close(log_descriptor);
|
||||
(void)unlink(result.path_.c_str());
|
||||
return result;
|
||||
}
|
||||
if (child == 0) {
|
||||
close(stdout_pipe[1]);
|
||||
close(stderr_pipe[1]);
|
||||
RunTeeProcess(stdout_pipe[0], stderr_pipe[0], log_descriptor,
|
||||
result.original_stdout_, result.original_stderr_);
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
close(stdout_pipe[0]);
|
||||
close(stderr_pipe[0]);
|
||||
close(log_descriptor);
|
||||
int redirect_error = 0;
|
||||
const bool stdout_redirected =
|
||||
dup2(stdout_pipe[1], STDOUT_FILENO) == STDOUT_FILENO;
|
||||
if (!stdout_redirected) redirect_error = errno;
|
||||
const bool stderr_redirected =
|
||||
dup2(stderr_pipe[1], STDERR_FILENO) == STDERR_FILENO;
|
||||
if (!stderr_redirected && redirect_error == 0) redirect_error = errno;
|
||||
close(stdout_pipe[1]);
|
||||
close(stderr_pipe[1]);
|
||||
if (!stdout_redirected || !stderr_redirected) {
|
||||
if (stdout_redirected) {
|
||||
(void)dup2(result.original_stdout_, STDOUT_FILENO);
|
||||
}
|
||||
if (stderr_redirected) {
|
||||
(void)dup2(result.original_stderr_, STDERR_FILENO);
|
||||
}
|
||||
close(result.original_stdout_);
|
||||
close(result.original_stderr_);
|
||||
result.original_stdout_ = -1;
|
||||
result.original_stderr_ = -1;
|
||||
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
result.error_ = "cannot activate session log streams: " +
|
||||
std::string(std::strerror(redirect_error));
|
||||
(void)unlink(result.path_.c_str());
|
||||
return result;
|
||||
}
|
||||
(void)setvbuf(stdout, nullptr, _IOLBF, BUFSIZ);
|
||||
|
||||
result.logger_process_ = static_cast<int>(child);
|
||||
result.active_ = true;
|
||||
(void)UpdateLatestLink(paths.logs_root(), result.path_, &result.warning_);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string SessionLog::Header(const Environment& environment,
|
||||
const RuntimePaths& paths,
|
||||
std::string_view graphics_backend) const {
|
||||
const SessionBuildInformation build = CurrentSessionBuildInformation();
|
||||
const std::filesystem::path executable = ExecutablePath();
|
||||
struct utsname system = {};
|
||||
const bool has_system = uname(&system) == 0;
|
||||
const GraphicsHardware graphics = DetectGraphicsHardware();
|
||||
const unsigned int threads = std::thread::hardware_concurrency();
|
||||
|
||||
std::ostringstream output;
|
||||
output << "[mocktail] version=" << build.version
|
||||
<< " commit=" << build.git_commit;
|
||||
if (build.git_dirty == "true") {
|
||||
output << " dirty=true";
|
||||
} else if (build.git_dirty == "unknown") {
|
||||
output << " dirty=unknown";
|
||||
}
|
||||
output << " build=" << build.build_type << " compiler=\"" << build.compiler
|
||||
<< "\"\n"
|
||||
<< "[mocktail] started=" << IsoLocalTimestamp(started_at_)
|
||||
<< " package=" << PackageKind(environment, executable)
|
||||
<< " target=" << build.target;
|
||||
if (has_system) {
|
||||
output << " host=\"" << system.sysname << ' ' << system.release << ' '
|
||||
<< system.machine << '"';
|
||||
}
|
||||
output << " pid=" << getpid() << '\n'
|
||||
<< "[mocktail] cpu=\"" << CpuModel() << "\" threads=";
|
||||
if (threads == 0) {
|
||||
output << "unknown";
|
||||
} else {
|
||||
output << threads;
|
||||
}
|
||||
output << " ram=" << TotalMemory() << " gpu=\"" << graphics.devices
|
||||
<< "\" gpu_driver=\"" << graphics.drivers
|
||||
<< "\" display=" << DisplayServer(environment) << " graphics="
|
||||
<< (graphics_backend.empty() ? "unknown" : graphics_backend) << '\n'
|
||||
<< "[mocktail] executable=" << DisplayPath(executable, paths)
|
||||
<< " config=" << DisplayPath(paths.config_file(), paths) << '\n'
|
||||
<< "[mocktail] log=" << DisplayPath(path_, paths) << '\n';
|
||||
if (!warning_.empty()) output << "[mocktail] warning=" << warning_ << '\n';
|
||||
return output.str();
|
||||
}
|
||||
|
||||
void SessionLog::Stop() {
|
||||
if (!active_) return;
|
||||
std::cout.flush();
|
||||
std::cerr.flush();
|
||||
(void)fflush(nullptr);
|
||||
(void)dup2(original_stdout_, STDOUT_FILENO);
|
||||
(void)dup2(original_stderr_, STDERR_FILENO);
|
||||
close(original_stdout_);
|
||||
close(original_stderr_);
|
||||
original_stdout_ = -1;
|
||||
original_stderr_ = -1;
|
||||
const pid_t child = static_cast<pid_t>(logger_process_);
|
||||
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
logger_process_ = -1;
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user