Compare commits

..
7 Commits
Author SHA1 Message Date
komaruworld 9bdd737d10 Release 1.0.2 2026-08-14 20:58:03 +03:00
komaruworld df253e786f Upd flatpak metainfo 2026-08-14 20:40:38 +03:00
komaruworld 53cd79b7da fix ci 2026-08-14 20:18:26 +03:00
komaruworld febebef554 Update website and add apt,rpm repository 2026-08-14 20:12:36 +03:00
komaruworld 23fe2eeca5 improved Discord rpc, autoupdate
fixes #4
2026-08-14 00:34:46 +03:00
komaruworld 6f5de3a31d improved ci 2026-08-13 22:36:42 +03:00
komaruworld ea5929faf6 improved the logs 2026-08-13 18:37:30 +03:00
38 changed files with 1972 additions and 515 deletions

No files matched your search

+58 -33
View File
@@ -1,27 +1,9 @@
name: Flatpak
on:
push:
branches: [main]
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'
- 'src/**'
- 'stubs/**'
- 'third_party/**'
workflow_run:
workflows: [Native packages]
types: [completed]
pull_request:
paths:
- '.github/workflows/flatpak.yml'
@@ -39,30 +21,69 @@ on:
- '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.ref }}
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: |
@@ -78,13 +99,10 @@ jobs:
FLATPAK_GPG_KEY_ID: ${{ secrets.FLATPAK_GPG_KEY_ID }}
run: |
set -Eeuo pipefail
git update-ref refs/heads/main "$GITHUB_SHA"
git update-ref refs/heads/main "$MOCKTAIL_SOURCE_SHA"
builder_signing=()
bundle_keys=()
publish=false
if [[ "$GITHUB_REF" == refs/heads/main && \
"$GITHUB_EVENT_NAME" != pull_request ]]; then
publish=true
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
[[ -n "$FLATPAK_GPG_PRIVATE_KEY" && \
-n "$FLATPAK_GPG_KEY_ID" ]]
install -d -m 0700 "$RUNNER_TEMP/mocktail-gnupg"
@@ -110,7 +128,7 @@ jobs:
"${builder_signing[@]}" \
build-flatpak-ci \
packaging/flatpak/space.bigrat.mocktail.json
if [[ "$publish" == true ]]; then
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
flatpak build-update-repo \
--generate-static-deltas \
--static-delta-jobs=2 \
@@ -126,9 +144,10 @@ jobs:
Mocktail-x86_64.flatpak \
space.bigrat.mocktail \
stable
if [[ "$publish" == true ]]; then
if [[ "$MOCKTAIL_PUBLISH" == true ]]; then
./scripts/assemble_flatpak_pages.sh \
repo Mocktail-x86_64.flatpak mocktail-flatpak.gpg public
repo Mocktail-x86_64.flatpak mocktail-flatpak.gpg \
public native-repositories
fi
- name: Upload Flatpak
@@ -140,18 +159,24 @@ jobs:
retention-days: 14
- name: Configure GitHub Pages
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
if: env.MOCKTAIL_PUBLISH == 'true'
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload GitHub Pages repository
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
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.ref == 'refs/heads/main' && github.event_name != 'pull_request'
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
+66 -8
View File
@@ -5,27 +5,21 @@ on:
branches: [main]
paths-ignore:
- '.github/workflows/ci.yml'
- '.github/workflows/flatpak.yml'
- '.gitignore'
- 'README.md'
- 'assets/screenshots/**'
- 'docs/**'
- 'packaging/aur/**'
- 'packaging/flatpak/**'
- 'scripts/assemble_flatpak_pages.sh'
- 'scripts/build_flatpak.sh'
- 'tests/**'
pull_request:
paths-ignore:
- '.github/workflows/ci.yml'
- '.github/workflows/flatpak.yml'
- '.gitignore'
- 'README.md'
- 'assets/screenshots/**'
- 'docs/**'
- 'packaging/aur/**'
- 'packaging/flatpak/**'
- 'scripts/assemble_flatpak_pages.sh'
- 'scripts/build_flatpak.sh'
- 'tests/**'
workflow_dispatch:
@@ -121,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
@@ -138,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 \
@@ -167,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 \
@@ -182,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 \
@@ -243,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:
@@ -259,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"
@@ -277,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
+71 -2
View File
@@ -8,7 +8,7 @@
cmake_minimum_required(VERSION 3.20)
project(Mocktail
VERSION 1.0.1
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
@@ -259,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
@@ -272,6 +307,17 @@ target_include_directories(mocktail_runtime PUBLIC
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)
@@ -671,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")
@@ -693,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)
+1 -1
View File
@@ -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 -4
View File
@@ -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
@@ -141,9 +141,6 @@ update-auto-launch: build ## Update Roblox and launch the verified current paylo
@./$(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
+45 -20
View File
@@ -24,6 +24,15 @@ an update fails.
## Install with Flatpak
Install the stable release from Flathub:
```bash
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,6 +53,42 @@ 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`.
## Install with DNF
Fedora 44 users can install either the stable `mocktail` package or the
`mocktail-nightly` package built from `main`:
```bash
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>
@@ -55,26 +100,6 @@ source, or install the stable prebuilt package with `paru -S mocktail-bin` or
</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).
```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
```
## Building
Only Linux `x86_64` is supported. Building requires CMake 3.20+, Git,
+5
View File
@@ -112,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
-15
View File
@@ -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
-12
View File
@@ -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
+2 -4
View File
@@ -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);
+74
View File
@@ -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_
+1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: komaruworld <rtysus228@gmail.com>
pkgname=mocktail
pkgver=1.0.1
pkgver=1.0.2
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
+2 -2
View File
@@ -1,6 +1,6 @@
pkgbase = mocktail
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 1.0.1
pkgver = 1.0.2
pkgrel = 1
url = https://github.com/komaruworld/mocktail
arch = x86_64
@@ -37,7 +37,7 @@ pkgbase = mocktail
conflicts = mocktail-bin
conflicts = mocktail-git
options = !debug
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.1
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.2
sha256sums = SKIP
pkgname = mocktail
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
pkgname=mocktail
pkgver=1.0.1
pkgver=1.0.2
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
+25 -1
View File
@@ -10,6 +10,10 @@
<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>
@@ -35,11 +39,31 @@
</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>
+29 -42
View File
@@ -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 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://mocktail.bigrat.space"
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,70 +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 -- \
"$(dirname -- "${BASH_SOURCE[0]}")/../packaging/discord-join.html" \
"${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=Play Roblox on Linux
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://mocktail.bigrat.space/mocktail.flatpakref</code>
<p>Mocktail is an independent community project and is not affiliated with Roblox Corporation.</p>
</main></body>
</html>
EOF
+110
View File
@@ -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
+33 -1
View File
@@ -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" \
+110 -18
View File
@@ -1088,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
)
@@ -1131,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, ...]:
@@ -1256,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
@@ -1277,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",
@@ -1362,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"]:
-66
View File
@@ -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
+2 -1
View File
@@ -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:
+70
View File
@@ -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 &gt;/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>
+88
View File
@@ -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;
}
+44 -6
View File
@@ -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);
}
+28 -12
View File
@@ -7,6 +7,7 @@
// http://www.apache.org/licenses/LICENSE-2.0
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
@@ -44,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"
@@ -109,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) {
@@ -164,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);
@@ -221,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) {
@@ -266,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) {
@@ -289,8 +305,8 @@ int main(int argc, char* argv[]) {
<< '\n';
}
// Register immediately after any cgroup re-exec and before starting helper
// processes or loading the Android payload. libgamemode caches its shared
// 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;
+19 -8
View File
@@ -552,10 +552,12 @@ DiscordRpcActivity BuildDiscordRpcActivity(
activity.details = TruncateUtf8(config.text.browsing, 128);
break;
case RobloxExperiencePresencePhase::kJoining:
activity.details = TruncateUtf8(config.text.joining, 128);
break;
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) {
@@ -564,14 +566,16 @@ DiscordRpcActivity BuildDiscordRpcActivity(
activity.details = activity.state;
activity.state.clear();
}
if (config.show_elapsed_time && session_started_at > 0) {
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 (config.join_enabled && request != nullptr &&
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()) {
@@ -733,7 +737,8 @@ class DiscordRpcSession::Impl final {
std::string place_name;
std::string place_icon_url;
bool resolve_metadata_after_publish = false;
if (desired.phase == RobloxExperiencePresencePhase::kPlaying &&
if ((desired.phase == RobloxExperiencePresencePhase::kJoining ||
desired.phase == RobloxExperiencePresencePhase::kPlaying) &&
desired.request.place_id > 0) {
CachedPlaceMetadata& cached =
place_metadata_[desired.request.place_id];
@@ -741,7 +746,6 @@ class DiscordRpcSession::Impl final {
place_icon_url = cached.icon_url;
if ((cached.name.empty() || cached.icon_url.empty()) &&
std::chrono::steady_clock::now() >= cached.next_attempt) {
// Publish fallback activity while metadata is in flight.
resolve_metadata_after_publish = true;
}
}
@@ -761,7 +765,12 @@ class DiscordRpcSession::Impl final {
std::fprintf(stderr, " [discord-rpc] connected to Discord Desktop\n");
published_revision = 0;
}
if (published_revision != desired.revision) {
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) {
@@ -824,7 +833,9 @@ class DiscordRpcSession::Impl final {
if (Snapshot().revision != desired.revision) {
continue;
}
if (changed) {
if (changed &&
(desired.phase == RobloxExperiencePresencePhase::kJoining ||
desired.phase == RobloxExperiencePresencePhase::kPlaying)) {
// Publish cached metadata through the normal rate limiter.
published_revision = 0;
continue;
+4 -162
View File
@@ -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;
}
+5
View File
@@ -144,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
+732
View File
@@ -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
+109 -13
View File
@@ -18,6 +18,7 @@
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <iterator>
#include <map>
#include <nlohmann/json.hpp>
#include <optional>
@@ -906,10 +907,10 @@ bool ValidateSemantics(const std::vector<Instruction>& reference,
return true;
}
std::optional<SignatureMatch> FindSignatureMatch(
std::optional<std::vector<SignatureMatch>> FindSignatureMatches(
const ElfImage& reference, const ElfImage& candidate,
const Disassembler& disassembler, const SignatureSpec& spec,
std::string* error) {
bool allow_multiple_candidates, std::string* error) {
const auto reference_instructions = disassembler.Decode(
reference, spec.reference_rva, spec.instruction_count, 4096, error);
if (!reference_instructions.has_value()) return std::nullopt;
@@ -995,21 +996,68 @@ std::optional<SignatureMatch> FindSignatureMatch(
}
search = found + 1;
}
if (matches.size() != 1) {
if (matches.empty()) {
*error = "signature " + spec.name + " matched " +
"0 candidate locations";
return std::nullopt;
}
if (!allow_multiple_candidates && matches.size() != 1U) {
*error = "signature " + spec.name + " matched " +
std::to_string(matches.size()) + " candidate locations";
return std::nullopt;
}
const std::uint64_t match_rva = text->address + matches[0];
const auto candidate_instructions = disassembler.Decode(
candidate, match_rva, spec.instruction_count, 4096, error);
if (!candidate_instructions.has_value() ||
!ValidateSemantics(*reference_instructions, *candidate_instructions,
spec.registry_size_may_change, error)) {
std::vector<SignatureMatch> semantic_matches;
std::string first_semantic_error;
for (const std::size_t match : matches) {
const std::uint64_t match_rva = text->address + match;
std::string semantic_error;
const auto candidate_instructions = disassembler.Decode(
candidate, match_rva, spec.instruction_count, 4096, &semantic_error);
if (!candidate_instructions.has_value() ||
!ValidateSemantics(*reference_instructions, *candidate_instructions,
spec.registry_size_may_change, &semantic_error)) {
if (first_semantic_error.empty()) {
first_semantic_error = std::move(semantic_error);
}
continue;
}
semantic_matches.push_back(SignatureMatch{
match_rva, *reference_instructions, *candidate_instructions});
}
if (semantic_matches.empty() && matches.size() == 1U &&
!first_semantic_error.empty()) {
*error = std::move(first_semantic_error);
return std::nullopt;
}
return SignatureMatch{match_rva, *reference_instructions,
*candidate_instructions};
return semantic_matches;
}
std::optional<SignatureMatch> FindSignatureMatch(
const ElfImage& reference, const ElfImage& candidate,
const Disassembler& disassembler, const SignatureSpec& spec,
std::string* error) {
auto matches =
FindSignatureMatches(reference, candidate, disassembler, spec, false,
error);
if (!matches.has_value()) return std::nullopt;
if (matches->size() != 1U) {
*error = "signature " + spec.name + " matched " +
std::to_string(matches->size()) + " candidate locations";
return std::nullopt;
}
return std::move(matches->front());
}
std::optional<std::uint64_t> DirectCallTarget(
const Instruction& instruction, std::string_view description,
std::string* error) {
if (instruction.mnemonic != "call" || instruction.operands.size() != 1U ||
instruction.operands[0].type != Operand::Type::kImmediate ||
instruction.operands[0].value < 0) {
*error = std::string(description) + " is not one direct call";
return std::nullopt;
}
return static_cast<std::uint64_t>(instruction.operands[0].value);
}
std::vector<std::uint64_t> RipTargets(const Instruction& instruction) {
@@ -1182,7 +1230,9 @@ std::optional<std::vector<SignatureSpec>> SignatureSpecs(const Json& sidecar,
for (const auto& [label, count] :
std::array<std::pair<std::string_view, std::size_t>, 6>{
{{"small-allocate", 24},
{"usable-size", 24},
// usable-size is an 18-instruction leaf. Do not include bytes from
// the adjacent function or alignment padding in its signature.
{"usable-size", 18},
{"reallocate", 24},
{"allocate", 24},
{"aligned-allocate-direct", 24},
@@ -1193,7 +1243,7 @@ std::optional<std::vector<SignatureSpec>> SignatureSpecs(const Json& sidecar,
}
if (!add("arena-initializer", seeds["arena_initializer"], 24) ||
!add("allocator-thread-initializer",
seeds["allocator_thread_initializer"], 24) ||
seeds["allocator_thread_initializer"], 19) ||
!add("registry-initializer", bootstrap["registry_initializer"], 20,
true) ||
!add("allocator-object-initializer",
@@ -1290,12 +1340,58 @@ std::optional<std::pair<Json, Json>> DeriveDocuments(const ElfImage& reference,
const auto specs = SignatureSpecs(sidecar, error);
if (!specs.has_value()) return std::nullopt;
std::map<std::string, SignatureMatch> matches;
std::vector<SignatureMatch> allocate_matches;
for (const SignatureSpec& spec : *specs) {
if (spec.name == "allocate") {
auto candidates = FindSignatureMatches(reference, candidate,
disassembler, spec, true, error);
if (!candidates.has_value()) return std::nullopt;
allocate_matches = std::move(*candidates);
continue;
}
auto match =
FindSignatureMatch(reference, candidate, disassembler, spec, error);
if (!match.has_value()) return std::nullopt;
matches.emplace(spec.name, std::move(*match));
}
// 2904 contains two semantically equivalent allocate wrappers. Select the
// one independently referenced by the registry initializer instead of
// weakening signature uniqueness globally.
const SignatureMatch& registry_match = matches.at("registry-initializer");
if (allocate_matches.empty() || registry_match.reference.size() <= 9U ||
registry_match.candidate.size() <= 9U) {
*error = "allocator signatures are incomplete";
return std::nullopt;
}
const auto reference_allocate = DirectCallTarget(
registry_match.reference[9], "reference registry allocator call", error);
if (!reference_allocate.has_value() ||
*reference_allocate !=
allocate_matches.front().reference.front().address) {
if (error->empty()) {
*error =
"reference registry initializer does not call its allocator bridge";
}
return std::nullopt;
}
const auto candidate_allocate = DirectCallTarget(
registry_match.candidate[9], "candidate registry allocator call", error);
if (!candidate_allocate.has_value()) return std::nullopt;
auto selected_allocate = std::find_if(
allocate_matches.begin(), allocate_matches.end(),
[&](const SignatureMatch& match) {
return match.rva == *candidate_allocate;
});
if (selected_allocate == allocate_matches.end() ||
std::find_if(std::next(selected_allocate), allocate_matches.end(),
[&](const SignatureMatch& match) {
return match.rva == *candidate_allocate;
}) != allocate_matches.end()) {
*error = "signature allocate candidates do not identify exactly one "
"registry allocator call";
return std::nullopt;
}
matches.emplace("allocate", std::move(*selected_allocate));
const auto reference_init = reference.InitArray(error);
const auto candidate_init = candidate.InitArray(error);
if (!reference_init.has_value() || !candidate_init.has_value()) {
+5
View File
@@ -155,6 +155,11 @@ UpdateConfigResult LoadUpdateConfig(const std::filesystem::path& path) {
result.error = "updates.automatic must be true or false";
break;
}
} else if (key == "testing_latest_only") {
if (!ParseBoolean(value, &result.config.testing_latest_only)) {
result.error = "updates.testing_latest_only must be true or false";
break;
}
} else if (key == "launch_after_update") {
if (!ParseBoolean(value, &result.config.launch_after_update)) {
result.error = "updates.launch_after_update must be true or false";
+35 -8
View File
@@ -235,7 +235,8 @@ ReferenceProfile ResolveReference(const UpdatePaths& paths,
ApkPureProvider* provider,
PayloadStore* store,
const std::filesystem::path& workspace,
int progress_fd) {
int progress_fd,
bool allow_reference_download) {
ReferenceProfile result;
if (installed && !installed.host_abi_profile.empty()) {
std::error_code filesystem_error;
@@ -262,6 +263,12 @@ ReferenceProfile ResolveReference(const UpdatePaths& paths,
result.profile = paths.host_abi_reference_profile;
return result;
}
if (!allow_reference_download) {
result.error =
"updates.testing_latest_only requires an installed HostAbi reference; "
"downloading an older reference payload is disabled";
return result;
}
Candidate downloaded =
DownloadCandidate(provider, store, ExactPayloadIdentity(preferred), true,
paths, workspace / "reference", progress_fd);
@@ -397,7 +404,9 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
}
ApkPureProvider provider;
std::optional<ProviderVersion> latest_version;
if (request.check_latest) {
const bool check_latest =
request.check_latest || configured.config.testing_latest_only;
if (check_latest) {
Progress(request.progress_fd, "Checking Roblox...");
const ProviderVersion latest = provider.CheckLatest();
if (latest) {
@@ -414,11 +423,20 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
result.message = "installed Roblox is newer than provider metadata";
return result;
}
} else if (current) {
result.payload_id = current.payload_id;
result.message =
"update metadata is temporarily unavailable: " + latest.error;
return result;
} else {
if (current) {
result.payload_id = current.payload_id;
result.message =
"update metadata is temporarily unavailable: " + latest.error;
return result;
}
if (configured.config.testing_latest_only) {
result.error =
"updates.testing_latest_only requires available provider latest "
"metadata: " +
latest.error;
return result;
}
}
}
const std::filesystem::path workspace = UniqueDirectory(
@@ -475,7 +493,8 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
if (candidate && !candidate.exact_supported && candidate.profile.empty()) {
const ReferenceProfile reference =
ResolveReference(paths, installed, *preferred, &provider, &store,
workspace, request.progress_fd);
workspace, request.progress_fd,
!configured.config.testing_latest_only);
if (!reference) {
candidate_error = reference.error;
} else {
@@ -530,6 +549,14 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
return result;
}
if (configured.config.testing_latest_only) {
result.error = "latest Roblox failed probation (" + candidate_error +
"); updates.testing_latest_only forbids activating an "
"older supported fallback";
cleanup();
return result;
}
Candidate fallback;
const std::string preferred_id =
std::to_string(preferred->version_code) + "-" + preferred->elf_build_id;
+7
View File
@@ -62,6 +62,12 @@ target_link_libraries(support_bundle_runtime_test PRIVATE
GTest::gtest_main
)
add_executable(session_log_test session_log_test.cc)
target_link_libraries(session_log_test PRIVATE
Mocktail::Runtime
GTest::gtest_main
)
add_executable(failure_dialog_test failure_dialog_test.cc)
target_link_libraries(failure_dialog_test PRIVATE
Mocktail::FailureDialog
@@ -803,6 +809,7 @@ gtest_discover_tests(runtime_config_file_test)
gtest_discover_tests(payload_update_preflight_test)
gtest_discover_tests(native_update_test)
gtest_discover_tests(support_bundle_runtime_test)
gtest_discover_tests(session_log_test)
gtest_discover_tests(failure_dialog_test)
gtest_discover_tests(frame_rate_policy_test)
gtest_discover_tests(performance_policy_test)
+21 -2
View File
@@ -128,7 +128,15 @@ grep -Fq 'CleanupStaleBuilderMounts' "${BUILD_HELPER}" ||
Fail 'obsolete GitLab CI configuration is still present'
grep -Fq 'name: Flatpak' "${GITHUB_CI}" ||
Fail 'GitHub Actions workflow has no stable display name'
grep -Fq 'branches: [main]' "${GITHUB_CI}" ||
validates_main=false
if grep -Fq 'branches: [main]' "${GITHUB_CI}"; then
validates_main=true
elif grep -Fq 'workflow_run:' "${GITHUB_CI}" &&
grep -Fq 'workflows: [Native packages]' "${GITHUB_CI}" &&
grep -Fq "head_branch == 'main'" "${GITHUB_CI}"; then
validates_main=true
fi
[[ "${validates_main}" == true ]] ||
Fail 'GitHub Actions does not validate the main branch'
grep -Fq 'pull_request:' "${GITHUB_CI}" ||
Fail 'GitHub Actions does not validate pull requests'
@@ -201,13 +209,20 @@ grep -Fq 'flatpak-builder is unavailable' "${TEMP_DIR}/stderr" ||
Fail 'helper did not explain how to install flatpak-builder'
mkdir -p -- "${TEMP_DIR}/repo/objects"
mkdir -p -- \
"${TEMP_DIR}/native-repositories/apt" \
"${TEMP_DIR}/native-repositories/rpm" \
"${TEMP_DIR}/native-repositories/downloads"
printf 'bundle\n' >"${TEMP_DIR}/Mocktail-x86_64.flatpak"
printf 'public-key\n' >"${TEMP_DIR}/mocktail-flatpak.gpg"
printf 'public-key\n' \
>"${TEMP_DIR}/native-repositories/mocktail-packages.gpg"
"${PAGES_HELPER}" \
"${TEMP_DIR}/repo" \
"${TEMP_DIR}/Mocktail-x86_64.flatpak" \
"${TEMP_DIR}/mocktail-flatpak.gpg" \
"${TEMP_DIR}/public"
"${TEMP_DIR}/public" \
"${TEMP_DIR}/native-repositories"
grep -Fq 'Url=https://mocktail.bigrat.space/repo/' \
"${TEMP_DIR}/public/mocktail.flatpakrepo" ||
Fail 'published Flatpak repository URL is incorrect'
@@ -218,5 +233,9 @@ grep -Fq 'flatpak install --user' "${TEMP_DIR}/public/index.html" ||
Fail 'Pages landing page has no direct installation command'
grep -Fq 'roblox://experiences/start' "${TEMP_DIR}/public/join.html" ||
Fail 'Pages output has no Discord join bridge'
[[ -d "${TEMP_DIR}/public/apt" &&
-d "${TEMP_DIR}/public/rpm" &&
-d "${TEMP_DIR}/public/downloads" ]] ||
Fail 'Pages output has no native package repositories'
printf 'Flatpak packaging contract test passed\n'
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env bash
# Copyright 2026 Mocktail Project Authors
# SPDX-License-Identifier: Apache-2.0
set -Eeuo pipefail
readonly installer="${1:?user updater installer is required}"
readonly project_root="${2:?project root is required}"
test_root="$(mktemp -d)"
trap 'rm -rf -- "${test_root}"' EXIT
mkdir -p "${test_root}/bin" "${test_root}/home"
canary="${test_root}/mocktail"
printf '#!/usr/bin/env bash\nexit 0\n' >"${canary}"
chmod 0755 "${canary}"
cat >"${test_root}/bin/systemctl" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"${SYSTEMCTL_CALLS:?}"
EOF
chmod 0755 "${test_root}/bin/systemctl"
export HOME="${test_root}/home"
export XDG_CONFIG_HOME="${test_root}/config"
export MOCKTAIL_UPDATE_CANARY_BIN="${canary}"
export SYSTEMCTL_CALLS="${test_root}/systemctl-calls"
export PATH="${test_root}/bin:${PATH}"
"${installer}" >/dev/null
readonly config_root="${XDG_CONFIG_HOME}/mocktail"
readonly unit_root="${XDG_CONFIG_HOME}/systemd/user"
[[ -f "${config_root}/config.yaml" ]]
[[ -f "${unit_root}/mocktail-update.service" ]]
[[ -f "${unit_root}/mocktail-update.timer" ]]
grep -Fxq "MOCKTAIL_PROJECT_ROOT=${project_root}" \
"${config_root}/updater.env"
grep -Fxq "MOCKTAIL_UPDATE_CANARY_BIN=${canary}" \
"${config_root}/updater.env"
grep -Fq \
'auto_update_roblox.sh --skip-build --no-launch --scheduled' \
"${unit_root}/mocktail-update.service"
grep -Fxq -- '--user daemon-reload' "${SYSTEMCTL_CALLS}"
grep -Fxq -- '--user enable --now mocktail-update.timer' "${SYSTEMCTL_CALLS}"
grep -Fxq -- '--user list-timers mocktail-update.timer --no-pager' \
"${SYSTEMCTL_CALLS}"
# A source-tree timer must follow the runtime built from that same tree. An
# older system installation may coexist, but it cannot safely approve or
# reject payloads on behalf of the development binary.
fixture_project="${test_root}/fixture-project"
mkdir -p "${fixture_project}/scripts" "${fixture_project}/build" \
"${fixture_project}/config/systemd"
cp -- "${installer}" "${fixture_project}/scripts/install_user_updater.sh"
cp -- "${project_root}/config/systemd/mocktail-update.service" \
"${fixture_project}/config/systemd/mocktail-update.service"
cp -- "${project_root}/config/systemd/mocktail-update.timer" \
"${fixture_project}/config/systemd/mocktail-update.timer"
cp -- "${project_root}/config/mocktail.example.yaml" \
"${fixture_project}/config/mocktail.example.yaml"
printf '#!/usr/bin/env bash\nexit 0\n' > \
"${fixture_project}/build/mocktail"
chmod 0755 "${fixture_project}/build/mocktail"
unset MOCKTAIL_UPDATE_CANARY_BIN
"${fixture_project}/scripts/install_user_updater.sh" >/dev/null
grep -Fxq \
"MOCKTAIL_UPDATE_CANARY_BIN=${fixture_project}/build/mocktail" \
"${config_root}/updater.env"
echo "user updater installation checks passed"
+10 -4
View File
@@ -107,11 +107,17 @@ grep -Fq 'paths-ignore:' "${WORKFLOW}" ||
Fail 'native package workflow has no path filters'
grep -Fq "'assets/screenshots/**'" "${WORKFLOW}" ||
Fail 'native packages rebuild for screenshot-only changes'
grep -Fq "'packaging/flatpak/**'" "${WORKFLOW}" ||
Fail 'native packages rebuild for Flatpak-only changes'
if grep -Fq "'packaging/flatpak/**'" "${WORKFLOW}"; then
Fail 'native packages ignore Flatpak changes required by Pages publication'
fi
if grep -Fq "'site/**'" "${WORKFLOW}"; then
Fail 'native packages ignore website changes required by Pages publication'
fi
grep -Fq 'sudo pacman -U ./mocktail-*-x86_64.pkg.tar.zst' "${README}" ||
Fail 'README has no direct pacman installation command'
grep -Fq 'sudo dnf install mocktail-nightly' "${README}" ||
Fail 'README has no nightly DNF installation command'
grep -Fq 'sudo apt install mocktail-nightly' "${README}" ||
Fail 'README has no nightly APT installation command'
grep -Fq 'Ubuntu 26.04+' "${README}" ||
Fail 'README has no Ubuntu source dependency guide'
grep -Fq 'Arch Linux' "${README}" ||
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 Mocktail Project Authors
// Licensed under the Apache License, Version 2.0.
#include "runtime/session_log.h"
#include <gtest/gtest.h>
#include <sys/stat.h>
#include <unistd.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <optional>
#include <regex>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
namespace mocktail {
namespace runtime {
namespace {
class MapEnvironment final : public Environment {
public:
explicit MapEnvironment(std::unordered_map<std::string, std::string> values)
: values_(std::move(values)) {}
std::optional<std::string> Get(std::string_view name) const override {
const auto found = values_.find(std::string(name));
return found == values_.end() ? std::nullopt
: std::optional<std::string>(found->second);
}
private:
std::unordered_map<std::string, std::string> values_;
};
class TemporaryDirectory final {
public:
TemporaryDirectory() {
char pattern[] = "/tmp/mocktail_session_log_XXXXXX";
const char* created = mkdtemp(pattern);
if (created != nullptr) root_ = created;
}
~TemporaryDirectory() {
std::error_code error;
std::filesystem::remove_all(root_, error);
}
const std::filesystem::path& root() const { return root_; }
private:
std::filesystem::path root_;
};
std::string ReadFile(const std::filesystem::path& path) {
std::ifstream input(path);
std::ostringstream contents;
contents << input.rdbuf();
return contents.str();
}
TEST(SessionLogTest, CapturesBothStreamsAndUpdatesLatest) {
TemporaryDirectory temporary;
const std::filesystem::path home = temporary.root() / "home";
const std::filesystem::path state = temporary.root() / "state";
const MapEnvironment environment({
{"HOME", home.string()},
{"MOCKTAIL_STATE_ROOT", state.string()},
{"WAYLAND_DISPLAY", "wayland-0"},
});
const RuntimePaths paths = RuntimePaths::FromEnvironment(environment);
std::filesystem::path session_path;
std::filesystem::path latest_path;
{
SessionLog log = SessionLog::Start(environment, paths);
ASSERT_TRUE(log) << log.error();
session_path = log.path();
latest_path = log.latest_path();
std::cout << log.Header(environment, paths, "direct-vulkan")
<< "session-stdout-marker\n"
<< std::flush;
std::cerr << "session-stderr-marker\n" << std::flush;
}
ASSERT_TRUE(std::filesystem::is_regular_file(session_path));
ASSERT_TRUE(std::filesystem::exists(latest_path));
EXPECT_EQ(std::filesystem::canonical(latest_path),
std::filesystem::canonical(session_path));
EXPECT_TRUE(std::regex_match(
session_path.filename().string(),
std::regex(
R"(^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}\.log$)")));
struct stat metadata = {};
ASSERT_EQ(stat(session_path.c_str(), &metadata), 0);
EXPECT_EQ(metadata.st_mode & 0777, 0600);
const std::string contents = ReadFile(session_path);
EXPECT_EQ(contents.rfind("[mocktail] version=", 0), 0U);
EXPECT_NE(contents.find("session-stdout-marker"), std::string::npos);
EXPECT_NE(contents.find("session-stderr-marker"), std::string::npos);
EXPECT_NE(contents.find(" commit="), std::string::npos);
EXPECT_NE(contents.find(" target="), std::string::npos);
EXPECT_NE(contents.find("[mocktail] cpu=\""), std::string::npos);
EXPECT_NE(contents.find(" ram="), std::string::npos);
EXPECT_NE(contents.find(" gpu=\""), std::string::npos);
EXPECT_NE(contents.find(" gpu_driver=\""), std::string::npos);
EXPECT_NE(contents.find("[mocktail] log="), std::string::npos);
}
TEST(SessionLogTest, PreservesSessionsStartedInTheSameSecond) {
TemporaryDirectory temporary;
const MapEnvironment environment({
{"HOME", (temporary.root() / "home").string()},
{"MOCKTAIL_STATE_ROOT", (temporary.root() / "state").string()},
});
const RuntimePaths paths = RuntimePaths::FromEnvironment(environment);
const auto timestamp = std::chrono::system_clock::from_time_t(1770000000);
std::filesystem::path first;
std::filesystem::path second;
{
SessionLog log = SessionLog::Start(environment, paths, timestamp);
ASSERT_TRUE(log) << log.error();
first = log.path();
}
{
SessionLog log = SessionLog::Start(environment, paths, timestamp);
ASSERT_TRUE(log) << log.error();
second = log.path();
}
EXPECT_NE(first, second);
EXPECT_TRUE(std::filesystem::is_regular_file(first));
EXPECT_TRUE(std::filesystem::is_regular_file(second));
EXPECT_EQ(second.stem().string().substr(second.stem().string().size() - 2),
"-2");
EXPECT_EQ(std::filesystem::canonical(paths.logs_root() / "latest.log"),
std::filesystem::canonical(second));
}
TEST(SessionLogTest, SkipsIsolatedCanary) {
TemporaryDirectory temporary;
const MapEnvironment environment({
{"HOME", temporary.root().string()},
{"MOCKTAIL_ISOLATED_CANARY", "1"},
});
const RuntimePaths paths = RuntimePaths::FromEnvironment(environment);
SessionLog log = SessionLog::Start(environment, paths);
EXPECT_FALSE(log);
EXPECT_FALSE(log.attempted());
EXPECT_FALSE(std::filesystem::exists(paths.logs_root()));
}
} // namespace
} // namespace runtime
} // namespace mocktail