From 4e65cdb6fbaca0978978e11e44eda90334584952 Mon Sep 17 00:00:00 2001 From: vii Date: Thu, 8 Oct 2026 23:42:45 -0500 Subject: [PATCH 01/30] feat(flatpak): add a build-only mode make flatpak-build and --no-install build without installing. The packaging test no longer finds a flatpak-builder installed on the host. --- Makefile | 9 ++++++++- README.md | 3 ++- scripts/build_flatpak.sh | 17 ++++++++++++++-- tests/flatpak_packaging_test.sh | 35 ++++++++++++++++++++++++++++++--- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index f56c744a..b8b0c085 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Modified by vii from komaruworld/mocktail. See README "About this fork". .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 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 flatpak-build 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 @@ -91,6 +91,13 @@ flatpak: ## Build and install the local x86-64 Flatpak --manifest "$(FLATPAK_MANIFEST)" \ --jobs "$(FLATPAK_JOBS)" +flatpak-build: ## Build the local Flatpak without installing it + @./scripts/build_flatpak.sh \ + --no-install \ + --build-dir "$(FLATPAK_BUILD_DIR)" \ + --manifest "$(FLATPAK_MANIFEST)" \ + --jobs "$(FLATPAK_JOBS)" + run-flatpak: ## Run the installed Flatpak @flatpak run io.github.CoderDayton.nightcap diff --git a/README.md b/README.md index c91e25d0..7ce94928 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,8 @@ Roblox website links open the build tree copy without installing, run To build and install the Flatpak from source, install `flatpak-builder` and run `make flatpak`. It builds with 2 parallel jobs to keep memory use low. Pass `FLATPAK_JOBS=N` to `make flatpak`, or set `MOCKTAIL_FLATPAK_JOBS=N` for -`scripts/build_flatpak.sh`, to change that. +`scripts/build_flatpak.sh`, to change that. To build without installing, run +`make flatpak-build`; the result stays in `build-flatpak/`. To run the same checks as CI before each commit and push, install [lefthook](https://github.com/evilmartians/lefthook) and run once: diff --git a/scripts/build_flatpak.sh b/scripts/build_flatpak.sh index 228324c7..767b28f8 100755 --- a/scripts/build_flatpak.sh +++ b/scripts/build_flatpak.sh @@ -11,17 +11,20 @@ readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" BUILD_DIR="${PROJECT_ROOT}/build-flatpak" MANIFEST="${PROJECT_ROOT}/packaging/flatpak/io.github.CoderDayton.nightcap.json" JOBS="${MOCKTAIL_FLATPAK_JOBS:-2}" +INSTALL=1 Usage() { cat <<'EOF' Usage: scripts/build_flatpak.sh [OPTIONS] -Build and install Mocktail as a per-user Flatpak for the host architecture. +Build Mocktail as a Flatpak for the host architecture and install it for the +current user. Options: --build-dir DIR Builder output directory (default: build-flatpak). --manifest FILE Flatpak manifest path. --jobs N Maximum parallel build jobs (default: 2). + --no-install Build only. The result stays in the build directory. -h, --help Show this help. EOF } @@ -80,6 +83,10 @@ while (( $# > 0 )); do JOBS="$2" shift 2 ;; + --no-install) + INSTALL=0 + shift + ;; -h|--help) Usage exit 0 @@ -124,6 +131,12 @@ fi CleanupStaleBuilderMounts +# Build dependencies are still installed for the user under --no-install. +install_args=() +if (( INSTALL )); then + install_args=(--install) +fi + cd -- "${PROJECT_ROOT}" exec "${builder[@]}" \ --arch="${HOST_ARCH}" \ @@ -131,6 +144,6 @@ exec "${builder[@]}" \ --force-clean \ --install-deps-from=flathub \ --user \ - --install \ + "${install_args[@]}" \ "${BUILD_DIR}" \ "${MANIFEST}" diff --git a/tests/flatpak_packaging_test.sh b/tests/flatpak_packaging_test.sh index 24d69e47..b3c1e385 100755 --- a/tests/flatpak_packaging_test.sh +++ b/tests/flatpak_packaging_test.sh @@ -193,9 +193,9 @@ cat >"${TEMP_DIR}/bin/flatpak" <<'EOF' exit 1 EOF chmod 0755 "${TEMP_DIR}/bin/uname" "${TEMP_DIR}/bin/flatpak" -# The helper sees only these tools, so a flatpak-builder on the host stays -# out of its PATH. -for tool in bash dirname realpath; do +# The helper runs with only these tools on PATH, so a flatpak-builder +# installed on the host is never found. +for tool in bash cat dirname realpath; do ln -s -- "$(command -v "${tool}")" "${TEMP_DIR}/bin/${tool}" done @@ -209,6 +209,35 @@ set -e grep -Fq 'flatpak-builder is unavailable' "${TEMP_DIR}/stderr" || Fail 'helper did not explain how to install flatpak-builder' +cat >"${TEMP_DIR}/bin/flatpak-builder" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" +EOF +chmod 0755 "${TEMP_DIR}/bin/flatpak-builder" + +builder_args="$( + PATH="${TEMP_DIR}/bin" \ + "${BUILD_HELPER}" --build-dir "${ROOT}/build-flatpak-test" +)" +grep -Fxq -- '--install' <<<"${builder_args}" || + Fail 'helper does not install the Flatpak by default' + +builder_args="$( + PATH="${TEMP_DIR}/bin" \ + "${BUILD_HELPER}" --no-install --build-dir "${ROOT}/build-flatpak-test" +)" +if grep -Fxq -- '--install' <<<"${builder_args}"; then + Fail 'helper installed the Flatpak despite --no-install' +fi +grep -Fxq -- '--install-deps-from=flathub' <<<"${builder_args}" || + Fail 'helper lost the build dependencies under --no-install' + +dry_run="$( + make -C "${ROOT}" --no-print-directory --dry-run flatpak-build | tr -d '"' +)" +grep -Fq -- '--no-install' <<<"${dry_run}" || + Fail 'make flatpak-build installs the Flatpak' + mkdir -p -- "${TEMP_DIR}/repo/objects" printf 'bundle\n' >"${TEMP_DIR}/Nightcap-x86_64.flatpak" printf 'public-key\n' >"${TEMP_DIR}/nightcap-flatpak.gpg" From 2f89e6faebbadf4401c42ff21876c03e3f9f3489 Mon Sep 17 00:00:00 2001 From: vii Date: Thu, 8 Oct 2026 23:42:45 -0500 Subject: [PATCH 02/30] docs(roadmap): drop controller options from the settings UI --- docs/ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7f985503..4fccbfa0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,7 +13,7 @@ Goal: a small GTK4 settings window, reachable from the app menu and from a `--settings` flag, that reads and writes the same `config.yaml`. - Graphics tab: backend, frame rate limit, vsync, small texture upscale. -- Input tab: the `input.raw_mouse` toggle and controller options. +- Input tab: the `input.raw_mouse` toggle. - Advanced tab: FFlag overrides with a plain text editor for `fflags.json`. - Discord tab: every field in the `discord_rpc` block, so a friend can see what you are playing and you can switch it all off in one click. From 8c3ae9d9d8993f2df48325c374aa13c99c3292df Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 9 Oct 2026 04:49:29 -0500 Subject: [PATCH 03/30] feat(runtime): add a config.yaml editor that keeps comments and layout Sets and unsets one key by editing only its bytes. Each result is parsed again and refused if any other setting changed. --- CMakeLists.txt | 1 + include/runtime/runtime_config_editor.h | 51 + src/runtime/runtime_config_editor.cc | 1244 +++++++++++++++++++++++ tests/CMakeLists.txt | 7 + tests/runtime_config_editor_test.cc | 831 +++++++++++++++ 5 files changed, 2134 insertions(+) create mode 100644 include/runtime/runtime_config_editor.h create mode 100644 src/runtime/runtime_config_editor.cc create mode 100644 tests/runtime_config_editor_test.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index 2efdf5d5..974ffe42 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -303,6 +303,7 @@ add_library(mocktail_runtime STATIC src/runtime/asset_overlay_names.cc src/runtime/runtime_config.cc src/runtime/runtime_config_bootstrap.cc + src/runtime/runtime_config_editor.cc src/runtime/runtime_config_file.cc src/runtime/runtime_paths.cc src/runtime/session_log.cc diff --git a/include/runtime/runtime_config_editor.h b/include/runtime/runtime_config_editor.h new file mode 100644 index 00000000..fce92c39 --- /dev/null +++ b/include/runtime/runtime_config_editor.h @@ -0,0 +1,51 @@ +#ifndef MOCKTAIL_RUNTIME_RUNTIME_CONFIG_EDITOR_H_ +#define MOCKTAIL_RUNTIME_RUNTIME_CONFIG_EDITOR_H_ + +#include +#include + +namespace mocktail { +namespace runtime { + +struct RuntimeConfigEdit { + std::string text; + std::string error; + + explicit operator bool() const { return error.empty(); } +}; + +// Returns `text`, the contents of a config.yaml, with the value at `key` set. +// `key` is a dotted path such as "graphics.vsync". Only the bytes that carry +// the key change: comments, order, line endings, and unrelated keys are kept. +// +// - A key with a value has that value replaced, whatever its YAML style. +// - A key that is present only as a comment ("# vsync: off") is uncommented +// in place, together with any commented section above it. +// - A key that is absent is added at the end of its section, and a missing +// section is added at the end of the file. +// - A key that holds a section becomes a single value; the section's lines +// stay in the file as comments. +// - A value that sits where a section is needed makes way for the section. +// +// `text` must be valid UTF-8 YAML whose top level is a mapping or empty. Every +// result is parsed again and compared with `text`; an edit that would change +// any other setting is refused with an error and no text. +RuntimeConfigEdit SetRuntimeConfigBool(std::string_view text, + std::string_view key, bool value); +RuntimeConfigEdit SetRuntimeConfigInteger(std::string_view text, + std::string_view key, + long long value); +RuntimeConfigEdit SetRuntimeConfigString(std::string_view text, + std::string_view key, + std::string_view value); + +// Returns `text` without `key`. The key's lines stay as comments, in the form +// the Set functions uncomment, and so does a section it leaves with no keys. +// A key the file does not have leaves `text` unchanged. +RuntimeConfigEdit UnsetRuntimeConfigValue(std::string_view text, + std::string_view key); + +} // namespace runtime +} // namespace mocktail + +#endif // MOCKTAIL_RUNTIME_RUNTIME_CONFIG_EDITOR_H_ diff --git a/src/runtime/runtime_config_editor.cc b/src/runtime/runtime_config_editor.cc new file mode 100644 index 00000000..3d5d744b --- /dev/null +++ b/src/runtime/runtime_config_editor.cc @@ -0,0 +1,1244 @@ +#include "runtime/runtime_config_editor.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mocktail { +namespace runtime { +namespace { + +constexpr std::size_t kIndentStep = 2; +// Separates the names of a flattened key path. It cannot appear in a key the +// editor accepts. +constexpr char kPathSeparator = '\x1f'; +constexpr char kEmptyCollection[] = "\x1e"; +// Aliases let a small file describe a huge tree. A file that needs more +// visits than this to walk is refused. +constexpr std::size_t kMaxNodeVisits = 100000; + +bool IsKeyCharacter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-'; +} + +bool SplitKey(std::string_view key, std::vector* path) { + while (true) { + const std::size_t dot = key.find('.'); + const std::string_view segment = key.substr(0, dot); + if (segment.empty()) { + return false; + } + for (const char c : segment) { + if (!IsKeyCharacter(c)) { + return false; + } + } + path->push_back(segment); + if (dot == std::string_view::npos) { + return true; + } + key.remove_prefix(dot + 1); + } +} + +// Length of the line break at `at`, using libyaml's set of breaks, or 0. +std::size_t BreakLength(std::string_view text, std::size_t at) { + const auto byte = [&text](std::size_t index) { + return index < text.size() ? static_cast(text[index]) : 0u; + }; + if (byte(at) == '\r') { + return byte(at + 1) == '\n' ? 2 : 1; + } + if (byte(at) == '\n') { + return 1; + } + if (byte(at) == 0xc2 && byte(at + 1) == 0x85) { + return 2; + } + if (byte(at) == 0xe2 && byte(at + 1) == 0x80 && + (byte(at + 2) == 0xa8 || byte(at + 2) == 0xa9)) { + return 3; + } + return 0; +} + +struct Edit { + std::size_t begin = 0; + std::size_t end = 0; + std::string text; +}; + +// One "# name:" line: a key that is commented out. +struct CommentedKey { + // Column the key has once uncommented. + std::size_t indent = 0; + std::string_view name; + // Everything after the colon. + std::string_view rest; +}; + +bool ParseCommentedKey(std::string_view line, CommentedKey* parsed) { + const std::size_t hash = line.find_first_not_of(' '); + if (hash == std::string_view::npos || line[hash] != '#') { + return false; + } + const std::size_t name_at = line.find_first_not_of(' ', hash + 1); + if (name_at == std::string_view::npos || name_at == hash + 1) { + return false; + } + std::size_t end = name_at; + while (end < line.size() && IsKeyCharacter(line[end])) { + ++end; + } + if (end == name_at || end == line.size() || line[end] != ':' || + (end + 1 < line.size() && line[end + 1] != ' ')) { + return false; + } + // "# key" uncomments to the column of the "#". + parsed->indent = name_at - 2; + parsed->name = line.substr(name_at, end - name_at); + parsed->rest = line.substr(end + 1); + return true; +} + +// The comment that follows a value, with the spaces before it, or empty. +std::string_view TrailingComment(std::string_view rest) { + char quote = '\0'; + for (std::size_t index = 0; index < rest.size(); ++index) { + const char c = rest[index]; + if (quote != '\0') { + if (c == '\\' && quote == '"') { + ++index; + } else if (c == quote) { + quote = '\0'; + } + continue; + } + if (c == '"' || c == '\'') { + quote = c; + } else if (c == '#' && index > 0 && rest[index - 1] == ' ') { + std::size_t start = index; + while (start > 0 && rest[start - 1] == ' ') { + --start; + } + const std::size_t value = rest.find_first_not_of(' '); + return rest.substr(value < start ? start : index - 1); + } + } + return {}; +} + +void AppendHexEscape(unsigned char byte, std::string* scalar) { + constexpr char kDigits[] = "0123456789ABCDEF"; + scalar->append("\\x"); + scalar->push_back(kDigits[byte >> 4]); + scalar->push_back(kDigits[byte & 0xf]); +} + +// The YAML text of a string value: plain when that reads back unchanged, +// otherwise double quoted with line breaks and control characters escaped. +std::string QuoteString(std::string_view value) { + bool plain = !value.empty() && value.front() != ' ' && value.back() != ' ' && + value.front() != '-'; + for (const char c : value) { + if (!(IsKeyCharacter(c) || c == '.' || c == '/' || c == ' ')) { + plain = false; + } + } + if (plain) { + return std::string(value); + } + const auto byte = [&value](std::size_t index) { + return index < value.size() ? static_cast(value[index]) + : 0u; + }; + std::string scalar(1, '"'); + for (std::size_t index = 0; index < value.size(); ++index) { + const unsigned c = byte(index); + if (c == '"' || c == '\\') { + scalar.push_back('\\'); + scalar.push_back(static_cast(c)); + } else if (c < 0x20 || c == 0x7f) { + AppendHexEscape(static_cast(c), &scalar); + } else if (c == 0xc2 && byte(index + 1) >= 0x80 && + byte(index + 1) <= 0x9f) { + // U+0080 to U+009F, which includes the next-line break U+0085. + AppendHexEscape(static_cast(byte(index + 1)), &scalar); + ++index; + } else if (c == 0xe2 && byte(index + 1) == 0x80 && + (byte(index + 2) == 0xa8 || byte(index + 2) == 0xa9)) { + scalar.append(byte(index + 2) == 0xa8 ? "\\L" : "\\P"); + index += 2; + } else if (c == 0xef && byte(index + 1) == 0xbb && + byte(index + 2) == 0xbf) { + scalar.append("\\uFEFF"); + index += 2; + } else { + scalar.push_back(static_cast(c)); + } + } + scalar.push_back('"'); + return scalar; +} + +std::string KeyText(std::size_t indent, std::string_view name) { + std::string text(indent, ' '); + text.append(name); + text.push_back(':'); + return text; +} + +std::string_view ScalarValue(const yaml_node_t* node) { + return {reinterpret_cast(node->data.scalar.value), + node->data.scalar.length}; +} + +bool IsNull(const yaml_node_t* node) { + return node->type == YAML_SCALAR_NODE && + node->start_mark.index == node->end_mark.index; +} + +bool IsBlockCollection(const yaml_node_t* node) { + return (node->type == YAML_MAPPING_NODE && + node->data.mapping.style != YAML_FLOW_MAPPING_STYLE) || + (node->type == YAML_SEQUENCE_NODE && + node->data.sequence.style != YAML_FLOW_SEQUENCE_STYLE); +} + +struct Pair { + yaml_node_t* mapping = nullptr; + yaml_node_t* key = nullptr; + yaml_node_t* value = nullptr; + // The value is written as "*name". `value` is then the node the alias + // names, whose text is elsewhere; the alias itself is at these bytes. + bool alias = false; + std::size_t alias_begin = 0; + std::size_t alias_end = 0; +}; + +struct LineInfo { + std::size_t begin = 0; + // End of the line's text, before its break. + std::size_t end = 0; + // Inside a value that spans lines, so a "#" here is not a comment. + bool content = false; + // Starts with a block-mapping key. + bool key = false; + std::size_t indent = 0; + std::string_view name; +}; + +// A parsed config file with the byte position of every node. +class Source { + public: + explicit Source(std::string_view text) : text_(text) {} + Source(const Source&) = delete; + Source& operator=(const Source&) = delete; + ~Source() { + if (loaded_) { + yaml_document_delete(&document_); + } + } + + bool Parse(std::string* error); + + std::string_view text() const { return text_; } + const std::string& eol() const { return eol_; } + std::vector& lines() { return lines_; } + yaml_node_t* root() { return yaml_document_get_root_node(&document_); } + yaml_node_t* Node(int index) { + return yaml_document_get_node(&document_, index); + } + // The block scalar whose text runs to the end of a file that has no final + // line break, or null. + yaml_node_t* open_block_scalar() { return open_block_scalar_; } + + // libyaml marks count characters and skip a byte order mark. + std::size_t Byte(const yaml_mark_t& mark) const { + return char_to_byte_[std::min(mark.index, char_to_byte_.size() - 1)]; + } + std::size_t LineAt(std::size_t byte) const { + std::size_t low = 0; + std::size_t high = lines_.size(); + while (high - low > 1) { + const std::size_t middle = low + (high - low) / 2; + (lines_[middle].begin <= byte ? low : high) = middle; + } + return low; + } + // Byte of a column that only spaces precede. + std::size_t ColumnByte(std::size_t line, std::size_t column) const { + return lines_[line].begin + (line == 0 ? bom_ : 0) + column; + } + + // One past the last byte the node's text occupies. libyaml ends a block + // collection at the next token and a block scalar after the blank lines + // that follow it; this stops at the node's own last character. + std::size_t LastByte(yaml_node_t* node); + // First byte after the key's colon, or the key's end when it has none. + std::size_t AfterColon(const Pair& pair, bool* has_colon = nullptr); + // One past the last byte of the pair's value, or of its colon when the + // value is empty. + std::size_t ValueEnd(const Pair& pair) { + return pair.alias ? pair.alias_end + : std::max(LastByte(pair.value), AfterColon(pair)); + } + // End of the anchor and tag that begin at `from` or after spaces that + // follow it, or `from` when there are none. + std::size_t PropertiesEnd(std::size_t from) const; + + Pair MakePair(yaml_node_t* mapping, const yaml_node_pair_t* raw); + Pair FindPair(yaml_node_t* mapping, std::string_view name); + void Flatten(yaml_node_t* node, const std::string& prefix, + std::map* leaves); + + private: + bool Check(yaml_node_t* node, std::vector* open, std::size_t* visits, + std::string* error); + void IndexNode(yaml_node_t* node); + + std::string_view text_; + std::size_t bom_ = 0; + std::string eol_ = "\n"; + std::vector char_to_byte_; + std::vector lines_; + yaml_document_t document_; + bool loaded_ = false; + yaml_node_t* open_block_scalar_ = nullptr; +}; + +bool Source::Parse(std::string* error) { + const auto byte = [this](std::size_t index) { + return index < text_.size() ? static_cast(text_[index]) + : 0u; + }; + if ((byte(0) == 0xff && byte(1) == 0xfe) || + (byte(0) == 0xfe && byte(1) == 0xff)) { + *error = "the file is not UTF-8"; + return false; + } + bom_ = byte(0) == 0xef && byte(1) == 0xbb && byte(2) == 0xbf ? 3 : 0; + for (std::size_t at = bom_; at < text_.size();) { + char_to_byte_.push_back(at); + const unsigned lead = byte(at); + at += lead < 0x80 ? 1 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : 4; + } + char_to_byte_.push_back(text_.size()); + + bool eol_found = false; + LineInfo line; + for (std::size_t at = 0; at < text_.size();) { + const std::size_t length = BreakLength(text_, at); + if (length == 0) { + ++at; + continue; + } + if (!eol_found) { + eol_found = true; + eol_ = std::string(text_.substr(at, length)); + } + line.end = at; + lines_.push_back(line); + at += length; + line.begin = at; + } + line.end = text_.size(); + lines_.push_back(line); + + yaml_parser_t parser; + if (!yaml_parser_initialize(&parser)) { + *error = "out of memory"; + return false; + } + yaml_parser_set_input_string( + &parser, reinterpret_cast(text_.data()), + text_.size()); + loaded_ = yaml_parser_load(&parser, &document_) != 0; + if (!loaded_) { + *error = "the file is not valid YAML"; + if (parser.problem != nullptr) { + *error += std::string(": ") + parser.problem + " on line " + + std::to_string(parser.problem_mark.line + 1); + } + } + yaml_parser_delete(&parser); + if (!loaded_) { + return false; + } + if (root() != nullptr) { + std::vector open(static_cast(document_.nodes.top - + document_.nodes.start)); + std::size_t visits = 0; + if (!Check(root(), &open, &visits, error)) { + return false; + } + IndexNode(root()); + } + return true; +} + +// Refuses a tree the other walks cannot finish or cannot compare: one that +// loops through an alias, one that aliases blow up, or one with a repeated +// key. +bool Source::Check(yaml_node_t* node, std::vector* open, + std::size_t* visits, std::string* error) { + if (++*visits > kMaxNodeVisits) { + *error = "the file's aliases repeat too much of it"; + return false; + } + if (node->type == YAML_SCALAR_NODE) { + return true; + } + char& is_open = (*open)[static_cast(node - document_.nodes.start)]; + if (is_open) { + *error = "an alias in the file names the section it is in"; + return false; + } + is_open = 1; + if (node->type == YAML_MAPPING_NODE) { + std::set names; + for (yaml_node_pair_t* pair = node->data.mapping.pairs.start; + pair < node->data.mapping.pairs.top; ++pair) { + yaml_node_t* key = Node(pair->key); + if (key->type == YAML_SCALAR_NODE && + !names.insert(ScalarValue(key)).second) { + *error = "the key " + std::string(ScalarValue(key)) + + " is set twice, again on line " + + std::to_string(key->start_mark.line + 1); + return false; + } + if (!Check(Node(pair->value), open, visits, error)) { + return false; + } + } + } else { + for (yaml_node_item_t* item = node->data.sequence.items.start; + item < node->data.sequence.items.top; ++item) { + if (!Check(Node(*item), open, visits, error)) { + return false; + } + } + } + is_open = 0; + return true; +} + +std::size_t Source::LastByte(yaml_node_t* node) { + if (node->type == YAML_SCALAR_NODE) { + const std::size_t begin = Byte(node->start_mark); + std::size_t end = Byte(node->end_mark); + if (node->data.scalar.style == YAML_LITERAL_SCALAR_STYLE || + node->data.scalar.style == YAML_FOLDED_SCALAR_STYLE) { + // Step back over the breaks and blank lines libyaml took with it. + while (end > begin + 1) { + const std::size_t line = LineAt(end - 1); + if (end > lines_[line].end) { + end = lines_[line].end; + } else if (text_[end - 1] == ' ') { + --end; + } else { + break; + } + } + } + return end; + } + if (!IsBlockCollection(node)) { + return Byte(node->end_mark); + } + if (node->type == YAML_MAPPING_NODE) { + if (node->data.mapping.pairs.start == node->data.mapping.pairs.top) { + return Byte(node->end_mark); + } + const yaml_node_pair_t* last = node->data.mapping.pairs.top - 1; + return std::max(Byte(Node(last->key)->end_mark), + LastByte(Node(last->value))); + } + if (node->data.sequence.items.start == node->data.sequence.items.top) { + return Byte(node->end_mark); + } + return LastByte(Node(*(node->data.sequence.items.top - 1))); +} + +std::size_t Source::AfterColon(const Pair& pair, bool* has_colon) { + std::size_t at = Byte(pair.key->end_mark); + const std::size_t colon = text_.find_first_not_of(' ', at); + const bool found = colon != std::string_view::npos && text_[colon] == ':'; + if (has_colon != nullptr) { + *has_colon = found; + } + return found ? colon + 1 : at; +} + +Pair Source::FindPair(yaml_node_t* mapping, std::string_view name) { + if (mapping == nullptr || mapping->type != YAML_MAPPING_NODE) { + return {}; + } + for (yaml_node_pair_t* pair = mapping->data.mapping.pairs.start; + pair < mapping->data.mapping.pairs.top; ++pair) { + yaml_node_t* key = Node(pair->key); + if (key->type == YAML_SCALAR_NODE && ScalarValue(key) == name) { + return MakePair(mapping, pair); + } + } + return {}; +} + +Pair Source::MakePair(yaml_node_t* mapping, const yaml_node_pair_t* raw) { + Pair pair; + pair.mapping = mapping; + pair.key = Node(raw->key); + pair.value = Node(raw->value); + bool has_colon = false; + const std::size_t star = + text_.find_first_not_of(' ', AfterColon(pair, &has_colon)); + // Only an alias can start with "*". + if (has_colon && star != std::string_view::npos && text_[star] == '*') { + const bool flow = mapping->data.mapping.style == YAML_FLOW_MAPPING_STYLE; + std::size_t end = star + 1; + while (end < text_.size() && text_[end] != ' ' && + BreakLength(text_, end) == 0 && + !(flow && (text_[end] == ',' || text_[end] == '}'))) { + ++end; + } + pair.alias = true; + pair.alias_begin = star; + pair.alias_end = end; + } + return pair; +} + +std::size_t Source::PropertiesEnd(std::size_t from) const { + std::size_t end = from; + while (true) { + const std::size_t at = text_.find_first_not_of(' ', end); + if (at == std::string_view::npos || + (text_[at] != '&' && text_[at] != '!')) { + return end; + } + end = at; + while (end < text_.size() && text_[end] != ' ' && + BreakLength(text_, end) == 0) { + ++end; + } + } +} + +void Source::Flatten(yaml_node_t* node, const std::string& prefix, + std::map* leaves) { + if (node->type == YAML_SCALAR_NODE) { + (*leaves)[prefix] = std::string(ScalarValue(node)); + return; + } + bool empty = true; + if (node->type == YAML_MAPPING_NODE) { + for (yaml_node_pair_t* pair = node->data.mapping.pairs.start; + pair < node->data.mapping.pairs.top; ++pair) { + empty = false; + yaml_node_t* key = Node(pair->key); + const std::string name = key->type == YAML_SCALAR_NODE + ? std::string(ScalarValue(key)) + : std::string("?"); + Flatten(Node(pair->value), + prefix.empty() ? name : prefix + kPathSeparator + name, leaves); + } + } else { + std::size_t index = 0; + for (yaml_node_item_t* item = node->data.sequence.items.start; + item < node->data.sequence.items.top; ++item, ++index) { + empty = false; + Flatten(Node(*item), prefix + kPathSeparator + std::to_string(index), + leaves); + } + } + if (empty) { + (*leaves)[prefix] = kEmptyCollection; + } +} + +void Source::IndexNode(yaml_node_t* node) { + const std::size_t first = LineAt(Byte(node->start_mark)); + const std::size_t last_byte = LastByte(node); + const std::size_t last = LineAt(last_byte > 0 ? last_byte - 1 : 0); + if (!IsBlockCollection(node)) { + for (std::size_t line = first + 1; line <= last; ++line) { + lines_[line].content = true; + } + if (node->type == YAML_SCALAR_NODE && last > first && + last_byte == text_.size() && + (node->data.scalar.style == YAML_LITERAL_SCALAR_STYLE || + node->data.scalar.style == YAML_FOLDED_SCALAR_STYLE)) { + open_block_scalar_ = node; + } + return; + } + if (node->type == YAML_SEQUENCE_NODE) { + for (yaml_node_item_t* item = node->data.sequence.items.start; + item < node->data.sequence.items.top; ++item) { + IndexNode(Node(*item)); + } + return; + } + for (yaml_node_pair_t* pair = node->data.mapping.pairs.start; + pair < node->data.mapping.pairs.top; ++pair) { + yaml_node_t* key = Node(pair->key); + const std::size_t key_byte = Byte(key->start_mark); + LineInfo& line = lines_[LineAt(key_byte)]; + if (key->type == YAML_SCALAR_NODE && + ColumnByte(LineAt(key_byte), key->start_mark.column) == key_byte && + text_.substr(line.begin, key_byte - line.begin) + .find_first_not_of(" \xef\xbb\xbf") == + std::string_view::npos) { + line.key = true; + line.indent = key->start_mark.column; + line.name = ScalarValue(key); + } + IndexNode(Node(pair->value)); + } +} + +// First byte of a line's text that is not a space, or its end. +std::size_t FirstText(Source& source, std::size_t line) { + const LineInfo& info = source.lines()[line]; + const std::size_t at = + source.text().find_first_not_of(' ', source.ColumnByte(line, 0)); + return at == std::string_view::npos || at > info.end ? info.end : at; +} + +// Comments out every line of text in [first, last], putting the "#" at +// `column` or at the line's own indentation when that is shallower. +void CommentOut(Source& source, std::size_t first, std::size_t last, + std::size_t column, std::vector* edits) { + for (std::size_t line = first; line <= last; ++line) { + const LineInfo& info = source.lines()[line]; + const std::size_t text = FirstText(source, line); + if (text == info.end || + (source.text()[text] == '#' && !info.content)) { + continue; + } + const std::size_t at = std::min(source.ColumnByte(line, column), text); + edits->push_back({at, at, "# "}); + } +} + +// Last line of the section that ends at `anchor`, counting the comments below +// it that are indented to at least `min_column`. Blank lines and shallower +// comments in between are passed over. +std::size_t ExtendOverComments(Source& source, std::size_t anchor, + std::size_t min_column) { + for (std::size_t line = anchor + 1; line < source.lines().size(); ++line) { + const LineInfo& info = source.lines()[line]; + const std::size_t text = FirstText(source, line); + if (text == info.end) { + continue; + } + if (info.key || info.content || source.text()[text] != '#') { + break; + } + if (text - source.ColumnByte(line, 0) >= min_column) { + anchor = line; + } + } + return anchor; +} + +void InsertLinesAfter(Source& source, std::size_t line, + const std::vector& added, + std::vector* edits) { + Edit edit; + if (line + 1 < source.lines().size()) { + edit.begin = edit.end = source.lines()[line + 1].begin; + for (const std::string& text : added) { + edit.text += text + source.eol(); + } + } else { + edit.begin = edit.end = source.text().size(); + for (const std::string& text : added) { + edit.text += source.eol() + text; + } + edit.text += source.eol(); + if (yaml_node_t* scalar = source.open_block_scalar()) { + // The line break this adds would become part of the scalar. Stripping + // its final breaks keeps the value it has now. + const std::size_t header = source.Byte(scalar->start_mark); + const std::size_t header_end = + source.lines()[source.LineAt(header)].end; + std::string_view indicators = + source.text().substr(header, header_end - header); + indicators = indicators.substr(0, indicators.find_first_of(" #")); + const std::size_t keep = indicators.find('+'); + if (keep != std::string_view::npos) { + edits->push_back({header + keep, header + keep + 1, "-"}); + } else if (indicators.find('-') == std::string_view::npos) { + edits->push_back({header + 1, header + 1, "-"}); + } + } + } + edits->push_back(std::move(edit)); +} + +void AppendLines(Source& source, const std::vector& added, + std::vector* edits) { + const std::size_t last = source.lines().size() - 1; + if (source.lines()[last].begin != source.lines()[last].end) { + InsertLinesAfter(source, last, added, edits); + return; + } + Edit edit; + edit.begin = edit.end = source.text().size(); + for (const std::string& text : added) { + edit.text += text + source.eol(); + } + edits->push_back(std::move(edit)); +} + +std::string Apply(std::string_view text, std::vector edits) { + std::stable_sort(edits.begin(), edits.end(), + [](const Edit& left, const Edit& right) { + return left.begin < right.begin; + }); + std::string result; + std::size_t at = 0; + for (const Edit& edit : edits) { + if (edit.begin < at) { + // Inside text an earlier edit already replaced. + continue; + } + result.append(text.substr(at, edit.begin - at)); + result.append(edit.text); + at = std::max(at, edit.end); + } + result.append(text.substr(at)); + return result; +} + +struct StackEntry { + std::size_t indent = 0; + std::string_view name; + std::size_t line = 0; +}; + +void PopToParentOf(std::size_t indent, std::vector* entries) { + while (!entries->empty() && entries->back().indent >= indent) { + entries->pop_back(); + } +} + +// Whether `entries` followed by `name` is a prefix of `path`, or `path`. +bool OnPath(const std::vector& entries, std::string_view name, + const std::vector& path) { + if (entries.size() >= path.size() || path[entries.size()] != name) { + return false; + } + for (std::size_t index = 0; index < entries.size(); ++index) { + if (entries[index].name != path[index]) { + return false; + } + } + return true; +} + +// Lines of the commented-out keys that lead from the section holding the +// first `depth` names of `path` down to `path` itself, or empty. Commented +// keys above that section are not used: uncommenting one would repeat a key +// the file already has. Nor is a chain used when the next line of settings +// is indented past `column`, the column the chain's first key would take: +// uncommented there, the chain would cut that line off from its section. +std::vector FindCommentedChain( + Source& source, const std::vector& path, + std::size_t depth, std::size_t column) { + std::vector active; + std::vector overlay; + std::size_t overlay_base = 0; + // Indentation of the last commented key that is off the path. Commented + // keys indented past it belong to it. + bool off_path = false; + std::size_t off_path_indent = 0; + for (std::size_t index = 0; index < source.lines().size(); ++index) { + const LineInfo& info = source.lines()[index]; + if (info.key) { + off_path = false; + overlay.clear(); + PopToParentOf(info.indent, &active); + active.push_back({info.indent, info.name, index}); + continue; + } + if (info.content) { + continue; + } + const std::size_t begin = source.ColumnByte(index, 0); + CommentedKey parsed; + if (begin > info.end || + !ParseCommentedKey(source.text().substr(begin, info.end - begin), + &parsed)) { + continue; + } + if (off_path && parsed.indent > off_path_indent) { + continue; + } + off_path = false; + std::vector chain = active; + if (!overlay.empty()) { + chain.resize(overlay_base); + chain.insert(chain.end(), overlay.begin(), overlay.end()); + } + PopToParentOf(parsed.indent, &chain); + if (!OnPath(chain, parsed.name, path)) { + // A commented key off the path still ends the commented sections that + // are indented at least as far. + PopToParentOf(parsed.indent, &overlay); + off_path = true; + off_path_indent = parsed.indent; + continue; + } + if (overlay.empty() || chain.size() < overlay_base) { + overlay_base = chain.size(); + } + overlay.assign(chain.begin() + static_cast(overlay_base), + chain.end()); + overlay.push_back({parsed.indent, parsed.name, index}); + if (chain.size() + 1 != path.size() || overlay_base != depth) { + continue; + } + bool cuts_a_section = false; + for (std::size_t next = index + 1; next < source.lines().size(); ++next) { + const LineInfo& following = source.lines()[next]; + const std::size_t text = FirstText(source, next); + if (text == following.end || + (source.text()[text] == '#' && !following.content)) { + continue; + } + cuts_a_section = following.content || + text - source.ColumnByte(next, 0) > column; + break; + } + if (!cuts_a_section) { + std::vector lines; + for (const StackEntry& entry : overlay) { + lines.push_back(entry.line); + } + return lines; + } + } + return {}; +} + +// "a: {b: {c: value}}" for the names of `path` from `depth` on. +std::string FlowText(const std::vector& path, + std::size_t depth, const std::string& scalar) { + std::string text; + for (std::size_t index = depth; index < path.size(); ++index) { + text += std::string(path[index]) + ": "; + if (index + 1 < path.size()) { + text += "{"; + } + } + text += scalar; + text.append(path.size() - depth - 1, '}'); + return text; +} + +std::string JoinPath(const std::vector& path, + std::size_t count) { + std::string joined; + for (std::size_t index = 0; index < count; ++index) { + if (index > 0) { + joined.push_back(kPathSeparator); + } + joined.append(path[index]); + } + return joined; +} + +void EraseSubtree(const std::string& prefix, + std::map* leaves) { + leaves->erase(prefix); + const std::string children = prefix + kPathSeparator; + auto it = leaves->lower_bound(children); + while (it != leaves->end() && + it->first.compare(0, children.size(), children) == 0) { + it = leaves->erase(it); + } +} + +RuntimeConfigEdit Fail(std::string_view key, const std::string& reason) { + RuntimeConfigEdit edit; + edit.error = "cannot change " + std::string(key) + ": " + reason; + return edit; +} + +bool IsFlowMapping(const yaml_node_t* node) { + return node->type == YAML_MAPPING_NODE && + node->data.mapping.style == YAML_FLOW_MAPPING_STYLE; +} + +std::size_t PairCount(const yaml_node_t* mapping) { + return static_cast(mapping->data.mapping.pairs.top - + mapping->data.mapping.pairs.start); +} + +// Writes `text` as the value of a key that has none yet. +void FillEmptyValue(Source& source, const Pair& pair, const std::string& text, + std::vector* edits) { + bool has_colon = false; + const std::size_t at = source.AfterColon(pair, &has_colon); + edits->push_back({at, at, (has_colon ? " " : ": ") + text}); +} + +// Comments out the lines of a block section or list below its key. An +// anchor or tag can put the value's start on the key's own line; that line +// stays. +void CommentOutBlockValue(Source& source, const Pair& pair, + std::vector* edits) { + const std::size_t key_line = + source.LineAt(source.Byte(pair.key->start_mark)); + CommentOut(source, + std::max(source.LineAt(source.Byte(pair.value->start_mark)), + key_line + 1), + source.LineAt(source.LastByte(pair.value) - 1), + pair.key->start_mark.column, edits); +} + +// Last line a block section owns. A block value that keeps its final line +// breaks ("|+") owns the blank lines after it. +std::size_t LastLine(Source& source, yaml_node_t* node) { + std::size_t line = source.LineAt(source.LastByte(node) - 1); + yaml_node_t* leaf = node; + while (IsBlockCollection(leaf)) { + if (leaf->type == YAML_MAPPING_NODE) { + if (PairCount(leaf) == 0) { + return line; + } + leaf = source.Node((leaf->data.mapping.pairs.top - 1)->value); + } else { + if (leaf->data.sequence.items.start == leaf->data.sequence.items.top) { + return line; + } + leaf = source.Node(*(leaf->data.sequence.items.top - 1)); + } + } + if (leaf->type != YAML_SCALAR_NODE || + (leaf->data.scalar.style != YAML_LITERAL_SCALAR_STYLE && + leaf->data.scalar.style != YAML_FOLDED_SCALAR_STYLE) || + source.LineAt(source.LastByte(leaf) - 1) != line) { + return line; + } + const std::size_t header = source.Byte(leaf->start_mark); + std::string_view indicators = source.text().substr( + header, source.lines()[source.LineAt(header)].end - header); + indicators = indicators.substr(0, indicators.find_first_of(" #")); + if (indicators.find('+') == std::string_view::npos) { + return line; + } + // The last entry of lines() is the text after the final line break, which + // the value does not own. + while (line + 2 < source.lines().size() && + FirstText(source, line + 1) == source.lines()[line + 1].end) { + ++line; + } + return line; +} + +void SetExisting(Source& source, const Pair& pair, const std::string& scalar, + std::vector* edits) { + yaml_node_t* value = pair.value; + if (pair.alias) { + edits->push_back({pair.alias_begin, pair.alias_end, scalar}); + } else if (IsNull(value)) { + FillEmptyValue(source, pair, scalar, edits); + } else if (IsBlockCollection(value)) { + // A section becomes a single value: its lines stay as comments. + CommentOutBlockValue(source, pair, edits); + const std::size_t at = source.PropertiesEnd(source.AfterColon(pair)); + edits->push_back({at, at, " " + scalar}); + } else { + // An anchor or tag in front of the value stays. + const std::size_t begin = source.Byte(value->start_mark); + const std::size_t end = source.LastByte(value); + const std::size_t properties = source.PropertiesEnd(begin); + const std::size_t content = + source.text().find_first_not_of(" \r\n", properties); + if (properties == begin) { + edits->push_back({begin, end, scalar}); + } else if (content == std::string_view::npos || content >= end) { + edits->push_back({properties, properties, " " + scalar}); + } else { + edits->push_back({content, end, scalar}); + } + } +} + +// Adds a key that the file does not have. `chain` holds the sections of +// `path` that do exist. Returns whether the innermost one held a value that +// had to make way for a section. +bool SetMissing(Source& source, yaml_node_t* root, + const std::vector& chain, + const std::vector& path, + const std::string& scalar, std::vector* edits) { + const std::size_t depth = chain.size(); + yaml_node_t* container = depth > 0 ? chain.back().value : root; + const bool is_mapping = + container != nullptr && container->type == YAML_MAPPING_NODE; + + if (depth > 0 && !is_mapping && IsFlowMapping(chain.back().mapping)) { + const std::string text = "{" + FlowText(path, depth, scalar) + "}"; + if (IsNull(container)) { + FillEmptyValue(source, chain.back(), text, edits); + } else { + edits->push_back({source.Byte(container->start_mark), + source.LastByte(container), text}); + } + return true; + } + if (is_mapping && IsFlowMapping(container)) { + if (PairCount(container) == 0) { + const std::size_t at = + source.text().find('{', source.Byte(container->start_mark)) + 1; + edits->push_back({at, at, FlowText(path, depth, scalar)}); + } else { + const std::size_t at = source.ValueEnd(source.MakePair( + container, container->data.mapping.pairs.top - 1)); + edits->push_back({at, at, ", " + FlowText(path, depth, scalar)}); + } + return false; + } + + std::size_t indent = 0; + std::size_t anchor = 0; + if (depth > 0 && !is_mapping) { + const Pair& parent = chain.back(); + const std::size_t column = parent.key->start_mark.column; + const std::size_t after = source.AfterColon(parent); + if (IsBlockCollection(container)) { + CommentOutBlockValue(source, parent, edits); + } else if (!IsNull(container)) { + edits->push_back({after, source.LastByte(container), ""}); + } + indent = column + kIndentStep; + anchor = ExtendOverComments( + source, + source.LineAt(std::max(source.LastByte(container), after) - 1), + column + 1); + } else if (container != nullptr) { + indent = source.Node(container->data.mapping.pairs.start->key) + ->start_mark.column; + anchor = ExtendOverComments( + source, LastLine(source, container), + depth > 0 ? chain.back().key->start_mark.column + 1 : 0); + } + + const std::vector commented = + FindCommentedChain(source, path, depth, indent); + if (!commented.empty()) { + // The first key takes the column its section's keys use, which need not + // be the column it was commented out at. The rest keep their offset. + std::size_t first_indent = 0; + for (const std::size_t line : commented) { + const LineInfo& info = source.lines()[line]; + const std::size_t begin = source.ColumnByte(line, 0); + CommentedKey parsed; + ParseCommentedKey(source.text().substr(begin, info.end - begin), + &parsed); + if (line == commented.front()) { + first_indent = parsed.indent; + } + std::string text = + KeyText(indent + (parsed.indent - first_indent), parsed.name); + // A commented key on the way down becomes a section, so only the last + // one keeps a value. + if (line == commented.back()) { + text += " " + scalar; + } + text += std::string(TrailingComment(parsed.rest)); + edits->push_back({begin, info.end, std::move(text)}); + } + } else { + std::vector added; + for (std::size_t index = depth; index < path.size(); ++index) { + added.push_back(KeyText(indent, path[index])); + indent += kIndentStep; + } + added.back() += " " + scalar; + if (container == nullptr) { + AppendLines(source, added, edits); + } else { + InsertLinesAfter(source, anchor, added, edits); + } + } + return depth > 0 && !is_mapping; +} + +void Unset(Source& source, const std::vector& chain, + std::vector* edits) { + const Pair& pair = chain.back(); + const std::size_t key_begin = source.Byte(pair.key->start_mark); + const std::size_t value_end = source.ValueEnd(pair); + if (IsFlowMapping(pair.mapping)) { + yaml_node_pair_t* raw = pair.mapping->data.mapping.pairs.start; + while (source.Node(raw->key) != pair.key) { + ++raw; + } + if (raw + 1 < pair.mapping->data.mapping.pairs.top) { + edits->push_back( + {key_begin, source.Byte(source.Node((raw + 1)->key)->start_mark), + ""}); + } else if (raw > pair.mapping->data.mapping.pairs.start) { + edits->push_back( + {source.ValueEnd(source.MakePair(pair.mapping, raw - 1)), value_end, + ""}); + } else { + // The only pair: clear everything between the braces, which takes a + // trailing comma with it. + edits->push_back( + {source.text().find('{', source.Byte(pair.mapping->start_mark)) + 1, + source.Byte(pair.mapping->end_mark) - 1, ""}); + } + return; + } + CommentOut(source, source.LineAt(key_begin), source.LineAt(value_end - 1), + pair.key->start_mark.column, edits); + // A section left without a key is commented out too, so the file never + // holds a section with nothing in it. + for (std::size_t depth = chain.size(); depth >= 2; --depth) { + const Pair& parent = chain[depth - 2]; + if (PairCount(chain[depth - 1].mapping) != 1 || + IsFlowMapping(parent.mapping)) { + break; + } + const std::size_t line = + source.LineAt(source.Byte(parent.key->start_mark)); + CommentOut(source, line, line, parent.key->start_mark.column, edits); + } +} + +// The file's settings: its top-level node, or null when it has none. A file +// that holds only a "---" marker has an empty value there. +yaml_node_t* Settings(Source& source) { + yaml_node_t* root = source.root(); + return root != nullptr && IsNull(root) ? nullptr : root; +} + +// Sets `key` to `scalar`, whose parsed value is `raw`, or unsets it when +// `scalar` is null. The result is parsed again and refused unless it differs +// from `text` in that key alone. +RuntimeConfigEdit Change(std::string_view text, std::string_view key, + const std::string* scalar, std::string_view raw) { + std::vector path; + if (!SplitKey(key, &path)) { + return Fail(key, "the key is not a dotted list of names"); + } + Source source(text); + std::string error; + if (!source.Parse(&error)) { + return Fail(key, error); + } + yaml_node_t* root = Settings(source); + if (root != nullptr && root->type != YAML_MAPPING_NODE) { + return Fail(key, "the top level of the file is not a list of settings"); + } + + std::vector chain; + for (yaml_node_t* node = root; + node != nullptr && chain.size() < path.size();) { + const Pair pair = source.FindPair(node, path[chain.size()]); + if (pair.key == nullptr) { + break; + } + chain.push_back(pair); + if (pair.alias && chain.size() < path.size()) { + // The keys below an alias are written in another section's lines, so + // changing one would change that section too. + return Fail(key, std::string(path[chain.size() - 1]) + + " is a YAML alias, and a key below an alias " + "cannot be changed on its own"); + } + node = pair.value; + } + const std::size_t depth = chain.size(); + const bool found = depth == path.size(); + + std::map expected; + if (root != nullptr) { + source.Flatten(root, "", &expected); + } + std::vector edits; + bool container_replaced = false; + if (scalar == nullptr) { + if (found) { + Unset(source, chain, &edits); + } + } else if (found) { + SetExisting(source, chain.back(), *scalar, &edits); + } else { + container_replaced = SetMissing(source, root, chain, path, *scalar, &edits); + } + + RuntimeConfigEdit edit; + edit.text = Apply(text, std::move(edits)); + + Source result(edit.text); + if (!result.Parse(&error)) { + return Fail(key, "the edit would leave the file unreadable"); + } + std::map actual; + if (yaml_node_t* result_root = Settings(result)) { + result.Flatten(result_root, "", &actual); + } + EraseSubtree(JoinPath(path, path.size()), &expected); + if (container_replaced) { + EraseSubtree(JoinPath(path, depth), &expected); + } + for (std::size_t count = 1; count < path.size(); ++count) { + // A section on the way is a value before it has keys, and again once its + // last key is gone. + expected.erase(JoinPath(path, count)); + if (scalar == nullptr) { + actual.erase(JoinPath(path, count)); + } + } + if (scalar != nullptr) { + expected[JoinPath(path, path.size())] = std::string(raw); + } + if (actual != expected) { + return Fail(key, "the edit would change other settings"); + } + return edit; +} + +} // namespace + +RuntimeConfigEdit SetRuntimeConfigBool(std::string_view text, + std::string_view key, bool value) { + const std::string scalar = value ? "true" : "false"; + return Change(text, key, &scalar, scalar); +} + +RuntimeConfigEdit SetRuntimeConfigInteger(std::string_view text, + std::string_view key, + long long value) { + const std::string scalar = std::to_string(value); + return Change(text, key, &scalar, scalar); +} + +RuntimeConfigEdit SetRuntimeConfigString(std::string_view text, + std::string_view key, + std::string_view value) { + const std::string scalar = QuoteString(value); + return Change(text, key, &scalar, value); +} + +RuntimeConfigEdit UnsetRuntimeConfigValue(std::string_view text, + std::string_view key) { + return Change(text, key, nullptr, {}); +} + +} // namespace runtime +} // namespace mocktail diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2305b738..3d505e86 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -51,6 +51,12 @@ if(MOCKTAIL_TEST_PYTHON AND MOCKTAIL_TEST_OPENSSL) set_tests_properties(FleasionIntegrationTest.ProxyAndTrust PROPERTIES TIMEOUT 45) endif() +add_executable(runtime_config_editor_test runtime_config_editor_test.cc) +target_link_libraries(runtime_config_editor_test PRIVATE + Mocktail::Runtime + GTest::gtest_main +) + add_executable(runtime_config_file_test runtime_config_file_test.cc) target_link_libraries(runtime_config_file_test PRIVATE Mocktail::Runtime @@ -1043,6 +1049,7 @@ gtest_discover_tests(jnivm_test) gtest_discover_tests(jnivm_fields_test) gtest_discover_tests(jnivm_fmod_write_allocation_test) gtest_discover_tests(runtime_config_test) +gtest_discover_tests(runtime_config_editor_test) gtest_discover_tests(runtime_config_file_test) gtest_discover_tests(payload_update_preflight_test) gtest_discover_tests(apk_provider_test) diff --git a/tests/runtime_config_editor_test.cc b/tests/runtime_config_editor_test.cc new file mode 100644 index 00000000..87da38f8 --- /dev/null +++ b/tests/runtime_config_editor_test.cc @@ -0,0 +1,831 @@ +#include "runtime/runtime_config_editor.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "runtime/runtime_config_bootstrap.h" +#include "runtime/runtime_config_file.h" + +namespace mocktail { +namespace runtime { +namespace { + +class EmptyEnvironment final : public Environment { + public: + std::optional Get(std::string_view) const override { + return std::nullopt; + } +}; + +RuntimeConfigLoadResult Load(std::string_view contents) { + char pattern[] = "/tmp/mocktail_config_editor_XXXXXX"; + const char* created = mkdtemp(pattern); + const std::filesystem::path directory = created != nullptr ? created : ""; + const std::filesystem::path file = directory / "config.yaml"; + { std::ofstream(file) << contents; } + RuntimeConfigLoadResult result = LoadRuntimeConfig(EmptyEnvironment(), file); + std::error_code error; + std::filesystem::remove_all(directory, error); + return result; +} + +std::size_t LineCount(std::string_view text) { + return static_cast(std::count(text.begin(), text.end(), '\n')); +} + +bool Contains(std::string_view text, std::string_view needle) { + return text.find(needle) != std::string_view::npos; +} + +TEST(RuntimeConfigEditorTest, ReplacesAnActiveValueAndNothingElse) { + const std::string before = + "# top comment\n" + "graphics:\n" + " # String: backend.\n" + " backend: direct-vulkan\n" + "window:\n" + " width: 1280\n"; + + const RuntimeConfigEdit edit = + SetRuntimeConfigString(before, "graphics.backend", "opengl"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "# top comment\n" + "graphics:\n" + " # String: backend.\n" + " backend: opengl\n" + "window:\n" + " width: 1280\n"); +} + +TEST(RuntimeConfigEditorTest, TellsSameNamedKeysInDifferentSectionsApart) { + const std::string before = + "integrations:\n" + " fleasion:\n" + " enabled: false\n" + " discord_rpc:\n" + " enabled: true\n"; + + const RuntimeConfigEdit edit = + SetRuntimeConfigBool(before, "integrations.discord_rpc.enabled", false); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "integrations:\n" + " fleasion:\n" + " enabled: false\n" + " discord_rpc:\n" + " enabled: false\n"); +} + +TEST(RuntimeConfigEditorTest, UncommentsAKeyInPlace) { + const std::string before = + "graphics:\n" + " backend: direct-vulkan\n" + " # Optional presentation override: auto, on, or off.\n" + " # vsync: off\n" + "performance:\n" + " gamemode: auto\n"; + + const RuntimeConfigEdit edit = + SetRuntimeConfigString(before, "graphics.vsync", "on"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "graphics:\n" + " backend: direct-vulkan\n" + " # Optional presentation override: auto, on, or off.\n" + " vsync: on\n" + "performance:\n" + " gamemode: auto\n"); +} + +TEST(RuntimeConfigEditorTest, UncommentsTheSectionAboveANestedKey) { + const std::string before = + "integrations:\n" + " discord_rpc:\n" + " enabled: true\n" + " # text:\n" + " # browsing: Browsing experiences\n" + " # playing: \"Playing {place_name}\"\n" + " # images:\n" + " # large: \"{place_icon}\"\n"; + + const RuntimeConfigEdit edit = SetRuntimeConfigString( + before, "integrations.discord_rpc.text.playing", "In {place_name}"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "integrations:\n" + " discord_rpc:\n" + " enabled: true\n" + " text:\n" + " # browsing: Browsing experiences\n" + " playing: \"In {place_name}\"\n" + " # images:\n" + " # large: \"{place_icon}\"\n"); +} + +TEST(RuntimeConfigEditorTest, PrefersAnActiveKeyOverACommentedOne) { + const std::string before = + "graphics:\n" + " # vsync: off\n" + " vsync: auto\n"; + + const RuntimeConfigEdit edit = + SetRuntimeConfigString(before, "graphics.vsync", "on"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "graphics:\n" + " # vsync: off\n" + " vsync: on\n"); +} + +TEST(RuntimeConfigEditorTest, AddsAMissingKeyAtTheEndOfItsSection) { + const std::string before = + "input:\n" + " touch: false\n" + "\n" + "window:\n" + " width: 1280\n"; + + const RuntimeConfigEdit edit = + SetRuntimeConfigBool(before, "input.raw_mouse", true); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "input:\n" + " touch: false\n" + " raw_mouse: true\n" + "\n" + "window:\n" + " width: 1280\n"); +} + +TEST(RuntimeConfigEditorTest, AddsAMissingSectionAtTheEndOfTheFile) { + const RuntimeConfigEdit edit = SetRuntimeConfigInteger( + "window:\n width: 1280", "performance.memory_limit_mb", 6144); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "window:\n" + " width: 1280\n" + "performance:\n" + " memory_limit_mb: 6144\n"); +} + +TEST(RuntimeConfigEditorTest, AddsMissingNestedSections) { + const RuntimeConfigEdit edit = SetRuntimeConfigBool( + "integrations:\n fleasion:\n enabled: false\n", + "integrations.discord_rpc.join.enabled", false); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "integrations:\n" + " fleasion:\n" + " enabled: false\n" + " discord_rpc:\n" + " join:\n" + " enabled: false\n"); +} + +TEST(RuntimeConfigEditorTest, QuotesOnlyStringsThatNeedIt) { + const std::string before = "window:\n title: Roblox\n"; + + EXPECT_EQ(SetRuntimeConfigString(before, "window.title", "My Game").text, + "window:\n title: My Game\n"); + EXPECT_EQ(SetRuntimeConfigString(before, "window.title", "").text, + "window:\n title: \"\"\n"); + EXPECT_EQ(SetRuntimeConfigString(before, "window.title", "a: b #c").text, + "window:\n title: \"a: b #c\"\n"); + EXPECT_EQ( + SetRuntimeConfigString(before, "window.title", "say \"hi\" \\ bye").text, + "window:\n title: \"say \\\"hi\\\" \\\\ bye\"\n"); + EXPECT_EQ(SetRuntimeConfigString(before, "window.title", " padded ").text, + "window:\n title: \" padded \"\n"); +} + +TEST(RuntimeConfigEditorTest, KeepsACommentAfterTheValue) { + const RuntimeConfigEdit edit = SetRuntimeConfigInteger( + "window:\n width: 1280 # my monitor\n", "window.width", 1920); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, "window:\n width: 1920 # my monitor\n"); +} + +TEST(RuntimeConfigEditorTest, DoesNotTreatAHashInsideQuotesAsAComment) { + const RuntimeConfigEdit edit = SetRuntimeConfigString( + "window:\n title: \"a # b\"\n", "window.title", "plain"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, "window:\n title: plain\n"); +} + +TEST(RuntimeConfigEditorTest, TurnsASectionIntoAValueAndKeepsItsLines) { + const std::string before = + "device:\n" + " type: mobile\n" + " # a note\n" + " name: Pixel\n" + "window:\n" + " width: 1280\n"; + + const RuntimeConfigEdit edit = + SetRuntimeConfigString(before, "device", "pc-windows-11"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "device: pc-windows-11\n" + "# type: mobile\n" + " # a note\n" + "# name: Pixel\n" + "window:\n" + " width: 1280\n"); +} + +TEST(RuntimeConfigEditorTest, TurnsAValueIntoASection) { + const RuntimeConfigEdit edit = SetRuntimeConfigString( + "device: pc-windows-11 # preset\nwindow:\n width: 1280\n", + "device.type", "mobile"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "device: # preset\n" + " type: mobile\n" + "window:\n" + " width: 1280\n"); +} + +TEST(RuntimeConfigEditorTest, ASectionCanGoToAValueAndBack) { + const std::string section = "device:\n type: mobile\n name: Pixel\n"; + + const RuntimeConfigEdit value = + SetRuntimeConfigString(section, "device", "pc"); + ASSERT_TRUE(value) << value.error; + const RuntimeConfigEdit back = + SetRuntimeConfigString(value.text, "device.type", "console"); + ASSERT_TRUE(back) << back.error; + EXPECT_EQ(back.text, "device:\n type: console\n# name: Pixel\n"); +} + +TEST(RuntimeConfigEditorTest, DoesNotUncommentASectionTheFileAlreadyHas) { + // Uncommenting "# device:" would give the file two device keys. + const std::string before = + "device: pc\n" + "# device:\n" + "# type: mobile\n"; + + const RuntimeConfigEdit edit = + SetRuntimeConfigString(before, "device.type", "console"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "device:\n" + " type: console\n" + "# device:\n" + "# type: mobile\n"); +} + +TEST(RuntimeConfigEditorTest, FillsASectionThatHoldsOnlyComments) { + const RuntimeConfigEdit edit = SetRuntimeConfigBool( + "input:\n # nothing set\nwindow:\n width: 1\n", "input.raw_mouse", + true); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "input:\n # nothing set\n raw_mouse: true\nwindow:\n width: 1\n"); +} + +TEST(RuntimeConfigEditorTest, UnsetCommentsTheKeyOut) { + const std::string before = + "graphics:\n" + " backend: opengl\n" + " vsync: on\n" + "window:\n" + " width: 1280\n"; + + const RuntimeConfigEdit edit = + UnsetRuntimeConfigValue(before, "graphics.vsync"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "graphics:\n" + " backend: opengl\n" + " # vsync: on\n" + "window:\n" + " width: 1280\n"); + EXPECT_EQ(SetRuntimeConfigString(edit.text, "graphics.vsync", "on").text, + before); +} + +TEST(RuntimeConfigEditorTest, UnsetOfAnAbsentKeyChangesNothing) { + const std::string before = "graphics:\n # vsync: off\n"; + const RuntimeConfigEdit edit = + UnsetRuntimeConfigValue(before, "graphics.vsync"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, before); + EXPECT_EQ(UnsetRuntimeConfigValue(before, "audio.output_device").text, + before); +} + +TEST(RuntimeConfigEditorTest, UnsetOfTheLastKeyCommentsItsSectionOutToo) { + const std::string before = + "integrations:\n" + " discord_rpc:\n" + " enabled: true\n" + " text:\n" + " playing: \"In {place_name}\"\n"; + + const RuntimeConfigEdit edit = UnsetRuntimeConfigValue( + before, "integrations.discord_rpc.text.playing"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "integrations:\n" + " discord_rpc:\n" + " enabled: true\n" + " # text:\n" + " # playing: \"In {place_name}\"\n"); + + const RuntimeConfigEdit again = SetRuntimeConfigString( + edit.text, "integrations.discord_rpc.text.playing", "In {place_name}"); + ASSERT_TRUE(again) << again.error; + EXPECT_EQ(again.text, before); +} + +TEST(RuntimeConfigEditorTest, UnsetOfASectionCommentsOutAllOfIt) { + const RuntimeConfigEdit edit = UnsetRuntimeConfigValue( + "a:\n keep: 1\n drop:\n x: 1\n\n y: |\n text\n tail: 2\n", + "a.drop"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "a:\n keep: 1\n # drop:\n # x: 1\n\n # y: |\n" + " # text\n tail: 2\n"); +} + +TEST(RuntimeConfigEditorTest, KeepsWindowsLineEndings) { + const std::string before = + "graphics:\r\n backend: direct-vulkan\r\n # vsync: off\r\n" + "window:\r\n width: 1280\r\n"; + + RuntimeConfigEdit edit = + SetRuntimeConfigString(before, "graphics.backend", "opengl"); + ASSERT_TRUE(edit) << edit.error; + edit = SetRuntimeConfigString(edit.text, "graphics.vsync", "on"); + ASSERT_TRUE(edit) << edit.error; + edit = SetRuntimeConfigBool(edit.text, "input.raw_mouse", true); + ASSERT_TRUE(edit) << edit.error; + edit = SetRuntimeConfigInteger(edit.text, "window.height", 720); + ASSERT_TRUE(edit) << edit.error; + edit = UnsetRuntimeConfigValue(edit.text, "window.width"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "graphics:\r\n backend: opengl\r\n vsync: on\r\n" + "window:\r\n # width: 1280\r\n height: 720\r\n" + "input:\r\n raw_mouse: true\r\n"); +} + +TEST(RuntimeConfigEditorTest, KeepsAByteOrderMarkAndOldMacLineEndings) { + const RuntimeConfigEdit edit = SetRuntimeConfigInteger( + "\xef\xbb\xbfwindow:\r width: 1280\r", "window.height", 720); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "\xef\xbb\xbfwindow:\r width: 1280\r height: 720\r"); + + const RuntimeConfigEdit top = + SetRuntimeConfigString("\xef\xbb\xbftitle: a\n", "title", "b"); + ASSERT_TRUE(top) << top.error; + EXPECT_EQ(top.text, "\xef\xbb\xbftitle: b\n"); +} + +TEST(RuntimeConfigEditorTest, FindsValuesAfterNonAsciiText) { + const RuntimeConfigEdit edit = SetRuntimeConfigString( + "# caf\xc3\xa9 \xe2\x9c\x93\nwindow:\n title: \"\xc2\xb5 \xe6\x97\xa5\" " + "# \xc3\xa9\n width: 1280\n", + "window.width", "1920"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "# caf\xc3\xa9 \xe2\x9c\x93\nwindow:\n title: \"\xc2\xb5 " + "\xe6\x97\xa5\" # \xc3\xa9\n width: 1920\n"); +} + +TEST(RuntimeConfigEditorTest, ReplacesValuesWrittenInAnyYamlStyle) { + const std::string before = + "a:\n" + " single: 'it''s # here' # note\n" + " literal: |\n" + " line one\n" + " key: not a key\n" + "\n" + " folded: >-\n" + " one\n" + " two\n" + " spread: first\n" + " second\n" + " \"quoted key\": 1\n" + " last: 2\n"; + + RuntimeConfigEdit edit = SetRuntimeConfigString(before, "a.single", "x"); + ASSERT_TRUE(edit) << edit.error; + edit = SetRuntimeConfigString(edit.text, "a.literal", "y"); + ASSERT_TRUE(edit) << edit.error; + edit = SetRuntimeConfigString(edit.text, "a.folded", "z"); + ASSERT_TRUE(edit) << edit.error; + edit = SetRuntimeConfigString(edit.text, "a.spread", "w"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "a:\n" + " single: x # note\n" + " literal: y\n" + "\n" + " folded: z\n" + " spread: w\n" + " \"quoted key\": 1\n" + " last: 2\n"); +} + +TEST(RuntimeConfigEditorTest, IgnoresCommentLookalikesInsideAValue) { + const std::string before = + "a:\n" + " note: |\n" + " # vsync: off\n" + " other: 1\n"; + + const RuntimeConfigEdit edit = SetRuntimeConfigString(before, "a.vsync", "on"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, + "a:\n" + " note: |\n" + " # vsync: off\n" + " other: 1\n" + " vsync: on\n"); +} + +TEST(RuntimeConfigEditorTest, EditsCompactMappings) { + const std::string before = "graphics: {backend: direct-vulkan, vsync: off}\n"; + + EXPECT_EQ(SetRuntimeConfigString(before, "graphics.vsync", "on").text, + "graphics: {backend: direct-vulkan, vsync: on}\n"); + EXPECT_EQ(SetRuntimeConfigBool(before, "graphics.hdr", true).text, + "graphics: {backend: direct-vulkan, vsync: off, hdr: true}\n"); + EXPECT_EQ(SetRuntimeConfigBool(before, "graphics.a.b", true).text, + "graphics: {backend: direct-vulkan, vsync: off, a: {b: true}}\n"); + EXPECT_EQ(SetRuntimeConfigBool(before, "graphics.vsync.deep", true).text, + "graphics: {backend: direct-vulkan, vsync: {deep: true}}\n"); + EXPECT_EQ(UnsetRuntimeConfigValue(before, "graphics.backend").text, + "graphics: {vsync: off}\n"); + EXPECT_EQ(UnsetRuntimeConfigValue(before, "graphics.vsync").text, + "graphics: {backend: direct-vulkan}\n"); + EXPECT_EQ(SetRuntimeConfigBool("graphics: {}\n", "graphics.hdr", true).text, + "graphics: {hdr: true}\n"); + EXPECT_EQ(SetRuntimeConfigString(before, "graphics", "none").text, + "graphics: none\n"); +} + +TEST(RuntimeConfigEditorTest, ReplacesAListWhereASectionIsNeeded) { + const RuntimeConfigEdit edit = SetRuntimeConfigBool( + "a:\n - one\n - two\nb: 1\n", "a.flag", true); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, "a:\n# - one\n# - two\n flag: true\nb: 1\n"); +} + +TEST(RuntimeConfigEditorTest, StartsAFileThatHasNoSettings) { + EXPECT_EQ(SetRuntimeConfigBool("", "input.raw_mouse", true).text, + "input:\n raw_mouse: true\n"); + EXPECT_EQ( + SetRuntimeConfigBool("# only a comment", "input.raw_mouse", true).text, + "# only a comment\ninput:\n raw_mouse: true\n"); +} + +TEST(RuntimeConfigEditorTest, RefusesAFileItCannotRead) { + EXPECT_FALSE(SetRuntimeConfigBool("a:\n\tb: 1\n", "a.b", true)); + EXPECT_FALSE(SetRuntimeConfigBool("- a\n- b\n", "a.b", true)); + EXPECT_FALSE(SetRuntimeConfigBool("a: [1, 2\n", "a", true)); + const RuntimeConfigEdit edit = + SetRuntimeConfigBool("\xff\xfe" "a\0:\0", "a", true); + EXPECT_FALSE(edit); + EXPECT_TRUE(edit.text.empty()); +} + +TEST(RuntimeConfigEditorTest, RejectsAMalformedKey) { + EXPECT_FALSE(SetRuntimeConfigBool("a: 1\n", "", true)); + EXPECT_FALSE(SetRuntimeConfigBool("a: 1\n", "a..b", true)); + EXPECT_FALSE(SetRuntimeConfigBool("a: 1\n", "a.b c", true)); +} + +TEST(RuntimeConfigEditorTest, EditsToTheFirstRunFileLoadWithTheNewValues) { + std::string text(DefaultRuntimeConfigYaml()); + const std::size_t lines = LineCount(text); + + const auto apply = [&text](const RuntimeConfigEdit& edit) { + ASSERT_TRUE(edit) << edit.error; + text = edit.text; + }; + apply(SetRuntimeConfigString(text, "graphics.backend", "opengl")); + apply(SetRuntimeConfigString(text, "graphics.vsync", "on")); + apply(SetRuntimeConfigInteger(text, "graphics.frame_rate_limit", 144)); + apply(SetRuntimeConfigBool(text, "input.raw_mouse", true)); + apply(SetRuntimeConfigString(text, "performance.gamemode", "off")); + apply(SetRuntimeConfigBool(text, "integrations.discord_rpc.enabled", false)); + apply(SetRuntimeConfigString(text, "integrations.discord_rpc.text.playing", + "In {place_name}")); + apply(SetRuntimeConfigString(text, "integrations.discord_rpc.images.small", + "")); + + // Every one of these keys ships in the template, active or commented. + EXPECT_EQ(LineCount(text), lines); + EXPECT_FALSE(Contains(text, "# vsync:")); + + const RuntimeConfigLoadResult loaded = Load(text); + ASSERT_TRUE(loaded) << loaded.error; + const RuntimeConfig& config = loaded.config; + EXPECT_EQ(config.graphics_backend_name(), "opengl"); + EXPECT_EQ(config.vsync_mode(), "on"); + EXPECT_TRUE(config.input_capabilities().raw_mouse); + EXPECT_FALSE(config.fleasion_enabled()); + EXPECT_FALSE(config.discord_rpc().enabled); + EXPECT_EQ(config.discord_rpc().text.playing, "In {place_name}"); + EXPECT_EQ(config.discord_rpc().text.browsing, "Browsing experiences"); + EXPECT_EQ(config.discord_rpc().images.small, ""); + EXPECT_EQ(config.discord_rpc().images.large, "{place_icon}"); +} + +TEST(RuntimeConfigEditorTest, FirstRunFileLoadsAfterEverySetAndUnset) { + const std::pair keys[] = { + {"graphics.backend", "opengl"}, + {"graphics.vsync", "on"}, + {"input.raw_mouse", "true"}, + {"performance.gamemode", "off"}, + {"integrations.discord_rpc.enabled", "false"}, + {"integrations.discord_rpc.text.playing", "In {place_name}"}, + {"integrations.discord_rpc.images.small", ""}, + }; + for (const char* eol : {"\n", "\r\n"}) { + std::string text; + for (const char c : DefaultRuntimeConfigYaml()) { + text += c == '\n' ? std::string(eol) : std::string(1, c); + } + for (const auto& [key, value] : keys) { + for (int round = 0; round < 3; ++round) { + const RuntimeConfigEdit edit = + round == 1 ? UnsetRuntimeConfigValue(text, key) + : SetRuntimeConfigString(text, key, value); + ASSERT_TRUE(edit) << key << " round " << round << ": " << edit.error; + text = edit.text; + const RuntimeConfigLoadResult loaded = Load(text); + ASSERT_TRUE(loaded) << key << " round " << round << ": " + << loaded.error; + } + } + EXPECT_FALSE(Contains(text, eol[0] == '\r' ? "x\ny" : "\r")); + } +} + +TEST(RuntimeConfigEditorTest, EscapesLineBreaksAndControlCharactersInAValue) { + const RuntimeConfigEdit edit = SetRuntimeConfigString( + "k: v\n", "k", "one\ntwo\tx\xc2\x85y\xe2\x80\xa8z"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, "k: \"one\\x0Atwo\\x09x\\x85y\\Lz\"\n"); +} + +TEST(RuntimeConfigEditorTest, RefusesAFileWhoseAliasesLoop) { + EXPECT_FALSE(SetRuntimeConfigString("a: &x [*x]\nb: 2\n", "b", "3")); + EXPECT_FALSE( + SetRuntimeConfigString("a: &x\n y: 1\n z: *x\nb: 2\n", "b", "3")); +} + +TEST(RuntimeConfigEditorTest, RefusesAFileThatSetsAKeyTwice) { + const RuntimeConfigEdit edit = + SetRuntimeConfigString("k: 1\nz: 2\nk: 3\n", "z", "4"); + EXPECT_FALSE(edit); + EXPECT_TRUE(Contains(edit.error, "k is set twice")) << edit.error; +} + +TEST(RuntimeConfigEditorTest, ChangesAKeyThatHoldsAnAlias) { + EXPECT_EQ(SetRuntimeConfigString("a: &x 1\nb: *x\n", "b", "2").text, + "a: &x 1\nb: 2\n"); + EXPECT_EQ(UnsetRuntimeConfigValue("a: &x 1\nb: *x\nc: 1\n", "b").text, + "a: &x 1\n# b: *x\nc: 1\n"); + EXPECT_EQ( + UnsetRuntimeConfigValue("a: &x 1\ng: {p: 1, q: *x}\n", "g.q").text, + "a: &x 1\ng: {p: 1}\n"); + EXPECT_EQ(SetRuntimeConfigString("a: &x 1\ng: {q: *x, p: 1}\n", "g.q", "2") + .text, + "a: &x 1\ng: {q: 2, p: 1}\n"); + EXPECT_EQ( + SetRuntimeConfigString("base: &b\n x: 1\nother: *b\n", "other", "v") + .text, + "base: &b\n x: 1\nother: v\n"); + + // A key reached through an alias lives in another section's lines. + const RuntimeConfigEdit through = SetRuntimeConfigString( + "base: &b\n x: 1\nother: *b\n", "other.x", "2"); + EXPECT_FALSE(through); + EXPECT_TRUE(Contains(through.error, "alias")) << through.error; +} + +TEST(RuntimeConfigEditorTest, KeepsAnAnchorOnAChangedValue) { + EXPECT_EQ(SetRuntimeConfigString("a: &x 1\nb: 2\n", "a", "3").text, + "a: &x 3\nb: 2\n"); + EXPECT_EQ(SetRuntimeConfigString("a: &x\n y: 1\nb: 2\n", "a", "3").text, + "a: &x 3\n# y: 1\nb: 2\n"); + // Changing a value that an alias copies would change the alias too. + EXPECT_FALSE(SetRuntimeConfigString("a: &x 1\nb: *x\n", "a", "3")); +} + +TEST(RuntimeConfigEditorTest, StartsAFileThatHasOnlyADocumentMarker) { + EXPECT_EQ(SetRuntimeConfigString("---\n", "b", "2").text, "---\nb: 2\n"); +} + +TEST(RuntimeConfigEditorTest, AddsAKeyBelowABlockValueThatKeepsBlankLines) { + const RuntimeConfigEdit edit = SetRuntimeConfigInteger( + "a:\n k: |+\n text\n\nz: 1\n", "a.n", 2); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, "a:\n k: |+\n text\n\n n: 2\nz: 1\n"); +} + +TEST(RuntimeConfigEditorTest, UnsetsTheOnlyKeyOfACompactMapping) { + EXPECT_EQ(UnsetRuntimeConfigValue("g: {a: 1,}\n", "g.a").text, "g: {}\n"); + EXPECT_EQ(UnsetRuntimeConfigValue("g: { a: 1 }\n", "g.a").text, "g: {}\n"); +} + +TEST(RuntimeConfigEditorTest, KeepsCommentsAroundAReplacedBlockValue) { + EXPECT_EQ(SetRuntimeConfigString( + "a:\n k: |\n text\n\n # keep me\n z: 1\n", "a.k", "y") + .text, + "a:\n k: y\n\n # keep me\n z: 1\n"); +} + +TEST(RuntimeConfigEditorTest, KeepsABlockValueThatEndsAFileWithNoLineBreak) { + // The added line break would otherwise join the value. + const RuntimeConfigEdit edit = + SetRuntimeConfigInteger("note: |\n text", "width", 1); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, "note: |-\n text\nwidth: 1\n"); +} + +TEST(RuntimeConfigEditorTest, DoesNotReadACommentedKeyAsItsUnclesChild) { + const std::string before = + "a: 1\n" + "# b:\n" + "# c:\n" + "# b: 2\n"; + + // "# b" belongs to the commented c, so b.b is absent. + const RuntimeConfigEdit edit = SetRuntimeConfigInteger(before, "b.b", 3); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, before + "b:\n b: 3\n"); +} + +TEST(RuntimeConfigEditorTest, UncommentsAKeyAtItsSectionsOwnIndentation) { + const RuntimeConfigEdit edit = SetRuntimeConfigString( + "a:\n# vsync: off\n backend: x\n", "a.vsync", "on"); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, "a:\n vsync: on\n backend: x\n"); +} + +TEST(RuntimeConfigEditorTest, LeavesACommentedKeyThatWouldSplitASection) { + const std::string before = + "a:\n" + "# b: 1\n" + " c: 2\n"; + + const RuntimeConfigEdit edit = SetRuntimeConfigInteger(before, "b", 5); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(edit.text, before + "b: 5\n"); +} + +// Writes random but valid config files in many YAML styles. +class RandomConfig { + public: + RandomConfig(unsigned seed, std::string eol) + : random_(seed), eol_(std::move(eol)) {} + + std::string Build() { + std::string text; + if (Chance(4)) { + text += "# header: not a key" + eol_; + } + Mapping(0, 0, &text); + if (Chance(5) && text.size() >= eol_.size()) { + text.resize(text.size() - eol_.size()); + } + return text; + } + + std::string Key() { return "k" + std::to_string(random_() % 5); } + bool Chance(unsigned one_in) { return random_() % one_in == 0; } + unsigned Pick(unsigned count) { return random_() % count; } + + private: + void Mapping(std::size_t indent, int depth, std::string* text) { + const std::string pad(indent, ' '); + bool any = false; + for (int index = 0; index < 5; ++index) { + const std::string key = "k" + std::to_string(index); + if (Chance(3) && (any || index < 4)) { + if (Chance(3)) { + *text += pad + "# " + key + ": commented" + eol_; + } + continue; + } + any = true; + if (Chance(4)) { + *text += eol_; + } + if (Chance(4)) { + *text += pad + "# note: about " + key + eol_; + } + switch (depth < 2 ? Pick(9) : 2 + Pick(7)) { + case 0: + case 1: + *text += pad + key + ":" + eol_; + Mapping(indent + 2, depth + 1, text); + break; + case 2: + *text += pad + key + ": plain words" + eol_; + break; + case 3: + *text += pad + key + ": \"quoted # \\\" text\" # trailing" + eol_; + break; + case 4: + *text += pad + key + ": 'single # ''quote'" + eol_; + break; + case 5: + *text += pad + key + ": |" + eol_ + pad + " first: line" + eol_ + + pad + " # second" + eol_; + if (Chance(2)) { + *text += eol_; + } + break; + case 6: + *text += pad + key + ":" + eol_; + break; + case 7: + *text += pad + key + ": {k0: 1, k1: \"two\", k2: {k3: x}}" + eol_; + break; + default: + *text += pad + key + ": caf\xc3\xa9 \xe2\x9c\x93" + eol_; + break; + } + } + } + + std::mt19937 random_; + std::string eol_; +}; + +TEST(RuntimeConfigEditorTest, RandomEditsToRandomFilesAlwaysApply) { + for (unsigned seed = 0; seed < 400; ++seed) { + const char* const eols[] = {"\n", "\r\n", "\r"}; + RandomConfig random(seed, eols[seed % 3]); + std::string text = random.Build(); + for (int step = 0; step < 30; ++step) { + std::string key = random.Key(); + for (unsigned extra = random.Pick(3); extra > 0; --extra) { + key += "." + random.Key(); + } + RuntimeConfigEdit edit; + const unsigned action = random.Pick(5); + switch (action) { + case 0: + edit = UnsetRuntimeConfigValue(text, key); + break; + case 1: + edit = SetRuntimeConfigBool(text, key, random.Chance(2)); + break; + case 2: + edit = SetRuntimeConfigInteger(text, key, step - 7); + break; + case 3: + edit = SetRuntimeConfigString(text, key, "a: \"b\" #c \xc2\xb5"); + break; + default: + edit = SetRuntimeConfigString(text, key, "word"); + break; + } + ASSERT_TRUE(edit) << "seed " << seed << " step " << step << " key " + << key << " action " << action << ": " << edit.error + << "\n--- file ---\n" + << text; + if (action == 4) { + EXPECT_EQ(SetRuntimeConfigString(edit.text, key, "word").text, + edit.text) + << "seed " << seed << " step " << step; + } + if (action == 0) { + EXPECT_EQ(UnsetRuntimeConfigValue(edit.text, key).text, edit.text) + << "seed " << seed << " step " << step; + } + text = edit.text; + } + } +} + +TEST(RuntimeConfigEditorTest, SettingTheSameValueTwiceChangesNothingMore) { + const std::string once = + SetRuntimeConfigString(std::string(DefaultRuntimeConfigYaml()), + "graphics.vsync", "on") + .text; + EXPECT_EQ(SetRuntimeConfigString(once, "graphics.vsync", "on").text, once); +} + +} // namespace +} // namespace runtime +} // namespace mocktail From 789ef358b6baa5291e1f9471ef52af977ce6b4ae Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 9 Oct 2026 04:52:33 -0500 Subject: [PATCH 04/30] feat(runtime): save config.yaml in one step after checking it loads A save is refused when the loader rejects the new text or the file changed on disk since it was read. --- CMakeLists.txt | 1 + include/runtime/runtime_config_store.h | 44 ++++++ src/runtime/runtime_config_store.cc | 167 ++++++++++++++++++++++ tests/CMakeLists.txt | 7 + tests/runtime_config_store_test.cc | 185 +++++++++++++++++++++++++ 5 files changed, 404 insertions(+) create mode 100644 include/runtime/runtime_config_store.h create mode 100644 src/runtime/runtime_config_store.cc create mode 100644 tests/runtime_config_store_test.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index 974ffe42..b54d7eec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -304,6 +304,7 @@ add_library(mocktail_runtime STATIC src/runtime/runtime_config.cc src/runtime/runtime_config_bootstrap.cc src/runtime/runtime_config_editor.cc + src/runtime/runtime_config_store.cc src/runtime/runtime_config_file.cc src/runtime/runtime_paths.cc src/runtime/session_log.cc diff --git a/include/runtime/runtime_config_store.h b/include/runtime/runtime_config_store.h new file mode 100644 index 00000000..62595240 --- /dev/null +++ b/include/runtime/runtime_config_store.h @@ -0,0 +1,44 @@ +#ifndef MOCKTAIL_RUNTIME_RUNTIME_CONFIG_STORE_H_ +#define MOCKTAIL_RUNTIME_RUNTIME_CONFIG_STORE_H_ + +#include +#include +#include + +namespace mocktail { +namespace runtime { + +struct RuntimeConfigText { + std::string text; + std::string error; + + explicit operator bool() const { return error.empty(); } +}; + +struct RuntimeConfigSaveResult { + std::string error; + + explicit operator bool() const { return error.empty(); } +}; + +// Reads config.yaml as text. The file must be a regular file; a symlink is +// refused. +RuntimeConfigText ReadRuntimeConfigText( + const std::filesystem::path& config_file); + +// Replaces config.yaml with `text` in one step, with mode 0600. On any error +// the file on disk is left byte for byte as it was. +// +// `text` is first written beside the file and loaded the way Nightcap loads +// its configuration, with no environment overrides, so a value the loader +// rejects is never saved. `original` is the text the caller read and edited: +// when the file no longer holds it, something else changed the file and the +// save is refused. The file must exist and be a regular file. +RuntimeConfigSaveResult SaveRuntimeConfigText( + const std::filesystem::path& config_file, std::string_view original, + std::string_view text); + +} // namespace runtime +} // namespace mocktail + +#endif // MOCKTAIL_RUNTIME_RUNTIME_CONFIG_STORE_H_ diff --git a/src/runtime/runtime_config_store.cc b/src/runtime/runtime_config_store.cc new file mode 100644 index 00000000..fbcdba62 --- /dev/null +++ b/src/runtime/runtime_config_store.cc @@ -0,0 +1,167 @@ +#include "runtime/runtime_config_store.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "runtime/environment.h" +#include "runtime/runtime_config_file.h" + +namespace mocktail { +namespace runtime { +namespace { + +// Validation reads the file alone. With the process environment, a variable +// such as MOCKTAIL_GRAPHICS_BACKEND would replace the file's value and hide +// a bad one. +class NoEnvironment final : public Environment { + public: + std::optional Get(std::string_view) const override { + return std::nullopt; + } +}; + +std::string ErrorMessage(std::string_view action, int error_number) { + return std::string(action) + ": " + std::strerror(error_number); +} + +bool WriteAll(int file, std::string_view contents) { + const char* next = contents.data(); + std::size_t remaining = contents.size(); + while (remaining != 0) { + const ssize_t written = write(file, next, remaining); + if (written < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + next += written; + remaining -= static_cast(written); + } + return true; +} + +} // namespace + +RuntimeConfigText ReadRuntimeConfigText( + const std::filesystem::path& config_file) { + RuntimeConfigText result; + const int file = + open(config_file.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (file < 0) { + result.error = errno == ELOOP + ? "refusing symlink runtime configuration" + : ErrorMessage("cannot open configuration", errno); + return result; + } + struct stat metadata = {}; + if (fstat(file, &metadata) != 0 || !S_ISREG(metadata.st_mode)) { + result.error = "runtime configuration must be a regular file"; + (void)close(file); + return result; + } + char buffer[4096]; + while (true) { + const ssize_t count = read(file, buffer, sizeof(buffer)); + if (count < 0 && errno == EINTR) { + continue; + } + if (count < 0) { + result.error = ErrorMessage("cannot read configuration", errno); + result.text.clear(); + break; + } + if (count == 0) { + break; + } + result.text.append(buffer, static_cast(count)); + } + (void)close(file); + return result; +} + +RuntimeConfigSaveResult SaveRuntimeConfigText( + const std::filesystem::path& config_file, std::string_view original, + std::string_view text) { + RuntimeConfigSaveResult result; + if (config_file.empty() || config_file.filename().empty()) { + result.error = "configuration path must name a file"; + return result; + } + const RuntimeConfigText current = ReadRuntimeConfigText(config_file); + if (!current) { + result.error = current.error; + return result; + } + if (current.text != original) { + result.error = "the configuration file changed on disk since it was read"; + return result; + } + + std::filesystem::path parent = config_file.parent_path(); + if (parent.empty()) { + parent = "."; + } + const std::string temporary_pattern = + (parent / ("." + config_file.filename().string() + ".tmp.XXXXXX")) + .string(); + std::vector temporary_path(temporary_pattern.begin(), + temporary_pattern.end()); + temporary_path.push_back('\0'); + const int temporary_file = mkstemp(temporary_path.data()); + if (temporary_file < 0) { + result.error = ErrorMessage("cannot create temporary configuration", errno); + return result; + } + const auto discard_temporary = [&temporary_path]() { + (void)unlink(temporary_path.data()); + }; + + const bool written = fcntl(temporary_file, F_SETFD, FD_CLOEXEC) == 0 && + fchmod(temporary_file, S_IRUSR | S_IWUSR) == 0 && + WriteAll(temporary_file, text) && + fsync(temporary_file) == 0; + const int write_error = errno; + if (close(temporary_file) != 0 || !written) { + result.error = ErrorMessage("cannot write temporary configuration", + written ? errno : write_error); + discard_temporary(); + return result; + } + + // The bytes that would be published are the bytes that get checked. + const RuntimeConfigLoadResult loaded = + LoadRuntimeConfig(NoEnvironment(), temporary_path.data()); + if (!loaded) { + result.error = loaded.error; + discard_temporary(); + return result; + } + + if (rename(temporary_path.data(), config_file.c_str()) != 0) { + result.error = ErrorMessage("cannot replace configuration", errno); + discard_temporary(); + return result; + } + // The rename is what a reader sees. Flushing the directory only makes it + // survive a power cut, so a failure here does not undo the save. + const int directory = + open(parent.c_str(), O_RDONLY | O_CLOEXEC | O_DIRECTORY); + if (directory >= 0) { + (void)fsync(directory); + (void)close(directory); + } + return result; +} + +} // namespace runtime +} // namespace mocktail diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3d505e86..63a07fd6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -51,6 +51,12 @@ if(MOCKTAIL_TEST_PYTHON AND MOCKTAIL_TEST_OPENSSL) set_tests_properties(FleasionIntegrationTest.ProxyAndTrust PROPERTIES TIMEOUT 45) endif() +add_executable(runtime_config_store_test runtime_config_store_test.cc) +target_link_libraries(runtime_config_store_test PRIVATE + Mocktail::Runtime + GTest::gtest_main +) + add_executable(runtime_config_editor_test runtime_config_editor_test.cc) target_link_libraries(runtime_config_editor_test PRIVATE Mocktail::Runtime @@ -1050,6 +1056,7 @@ gtest_discover_tests(jnivm_fields_test) gtest_discover_tests(jnivm_fmod_write_allocation_test) gtest_discover_tests(runtime_config_test) gtest_discover_tests(runtime_config_editor_test) +gtest_discover_tests(runtime_config_store_test) gtest_discover_tests(runtime_config_file_test) gtest_discover_tests(payload_update_preflight_test) gtest_discover_tests(apk_provider_test) diff --git a/tests/runtime_config_store_test.cc b/tests/runtime_config_store_test.cc new file mode 100644 index 00000000..7c847a46 --- /dev/null +++ b/tests/runtime_config_store_test.cc @@ -0,0 +1,185 @@ +#include "runtime/runtime_config_store.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "runtime/runtime_config_bootstrap.h" +#include "runtime/runtime_config_editor.h" + +namespace mocktail { +namespace runtime { +namespace { + +class RuntimeConfigStoreTest : public testing::Test { + protected: + void SetUp() override { + char pattern[] = "/tmp/mocktail_config_store_XXXXXX"; + const char* created = mkdtemp(pattern); + ASSERT_NE(created, nullptr); + directory_ = created; + file_ = directory_ / "config.yaml"; + } + + void TearDown() override { + std::error_code error; + std::filesystem::remove_all(directory_, error); + } + + void Write(const std::filesystem::path& path, std::string_view contents) { + std::ofstream(path, std::ios::binary) << contents; + } + + std::string Read(const std::filesystem::path& path) { + std::ostringstream contents; + contents << std::ifstream(path, std::ios::binary).rdbuf(); + return contents.str(); + } + + mode_t Mode(const std::filesystem::path& path) { + struct stat metadata = {}; + return stat(path.c_str(), &metadata) == 0 ? metadata.st_mode & 0777 : 0; + } + + std::size_t EntryCount() { + std::size_t count = 0; + for (const auto& entry : std::filesystem::directory_iterator(directory_)) { + (void)entry; + ++count; + } + return count; + } + + std::filesystem::path directory_; + std::filesystem::path file_; +}; + +constexpr std::string_view kBefore = "graphics:\n backend: direct-vulkan\n"; +constexpr std::string_view kAfter = "graphics:\n backend: opengl\n"; + +TEST_F(RuntimeConfigStoreTest, ReadsTheFile) { + Write(file_, kBefore); + const RuntimeConfigText read = ReadRuntimeConfigText(file_); + ASSERT_TRUE(read) << read.error; + EXPECT_EQ(read.text, kBefore); +} + +TEST_F(RuntimeConfigStoreTest, ReadRefusesAMissingFileAndASymlink) { + EXPECT_FALSE(ReadRuntimeConfigText(file_)); + + const std::filesystem::path target = directory_ / "target.yaml"; + Write(target, kBefore); + std::filesystem::create_symlink(target, file_); + EXPECT_FALSE(ReadRuntimeConfigText(file_)); +} + +TEST_F(RuntimeConfigStoreTest, SavesTextThatLoads) { + Write(file_, kBefore); + const RuntimeConfigSaveResult saved = + SaveRuntimeConfigText(file_, kBefore, kAfter); + ASSERT_TRUE(saved) << saved.error; + EXPECT_EQ(Read(file_), kAfter); + EXPECT_EQ(EntryCount(), 1u); +} + +TEST_F(RuntimeConfigStoreTest, SavedFileIsPrivateToTheUser) { + Write(file_, kBefore); + ASSERT_EQ(chmod(file_.c_str(), 0644), 0); + ASSERT_TRUE(SaveRuntimeConfigText(file_, kBefore, kAfter)); + EXPECT_EQ(Mode(file_), 0600u); +} + +TEST_F(RuntimeConfigStoreTest, AValueTheLoaderRejectsLeavesTheFileUntouched) { + Write(file_, kBefore); + const RuntimeConfigSaveResult saved = SaveRuntimeConfigText( + file_, kBefore, "graphics:\n backend: no-such-backend\n"); + EXPECT_FALSE(saved); + EXPECT_NE(saved.error.find("graphics.backend"), std::string::npos) + << saved.error; + EXPECT_EQ(Read(file_), kBefore); + EXPECT_EQ(EntryCount(), 1u); +} + +TEST_F(RuntimeConfigStoreTest, BrokenYamlLeavesTheFileUntouched) { + Write(file_, kBefore); + EXPECT_FALSE(SaveRuntimeConfigText(file_, kBefore, "graphics: [1, 2\n")); + EXPECT_EQ(Read(file_), kBefore); + EXPECT_EQ(EntryCount(), 1u); +} + +TEST_F(RuntimeConfigStoreTest, AnEnvironmentOverrideDoesNotHideABadValue) { + Write(file_, kBefore); + ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "opengl", 1), 0); + const RuntimeConfigSaveResult saved = SaveRuntimeConfigText( + file_, kBefore, "graphics:\n backend: no-such-backend\n"); + ASSERT_EQ(unsetenv("MOCKTAIL_GRAPHICS_BACKEND"), 0); + EXPECT_FALSE(saved); + EXPECT_EQ(Read(file_), kBefore); +} + +TEST_F(RuntimeConfigStoreTest, RefusesToOverwriteAFileThatChangedOnDisk) { + const std::string by_hand = "graphics:\n backend: opengl\n vsync: on\n"; + Write(file_, by_hand); + const RuntimeConfigSaveResult saved = + SaveRuntimeConfigText(file_, kBefore, kAfter); + EXPECT_FALSE(saved); + EXPECT_NE(saved.error.find("changed"), std::string::npos) << saved.error; + EXPECT_EQ(Read(file_), by_hand); + EXPECT_EQ(EntryCount(), 1u); +} + +TEST_F(RuntimeConfigStoreTest, RefusesAMissingFile) { + EXPECT_FALSE(SaveRuntimeConfigText(file_, "", kAfter)); + EXPECT_FALSE(std::filesystem::exists(file_)); + EXPECT_EQ(EntryCount(), 0u); +} + +TEST_F(RuntimeConfigStoreTest, RefusesASymlinkAndLeavesItsTargetAlone) { + const std::filesystem::path target = directory_ / "target.yaml"; + Write(target, kBefore); + std::filesystem::create_symlink(target, file_); + + EXPECT_FALSE(SaveRuntimeConfigText(file_, kBefore, kAfter)); + EXPECT_TRUE(std::filesystem::is_symlink(file_)); + EXPECT_EQ(Read(target), kBefore); + EXPECT_EQ(EntryCount(), 2u); +} + +TEST_F(RuntimeConfigStoreTest, ReportsADirectoryItCannotWriteTo) { + if (geteuid() == 0) { + GTEST_SKIP() << "root can write to any directory"; + } + Write(file_, kBefore); + ASSERT_EQ(chmod(directory_.c_str(), 0500), 0); + const RuntimeConfigSaveResult saved = + SaveRuntimeConfigText(file_, kBefore, kAfter); + ASSERT_EQ(chmod(directory_.c_str(), 0700), 0); + EXPECT_FALSE(saved); + EXPECT_EQ(Read(file_), kBefore); +} + +TEST_F(RuntimeConfigStoreTest, AnEditedFirstRunFileSavesAndReadsBack) { + ASSERT_TRUE(EnsureRuntimeConfigFile(file_)); + const RuntimeConfigText read = ReadRuntimeConfigText(file_); + ASSERT_TRUE(read) << read.error; + + const RuntimeConfigEdit edit = + SetRuntimeConfigString(read.text, "graphics.vsync", "on"); + ASSERT_TRUE(edit) << edit.error; + const RuntimeConfigSaveResult saved = + SaveRuntimeConfigText(file_, read.text, edit.text); + ASSERT_TRUE(saved) << saved.error; + EXPECT_EQ(ReadRuntimeConfigText(file_).text, edit.text); + EXPECT_EQ(Mode(file_), 0600u); +} + +} // namespace +} // namespace runtime +} // namespace mocktail From 3820e40bb9ae7171caa7fc4906dea3cd03244540 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 9 Oct 2026 13:21:23 -0500 Subject: [PATCH 05/30] feat(ui): add a settings window that edits config.yaml Rows without a working control yet are shown grayed out. --- CMakeLists.txt | 17 + include/runtime/runtime_config_editor.h | 15 + include/ui/settings_model.h | 124 +++++++ src/runtime/runtime_config_editor.cc | 28 ++ src/ui/mocktail_settings.cc | 460 ++++++++++++++++++++++++ src/ui/settings_model.cc | 377 +++++++++++++++++++ tests/CMakeLists.txt | 7 + tests/runtime_config_editor_test.cc | 42 +++ tests/settings_model_test.cc | 268 ++++++++++++++ 9 files changed, 1338 insertions(+) create mode 100644 include/ui/settings_model.h create mode 100644 src/ui/mocktail_settings.cc create mode 100644 src/ui/settings_model.cc create mode 100644 tests/settings_model_test.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index b54d7eec..e4bd9386 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -370,6 +370,23 @@ add_executable(mocktail_failure_dialog target_link_libraries(mocktail_failure_dialog PRIVATE PkgConfig::LIBADWAITA) mocktail_apply_compile_options(mocktail_failure_dialog) +add_library(mocktail_settings_model STATIC + src/ui/settings_model.cc +) +add_library(Mocktail::SettingsModel ALIAS mocktail_settings_model) +target_include_directories(mocktail_settings_model PUBLIC include) +target_link_libraries(mocktail_settings_model PUBLIC Mocktail::Runtime) +mocktail_apply_compile_options(mocktail_settings_model) + +add_executable(mocktail_settings + src/ui/mocktail_settings.cc +) +target_link_libraries(mocktail_settings PRIVATE + Mocktail::SettingsModel + PkgConfig::LIBADWAITA +) +mocktail_apply_compile_options(mocktail_settings) + add_library(mocktail_services STATIC src/services/http_client.cc src/services/auth_service.cc diff --git a/include/runtime/runtime_config_editor.h b/include/runtime/runtime_config_editor.h index fce92c39..055b4b6b 100644 --- a/include/runtime/runtime_config_editor.h +++ b/include/runtime/runtime_config_editor.h @@ -45,6 +45,21 @@ RuntimeConfigEdit SetRuntimeConfigString(std::string_view text, RuntimeConfigEdit UnsetRuntimeConfigValue(std::string_view text, std::string_view key); +struct RuntimeConfigValue { + // Whether the file sets `key` to a single value. + bool found = false; + std::string value; + std::string error; + + explicit operator bool() const { return error.empty(); } +}; + +// Returns the value `text` gives `key`. A key that is commented out, absent, +// empty, or a section is not found. `error` is set only when `text` cannot be +// read. +RuntimeConfigValue GetRuntimeConfigValue(std::string_view text, + std::string_view key); + } // namespace runtime } // namespace mocktail diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h new file mode 100644 index 00000000..7d1a2b6f --- /dev/null +++ b/include/ui/settings_model.h @@ -0,0 +1,124 @@ +#ifndef MOCKTAIL_UI_SETTINGS_MODEL_H_ +#define MOCKTAIL_UI_SETTINGS_MODEL_H_ + +#include +#include +#include +#include +#include +#include + +namespace mocktail { +namespace ui { + +// What the settings window shows and how each row maps to config.yaml. This +// file has no GTK in it, so the mapping is tested without a display. + +enum class SettingKind { + kChoice, + kToggle, + kFrameRate, + // The window lists these so every setting has its place, and shows them + // grayed out: it has no control that edits them yet. + kNumber, + kText, + kEditor, +}; + +struct SettingChoice { + // Written to the file. Empty means the key is left unset. + std::string_view value; + std::string_view label; +}; + +struct Setting { + std::string_view key; + std::string_view title; + std::string_view subtitle; + SettingKind kind = SettingKind::kChoice; + std::vector choices; + // What Nightcap uses when the file does not set the key. + std::string_view default_value; + // Other spellings the loader accepts, each with the choice it means. + std::vector> aliases; +}; + +struct SettingsGroup { + std::string_view title; + std::vector settings; +}; + +struct SettingsPage { + std::string_view name; + std::string_view title; + std::string_view icon_name; + // The sidebar heading the page is listed under. Pages that share one are + // next to each other. + std::string_view section; + std::vector groups; +}; + +const std::vector& SettingsPages(); + +// Whether the window can change `setting`. One it cannot change is shown +// grayed out with the file's value. +bool IsAvailable(const Setting& setting); + +inline constexpr std::size_t kNoChoice = static_cast(-1); + +// Index of the choice that `value`, the file's value or empty when unset, +// stands for. kNoChoice when the file holds a value the window does not list. +std::size_t ChoiceIndex(const Setting& setting, std::string_view value); + +// Whether a toggle is on for `value`, the file's value or empty when unset. +bool ToggleState(const Setting& setting, std::string_view value); + +enum class FrameRateMode { + kRoblox, + kDisplay, + kUnlimited, + kCustom, +}; + +struct FrameRate { + FrameRateMode mode = FrameRateMode::kRoblox; + // Frames per second. Used only by kCustom. + int custom = 0; +}; + +inline constexpr int kDefaultCustomFrameRate = 120; +inline constexpr int kMaximumCustomFrameRate = 9999; + +// Reads graphics.frame_rate_limit. Empty, "-1", and anything that is not a +// limit mean Roblox's own setting decides. +FrameRate ParseFrameRate(std::string_view value); +// The file's value for `rate`. Empty means the key is left unset. +std::string FrameRateValue(const FrameRate& rate); + +// The config file as the window edits it. Every change is saved at once. +class SettingsDocument { + public: + explicit SettingsDocument(std::filesystem::path config_file); + + // Reads the file, creating the first-run file when there is none. + bool Load(std::string* error); + + // The file's value for `key`, or empty when it is not set. + std::string Value(std::string_view key) const; + + // Sets `key` and saves. An empty `value` unsets the key. On failure nothing + // is saved, and the document holds whatever is on disk now, so the window + // must show its rows again. + bool Set(std::string_view key, std::string_view value, std::string* error); + + const std::filesystem::path& config_file() const { return config_file_; } + + private: + std::filesystem::path config_file_; + std::string text_; +}; + +} // namespace ui +} // namespace mocktail + +#endif // MOCKTAIL_UI_SETTINGS_MODEL_H_ diff --git a/src/runtime/runtime_config_editor.cc b/src/runtime/runtime_config_editor.cc index 3d5d744b..e5b1eda8 100644 --- a/src/runtime/runtime_config_editor.cc +++ b/src/runtime/runtime_config_editor.cc @@ -1240,5 +1240,33 @@ RuntimeConfigEdit UnsetRuntimeConfigValue(std::string_view text, return Change(text, key, nullptr, {}); } +RuntimeConfigValue GetRuntimeConfigValue(std::string_view text, + std::string_view key) { + RuntimeConfigValue result; + std::vector path; + if (!SplitKey(key, &path)) { + result.error = "cannot read " + std::string(key) + + ": the key is not a dotted list of names"; + return result; + } + Source source(text); + if (!source.Parse(&result.error)) { + result.error = "cannot read " + std::string(key) + ": " + result.error; + return result; + } + yaml_node_t* node = Settings(source); + for (const std::string_view name : path) { + node = source.FindPair(node, name).value; + if (node == nullptr) { + return result; + } + } + if (node->type == YAML_SCALAR_NODE && !IsNull(node)) { + result.found = true; + result.value = std::string(ScalarValue(node)); + } + return result; +} + } // namespace runtime } // namespace mocktail diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc new file mode 100644 index 00000000..1ffb1c0e --- /dev/null +++ b/src/ui/mocktail_settings.cc @@ -0,0 +1,460 @@ +#include + +#include +#include +#include +#include + +#include "runtime/environment.h" +#include "runtime/runtime_paths.h" +#include "ui/settings_model.h" + +namespace { + +using mocktail::ui::ChoiceIndex; +using mocktail::ui::FrameRate; +using mocktail::ui::FrameRateMode; +using mocktail::ui::kNoChoice; +using mocktail::ui::Setting; +using mocktail::ui::SettingKind; +using mocktail::ui::SettingsDocument; +using mocktail::ui::SettingsGroup; +using mocktail::ui::SettingsPage; + +constexpr char kApplicationId[] = "io.github.CoderDayton.nightcap"; + +struct SettingsWindow; + +struct Row { + SettingsWindow* window = nullptr; + const Setting* setting = nullptr; + GtkWidget* widget = nullptr; + // The custom frames-per-second row. Only a kFrameRate row has one. + GtkWidget* custom = nullptr; + // A value the file holds that the row does not list. It is shown as one + // more choice so opening the window never changes it. + std::string unlisted; +}; + +struct SettingsWindow { + explicit SettingsWindow(std::filesystem::path config_file) + : document(std::move(config_file)) {} + + SettingsDocument document; + std::deque rows; + AdwNavigationSplitView* split = nullptr; + AdwNavigationPage* content_page = nullptr; + GtkStack* stack = nullptr; + AdwToastOverlay* toasts = nullptr; + // The card under the settings that says when changes take effect. + GtkRevealer* notice = nullptr; + // Set while rows are filled from the file, so that filling one is not + // taken for a change by the user. + bool refreshing = false; +}; + +std::string Text(std::string_view text) { return std::string(text); } + +void SetChoices(Row* row) { + GtkStringList* labels = gtk_string_list_new(nullptr); + for (const auto& choice : row->setting->choices) { + gtk_string_list_append(labels, Text(choice.label).c_str()); + } + if (!row->unlisted.empty()) { + gtk_string_list_append(labels, row->unlisted.c_str()); + } + adw_combo_row_set_model(ADW_COMBO_ROW(row->widget), G_LIST_MODEL(labels)); + g_object_unref(labels); +} + +void RefreshRow(Row* row) { + const Setting& setting = *row->setting; + const std::string value = row->window->document.Value(setting.key); + switch (setting.kind) { + case SettingKind::kToggle: + adw_switch_row_set_active(ADW_SWITCH_ROW(row->widget), + mocktail::ui::ToggleState(setting, value)); + break; + case SettingKind::kChoice: { + const std::size_t index = ChoiceIndex(setting, value); + row->unlisted = index == kNoChoice ? value : std::string(); + SetChoices(row); + adw_combo_row_set_selected( + ADW_COMBO_ROW(row->widget), + static_cast(index == kNoChoice ? setting.choices.size() + : index)); + break; + } + case SettingKind::kFrameRate: { + const FrameRate rate = mocktail::ui::ParseFrameRate(value); + adw_combo_row_set_selected(ADW_COMBO_ROW(row->widget), + static_cast(rate.mode)); + if (rate.mode == FrameRateMode::kCustom) { + adw_spin_row_set_value(ADW_SPIN_ROW(row->custom), rate.custom); + } + gtk_widget_set_visible(row->custom, rate.mode == FrameRateMode::kCustom); + break; + } + case SettingKind::kNumber: + adw_spin_row_set_value( + ADW_SPIN_ROW(row->widget), + g_ascii_strtod( + Text(value.empty() ? setting.default_value : value).c_str(), + nullptr)); + break; + case SettingKind::kText: + gtk_editable_set_text( + GTK_EDITABLE(row->widget), + Text(value.empty() ? setting.default_value : value).c_str()); + break; + case SettingKind::kEditor: + break; + } +} + +void RefreshRows(SettingsWindow* window) { + window->refreshing = true; + for (Row& row : window->rows) { + RefreshRow(&row); + } + window->refreshing = false; +} + +void Apply(Row* row, const std::string& value) { + SettingsWindow* window = row->window; + if (window->refreshing) { + return; + } + std::string error; + if (window->document.Set(row->setting->key, value, &error)) { + gtk_revealer_set_reveal_child(window->notice, TRUE); + } else { + AdwToast* toast = adw_toast_new(error.c_str()); + adw_toast_set_use_markup(toast, FALSE); + adw_toast_set_timeout(toast, 0); + adw_toast_overlay_add_toast(window->toasts, toast); + } + // On failure the rows go back to what the file holds. + RefreshRows(window); +} + +void OnToggleChanged(GObject* object, GParamSpec*, gpointer user_data) { + Apply(static_cast(user_data), + adw_switch_row_get_active(ADW_SWITCH_ROW(object)) ? "true" : "false"); +} + +void OnChoiceChanged(GObject* object, GParamSpec*, gpointer user_data) { + Row* row = static_cast(user_data); + const guint selected = adw_combo_row_get_selected(ADW_COMBO_ROW(object)); + if (selected >= row->setting->choices.size()) { + // The unlisted value is already what the file holds. + return; + } + Apply(row, Text(row->setting->choices[selected].value)); +} + +void OnFrameRateChanged(GObject*, GParamSpec*, gpointer user_data) { + Row* row = static_cast(user_data); + const guint selected = adw_combo_row_get_selected(ADW_COMBO_ROW(row->widget)); + if (selected > static_cast(FrameRateMode::kCustom)) { + return; + } + FrameRate rate; + rate.mode = static_cast(selected); + rate.custom = + static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(row->custom))); + Apply(row, mocktail::ui::FrameRateValue(rate)); +} + +void SetRowText(GtkWidget* widget, std::string_view title, + std::string_view subtitle) { + adw_preferences_row_set_use_markup(ADW_PREFERENCES_ROW(widget), FALSE); + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(widget), + Text(title).c_str()); + // A text row shows its title inside the field and has no subtitle. + if (ADW_IS_ACTION_ROW(widget)) { + adw_action_row_set_subtitle(ADW_ACTION_ROW(widget), + Text(subtitle).c_str()); + } +} + +void AddRow(SettingsWindow* window, AdwPreferencesGroup* group, + const Setting& setting) { + Row& row = window->rows.emplace_back(); + row.window = window; + row.setting = &setting; + switch (setting.kind) { + case SettingKind::kToggle: + row.widget = adw_switch_row_new(); + g_signal_connect(row.widget, "notify::active", + G_CALLBACK(OnToggleChanged), &row); + break; + case SettingKind::kChoice: + row.widget = adw_combo_row_new(); + g_signal_connect(row.widget, "notify::selected", + G_CALLBACK(OnChoiceChanged), &row); + break; + case SettingKind::kFrameRate: + row.widget = adw_combo_row_new(); + SetChoices(&row); + row.custom = adw_spin_row_new_with_range( + 1, mocktail::ui::kMaximumCustomFrameRate, 1); + adw_spin_row_set_value(ADW_SPIN_ROW(row.custom), + mocktail::ui::kDefaultCustomFrameRate); + SetRowText(row.custom, "Custom limit", "Frames per second"); + g_signal_connect(row.widget, "notify::selected", + G_CALLBACK(OnFrameRateChanged), &row); + g_signal_connect(row.custom, "notify::value", + G_CALLBACK(OnFrameRateChanged), &row); + break; + case SettingKind::kNumber: + row.widget = adw_spin_row_new_with_range(0, 1048576, 256); + break; + case SettingKind::kText: + row.widget = adw_entry_row_new(); + break; + case SettingKind::kEditor: + row.widget = adw_action_row_new(); + adw_action_row_add_suffix( + ADW_ACTION_ROW(row.widget), + gtk_image_new_from_icon_name("go-next-symbolic")); + break; + } + // A setting with no working control yet shows the file's value, grayed + // out. + gtk_widget_set_sensitive(row.widget, mocktail::ui::IsAvailable(setting)); + SetRowText(row.widget, setting.title, setting.subtitle); + adw_preferences_group_add(group, row.widget); + if (row.custom != nullptr) { + adw_preferences_group_add(group, row.custom); + } +} + +const SettingsPage& PageOf(GtkListBoxRow* row) { + return mocktail::ui::SettingsPages()[static_cast( + gtk_list_box_row_get_index(row))]; +} + +// Puts a section name above the first sidebar row of each section. +void SetSidebarHeader(GtkListBoxRow* row, GtkListBoxRow* before, gpointer) { + const SettingsPage& page = PageOf(row); + if (before != nullptr && PageOf(before).section == page.section) { + gtk_list_box_row_set_header(row, nullptr); + return; + } + char* upper = g_utf8_strup(page.section.data(), + static_cast(page.section.size())); + GtkWidget* label = gtk_label_new(upper); + g_free(upper); + gtk_label_set_xalign(GTK_LABEL(label), 0); + gtk_widget_add_css_class(label, "caption-heading"); + gtk_widget_add_css_class(label, "dim-label"); + gtk_widget_set_margin_start(label, 12); + gtk_widget_set_margin_top(label, before == nullptr ? 6 : 18); + gtk_widget_set_margin_bottom(label, 6); + gtk_list_box_row_set_header(row, label); +} + +void OnPageSelected(GtkListBox*, GtkListBoxRow* row, gpointer user_data) { + auto* window = static_cast(user_data); + if (row == nullptr) { + return; + } + const SettingsPage& page = PageOf(row); + gtk_stack_set_visible_child_name(window->stack, Text(page.name).c_str()); + adw_navigation_page_set_title(window->content_page, + Text(page.title).c_str()); +} + +// On a narrow window the sidebar and the settings take turns. +void OnPageActivated(GtkListBox*, GtkListBoxRow*, gpointer user_data) { + adw_navigation_split_view_set_show_content( + static_cast(user_data)->split, TRUE); +} + +GtkWidget* BuildSidebar(SettingsWindow* window) { + GtkWidget* list = gtk_list_box_new(); + gtk_widget_add_css_class(list, "navigation-sidebar"); + gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_BROWSE); + gtk_list_box_set_header_func(GTK_LIST_BOX(list), SetSidebarHeader, nullptr, + nullptr); + for (const SettingsPage& page : mocktail::ui::SettingsPages()) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12); + gtk_box_append(GTK_BOX(box), + gtk_image_new_from_icon_name(Text(page.icon_name).c_str())); + GtkWidget* label = gtk_label_new(Text(page.title).c_str()); + gtk_label_set_xalign(GTK_LABEL(label), 0); + gtk_box_append(GTK_BOX(box), label); + gtk_list_box_append(GTK_LIST_BOX(list), box); + } + g_signal_connect(list, "row-selected", G_CALLBACK(OnPageSelected), window); + g_signal_connect(list, "row-activated", G_CALLBACK(OnPageActivated), + window); + + GtkWidget* scrolled = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), list); + + GtkWidget* toolbar = adw_toolbar_view_new(); + adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(toolbar), + adw_header_bar_new()); + adw_toolbar_view_set_content(ADW_TOOLBAR_VIEW(toolbar), scrolled); + gtk_list_box_select_row( + GTK_LIST_BOX(list), + gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 0)); + return toolbar; +} + +GtkWidget* BuildNotice(SettingsWindow* window) { + GtkWidget* icon = + gtk_image_new_from_icon_name("dialog-information-symbolic"); + GtkWidget* label = gtk_label_new( + "Saved. Changes apply the next time Nightcap starts."); + gtk_label_set_xalign(GTK_LABEL(label), 0); + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_widget_set_margin_start(icon, 14); + gtk_widget_set_margin_end(label, 14); + gtk_widget_set_margin_top(label, 12); + gtk_widget_set_margin_bottom(label, 12); + + // The stock "card" style gives the rounded shape the rows above have. + GtkWidget* card = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + gtk_widget_add_css_class(card, "card"); + gtk_box_append(GTK_BOX(card), icon); + gtk_box_append(GTK_BOX(card), label); + gtk_widget_set_margin_start(card, 12); + gtk_widget_set_margin_end(card, 12); + gtk_widget_set_margin_bottom(card, 12); + + GtkWidget* revealer = gtk_revealer_new(); + window->notice = GTK_REVEALER(revealer); + gtk_revealer_set_transition_type(window->notice, + GTK_REVEALER_TRANSITION_TYPE_SLIDE_UP); + gtk_revealer_set_child(window->notice, card); + return revealer; +} + +GtkWidget* BuildPages(SettingsWindow* window) { + GtkWidget* stack = gtk_stack_new(); + window->stack = GTK_STACK(stack); + for (const SettingsPage& page : mocktail::ui::SettingsPages()) { + GtkWidget* page_widget = adw_preferences_page_new(); + for (const SettingsGroup& group : page.groups) { + GtkWidget* group_widget = adw_preferences_group_new(); + adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(group_widget), + Text(group.title).c_str()); + for (const Setting& setting : group.settings) { + AddRow(window, ADW_PREFERENCES_GROUP(group_widget), setting); + } + adw_preferences_page_add(ADW_PREFERENCES_PAGE(page_widget), + ADW_PREFERENCES_GROUP(group_widget)); + } + gtk_stack_add_named(GTK_STACK(stack), page_widget, + Text(page.name).c_str()); + } + return stack; +} + +// The sidebar of pages beside the settings of the chosen one. +GtkWidget* BuildSplitView(SettingsWindow* window, + AdwApplicationWindow* window_widget) { + GtkWidget* split = adw_navigation_split_view_new(); + window->split = ADW_NAVIGATION_SPLIT_VIEW(split); + adw_navigation_split_view_set_min_sidebar_width(window->split, 200); + adw_navigation_split_view_set_max_sidebar_width(window->split, 240); + + GtkWidget* toasts = adw_toast_overlay_new(); + window->toasts = ADW_TOAST_OVERLAY(toasts); + adw_toast_overlay_set_child(window->toasts, BuildPages(window)); + GtkWidget* toolbar = adw_toolbar_view_new(); + adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(toolbar), + adw_header_bar_new()); + adw_toolbar_view_add_bottom_bar(ADW_TOOLBAR_VIEW(toolbar), + BuildNotice(window)); + adw_toolbar_view_set_content(ADW_TOOLBAR_VIEW(toolbar), toasts); + window->content_page = adw_navigation_page_new(toolbar, "Settings"); + adw_navigation_split_view_set_content(window->split, window->content_page); + + // The sidebar selects its first row, which needs the pages above. + adw_navigation_split_view_set_sidebar( + window->split, + adw_navigation_page_new(BuildSidebar(window), "Nightcap")); + + AdwBreakpoint* narrow = + adw_breakpoint_new(adw_breakpoint_condition_parse("max-width: 620sp")); + GValue collapsed = G_VALUE_INIT; + g_value_init(&collapsed, G_TYPE_BOOLEAN); + g_value_set_boolean(&collapsed, TRUE); + adw_breakpoint_add_setter(narrow, G_OBJECT(split), "collapsed", &collapsed); + g_value_unset(&collapsed); + adw_application_window_add_breakpoint(window_widget, narrow); + return split; +} + +GtkWidget* BuildProblemPage(const SettingsWindow& window, + const std::string& error) { + GtkWidget* page = adw_status_page_new(); + adw_status_page_set_icon_name(ADW_STATUS_PAGE(page), + "dialog-warning-symbolic"); + adw_status_page_set_title(ADW_STATUS_PAGE(page), + "The settings file has a problem"); + char* description = g_markup_printf_escaped( + "%s\n\nFix or remove this file, then open Settings again:\n%s", + error.c_str(), window.document.config_file().c_str()); + adw_status_page_set_description(ADW_STATUS_PAGE(page), description); + g_free(description); + return page; +} + +void OnActivate(GtkApplication* application, gpointer user_data) { + auto* window = static_cast(user_data); + if (GtkWindow* existing = gtk_application_get_active_window(application)) { + gtk_window_present(existing); + return; + } + + GtkWidget* window_widget = adw_application_window_new(application); + gtk_window_set_title(GTK_WINDOW(window_widget), "Nightcap Settings"); + gtk_window_set_default_size(GTK_WINDOW(window_widget), 860, 620); + gtk_widget_set_size_request(window_widget, 360, 420); + gtk_window_set_icon_name(GTK_WINDOW(window_widget), kApplicationId); + + GtkWidget* content = nullptr; + std::string error; + if (window->document.Load(&error)) { + content = BuildSplitView(window, ADW_APPLICATION_WINDOW(window_widget)); + RefreshRows(window); + } else { + content = adw_toolbar_view_new(); + adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(content), + adw_header_bar_new()); + adw_toolbar_view_set_content(ADW_TOOLBAR_VIEW(content), + BuildProblemPage(*window, error)); + } + + adw_application_window_set_content(ADW_APPLICATION_WINDOW(window_widget), + content); + gtk_window_present(GTK_WINDOW(window_widget)); +} + +} // namespace + +int main(int argc, char* argv[]) { + g_set_application_name("Nightcap Settings"); + const mocktail::runtime::RuntimePaths paths = + mocktail::runtime::RuntimePaths::FromEnvironment( + mocktail::runtime::ProcessEnvironment()); + SettingsWindow window(paths.config_file()); + + // The application ID matches the desktop entry so the window gets + // Nightcap's icon. Nightcap itself does not own the name on the session + // bus, and neither does this window. + AdwApplication* application = + adw_application_new(kApplicationId, G_APPLICATION_NON_UNIQUE); + g_signal_connect(application, "activate", G_CALLBACK(OnActivate), &window); + const int status = + g_application_run(G_APPLICATION(application), argc, argv); + g_object_unref(application); + return status; +} diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc new file mode 100644 index 00000000..0bda5401 --- /dev/null +++ b/src/ui/settings_model.cc @@ -0,0 +1,377 @@ +#include "ui/settings_model.h" + +#include + +#include "runtime/runtime_config_bootstrap.h" +#include "runtime/runtime_config_editor.h" +#include "runtime/runtime_config_store.h" + +namespace mocktail { +namespace ui { + +const std::vector& SettingsPages() { + static const std::vector pages = { + {"graphics", + "Graphics", + "video-display-symbolic", + "Display & input", + { + {"Rendering", + { + {"graphics.backend", + "Graphics backend", + "Use OpenGL if your GPU has no working Vulkan driver.", + SettingKind::kChoice, + { + {"direct-vulkan", "Vulkan"}, + {"opengl", "OpenGL"}, + {"angle-vulkan", "ANGLE on Vulkan"}, + }, + "direct-vulkan", + { + {"vulkan", "direct-vulkan"}, + {"native-vulkan", "direct-vulkan"}, + {"system", "opengl"}, + {"gles", "opengl"}, + }}, + {"graphics.frame_rate_limit", + "Frame rate limit", + "Roblox setting leaves the cap to the in-game menu.", + SettingKind::kFrameRate, + { + {"", "Roblox setting"}, + {"display", "Match display"}, + {"unlimited", "Unlimited"}, + {"custom", "Custom"}, + }, + "", + {}}, + {"graphics.vsync", + "VSync", + "Lines frames up with the display to stop tearing.", + SettingKind::kChoice, + { + {"", "Default"}, + {"auto", "Automatic"}, + {"on", "On"}, + {"off", "Off"}, + }, + "", + {}}, + }}, + {"Display", + { + {"window.high_dpi", + "High-DPI rendering", + "Render at the display's full pixel density. Sharper, but " + "slower.", + SettingKind::kToggle, + {}, + "false", + {}}, + {"appearance.theme", + "Theme", + "Roblox account uses the theme saved on your account.", + SettingKind::kChoice, + { + {"roblox", "Roblox account"}, + {"system", "Follow system"}, + {"light", "Light"}, + {"dark", "Dark"}, + }, + "roblox", + {}}, + }}, + }}, + {"input", + "Input", + "input-mouse-symbolic", + "Display & input", + { + {"Mouse", + { + {"input.raw_mouse", + "Raw mouse input", + "Turn on if first-person aiming feels too slow or too fast " + "with fractional display scaling.", + SettingKind::kToggle, + {}, + "false", + {}}, + }}, + }}, + {"performance", + "Performance", + "power-profile-performance-symbolic", + "System", + { + {"Processor", + { + {"performance.multithreaded_rendering", + "Multithreaded rendering", + "Size Roblox's render queues from every CPU core.", + SettingKind::kToggle, + {}, + "false", + {}}, + {"performance.physics_worker_mode", + "Physics workers", + "Throughput gives Roblox every core. Latency keeps Roblox's " + "own pool sizes.", + SettingKind::kChoice, + { + {"throughput", "Throughput"}, + {"latency", "Latency"}, + {"auto", "Automatic"}, + }, + "throughput", + {}}, + {"performance.gamemode", + "GameMode", + "Ask Feral GameMode to tune the system while you play.", + SettingKind::kChoice, + { + {"auto", "Automatic"}, + {"on", "On"}, + {"off", "Off"}, + }, + "auto", + {}}, + }}, + {"Memory", + { + {"performance.memory_limit_mb", + "Memory limit", + "Hard cap in MiB for Nightcap and Roblox. 0 turns it off.", + SettingKind::kNumber, + {}, + "0", + {}}, + }}, + }}, + {"audio", + "Audio", + "audio-speakers-symbolic", + "System", + { + {"Devices", + { + {"audio.output_device", "Output device", "", + SettingKind::kText, {}, "default", {}}, + {"audio.input_device", "Input device", "", + SettingKind::kText, {}, "default", {}}, + }}, + }}, + {"discord", + "Discord", + "user-available-symbolic", + "Integrations", + { + {"Rich Presence", + { + {"integrations.discord_rpc.enabled", + "Show activity in Discord", + "Never signs in to Discord or reads an account token.", + SettingKind::kToggle, + {}, + "true", + {}}, + {"integrations.discord_rpc.show_place_name", + "Show the experience name", "", SettingKind::kToggle, {}, + "true", {}}, + {"integrations.discord_rpc.show_elapsed_time", + "Show elapsed time", "", SettingKind::kToggle, {}, "true", + {}}, + }}, + {"Join button", + { + {"integrations.discord_rpc.join.enabled", + "Let friends join", + "Adds a button that opens the experience you are in.", + SettingKind::kToggle, + {}, + "true", + {}}, + {"integrations.discord_rpc.join.public_servers_only", + "Public servers only", + "Never share a private or reserved server.", + SettingKind::kToggle, + {}, + "true", + {}}, + {"integrations.discord_rpc.join.button_label", + "Button label", "", SettingKind::kText, {}, "Join Server", + {}}, + }}, + {"Text", + { + {"integrations.discord_rpc.text.title", "Title", "", + SettingKind::kText, {}, "Roblox", {}}, + {"integrations.discord_rpc.text.playing", "Playing", "", + SettingKind::kText, {}, "Playing {place_name}", {}}, + {"integrations.discord_rpc.text.state", "Second line", "", + SettingKind::kText, {}, "by {creator_name}", {}}, + {"integrations.discord_rpc.text.browsing", "Browsing", "", + SettingKind::kText, {}, "Browsing experiences", {}}, + {"integrations.discord_rpc.text.joining", "Joining", "", + SettingKind::kText, {}, "Joining an experience", {}}, + {"integrations.discord_rpc.text.unknown_place", + "Unknown experience", "", SettingKind::kText, {}, + "Unknown experience", {}}, + }}, + {"Images", + { + {"integrations.discord_rpc.images.large", "Large image", "", + SettingKind::kText, {}, "{place_icon}", {}}, + {"integrations.discord_rpc.images.large_text", + "Large image text", "", SettingKind::kText, {}, + "{place_name}", {}}, + {"integrations.discord_rpc.images.small", "Small image", "", + SettingKind::kText, {}, "roblox_small", {}}, + {"integrations.discord_rpc.images.small_text", + "Small image text", "", SettingKind::kText, {}, "Roblox", + {}}, + }}, + }}, + {"advanced", + "Advanced", + "applications-engineering-symbolic", + "Other", + { + {"Fast flags", + { + {"fflags", + "FFlag overrides", + "Edit fflags.json. Invalid JSON is refused.", + SettingKind::kEditor, + {}, + "", + {}}, + }}, + }}, + }; + return pages; +} + +bool IsAvailable(const Setting& setting) { + return setting.kind == SettingKind::kChoice || + setting.kind == SettingKind::kToggle || + setting.kind == SettingKind::kFrameRate; +} + +std::size_t ChoiceIndex(const Setting& setting, std::string_view value) { + if (value.empty()) { + value = setting.default_value; + } + for (const auto& [alias, choice] : setting.aliases) { + if (value == alias) { + value = choice; + } + } + for (std::size_t index = 0; index < setting.choices.size(); ++index) { + if (setting.choices[index].value == value) { + return index; + } + } + return kNoChoice; +} + +bool ToggleState(const Setting& setting, std::string_view value) { + return (value.empty() ? setting.default_value : value) == "true"; +} + +FrameRate ParseFrameRate(std::string_view value) { + FrameRate rate; + if (value == "display") { + rate.mode = FrameRateMode::kDisplay; + } else if (value == "unlimited") { + rate.mode = FrameRateMode::kUnlimited; + } else { + int custom = 0; + const auto parsed = + std::from_chars(value.data(), value.data() + value.size(), custom); + if (parsed.ec == std::errc() && + parsed.ptr == value.data() + value.size() && custom > 0) { + rate.mode = FrameRateMode::kCustom; + rate.custom = custom; + } + } + return rate; +} + +std::string FrameRateValue(const FrameRate& rate) { + switch (rate.mode) { + case FrameRateMode::kDisplay: + return "display"; + case FrameRateMode::kUnlimited: + return "unlimited"; + case FrameRateMode::kCustom: + return std::to_string(rate.custom > 0 ? rate.custom + : kDefaultCustomFrameRate); + case FrameRateMode::kRoblox: + break; + } + return {}; +} + +SettingsDocument::SettingsDocument(std::filesystem::path config_file) + : config_file_(std::move(config_file)) {} + +bool SettingsDocument::Load(std::string* error) { + const runtime::RuntimeConfigBootstrapResult ensured = + runtime::EnsureRuntimeConfigFile(config_file_); + if (!ensured) { + *error = ensured.error; + return false; + } + runtime::RuntimeConfigText read = + runtime::ReadRuntimeConfigText(config_file_); + if (!read) { + *error = read.error; + return false; + } + // A file the editor cannot read is reported now, not on the first change. + const runtime::RuntimeConfigValue probe = + runtime::GetRuntimeConfigValue(read.text, "graphics.backend"); + if (!probe) { + *error = probe.error; + return false; + } + text_ = std::move(read.text); + return true; +} + +std::string SettingsDocument::Value(std::string_view key) const { + return runtime::GetRuntimeConfigValue(text_, key).value; +} + +bool SettingsDocument::Set(std::string_view key, std::string_view value, + std::string* error) { + const runtime::RuntimeConfigEdit edit = + value.empty() ? runtime::UnsetRuntimeConfigValue(text_, key) + : runtime::SetRuntimeConfigString(text_, key, value); + if (!edit) { + *error = edit.error; + return false; + } + if (edit.text == text_) { + return true; + } + const runtime::RuntimeConfigSaveResult saved = + runtime::SaveRuntimeConfigText(config_file_, text_, edit.text); + if (!saved) { + *error = saved.error; + // The file may have changed under the window. Show what it holds now. + runtime::RuntimeConfigText read = + runtime::ReadRuntimeConfigText(config_file_); + if (read) { + text_ = std::move(read.text); + } + return false; + } + text_ = edit.text; + return true; +} + +} // namespace ui +} // namespace mocktail diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 63a07fd6..ce426815 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -51,6 +51,12 @@ if(MOCKTAIL_TEST_PYTHON AND MOCKTAIL_TEST_OPENSSL) set_tests_properties(FleasionIntegrationTest.ProxyAndTrust PROPERTIES TIMEOUT 45) endif() +add_executable(settings_model_test settings_model_test.cc) +target_link_libraries(settings_model_test PRIVATE + Mocktail::SettingsModel + GTest::gtest_main +) + add_executable(runtime_config_store_test runtime_config_store_test.cc) target_link_libraries(runtime_config_store_test PRIVATE Mocktail::Runtime @@ -1057,6 +1063,7 @@ gtest_discover_tests(jnivm_fmod_write_allocation_test) gtest_discover_tests(runtime_config_test) gtest_discover_tests(runtime_config_editor_test) gtest_discover_tests(runtime_config_store_test) +gtest_discover_tests(settings_model_test) gtest_discover_tests(runtime_config_file_test) gtest_discover_tests(payload_update_preflight_test) gtest_discover_tests(apk_provider_test) diff --git a/tests/runtime_config_editor_test.cc b/tests/runtime_config_editor_test.cc index 87da38f8..6b790052 100644 --- a/tests/runtime_config_editor_test.cc +++ b/tests/runtime_config_editor_test.cc @@ -577,6 +577,48 @@ TEST(RuntimeConfigEditorTest, FirstRunFileLoadsAfterEverySetAndUnset) { } } +TEST(RuntimeConfigEditorTest, GetsTheValueOfAKey) { + const std::string text = + "graphics:\n" + " backend: opengl # note\n" + " # vsync: off\n" + " empty:\n" + "window: {title: \"My \\\"game\\\"\", width: 1280}\n"; + + RuntimeConfigValue value = GetRuntimeConfigValue(text, "graphics.backend"); + ASSERT_TRUE(value) << value.error; + EXPECT_TRUE(value.found); + EXPECT_EQ(value.value, "opengl"); + + value = GetRuntimeConfigValue(text, "window.title"); + EXPECT_TRUE(value.found); + EXPECT_EQ(value.value, "My \"game\""); + + // Commented out, absent, a section, and a key with nothing after it are + // all not set. + EXPECT_FALSE(GetRuntimeConfigValue(text, "graphics.vsync").found); + EXPECT_FALSE(GetRuntimeConfigValue(text, "audio.output_device").found); + EXPECT_FALSE(GetRuntimeConfigValue(text, "graphics").found); + EXPECT_FALSE(GetRuntimeConfigValue(text, "graphics.empty").found); + EXPECT_TRUE(GetRuntimeConfigValue(text, "graphics.vsync")); + EXPECT_FALSE(GetRuntimeConfigValue("", "graphics.vsync").found); +} + +TEST(RuntimeConfigEditorTest, GetReportsAFileItCannotRead) { + const RuntimeConfigValue value = + GetRuntimeConfigValue("a: [1, 2\n", "a"); + EXPECT_FALSE(value); + EXPECT_FALSE(value.found); +} + +TEST(RuntimeConfigEditorTest, GetReadsBackWhatSetWrote) { + const std::string odd = "a: \"b\" #c \xc2\xb5\nnext"; + const RuntimeConfigEdit edit = + SetRuntimeConfigString("window:\n width: 1\n", "window.title", odd); + ASSERT_TRUE(edit) << edit.error; + EXPECT_EQ(GetRuntimeConfigValue(edit.text, "window.title").value, odd); +} + TEST(RuntimeConfigEditorTest, EscapesLineBreaksAndControlCharactersInAValue) { const RuntimeConfigEdit edit = SetRuntimeConfigString( "k: v\n", "k", "one\ntwo\tx\xc2\x85y\xe2\x80\xa8z"); diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc new file mode 100644 index 00000000..02330777 --- /dev/null +++ b/tests/settings_model_test.cc @@ -0,0 +1,268 @@ +#include "ui/settings_model.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "runtime/environment.h" +#include "runtime/runtime_config_file.h" + +namespace mocktail { +namespace ui { +namespace { + +class EmptyEnvironment final : public runtime::Environment { + public: + std::optional Get(std::string_view) const override { + return std::nullopt; + } +}; + +const Setting& Find(std::string_view key) { + for (const SettingsPage& page : SettingsPages()) { + for (const SettingsGroup& group : page.groups) { + for (const Setting& setting : group.settings) { + if (setting.key == key) { + return setting; + } + } + } + } + ADD_FAILURE() << "no setting " << key; + static const Setting missing; + return missing; +} + +class SettingsModelTest : public testing::Test { + protected: + void SetUp() override { + char pattern[] = "/tmp/mocktail_settings_model_XXXXXX"; + const char* created = mkdtemp(pattern); + ASSERT_NE(created, nullptr); + directory_ = created; + file_ = directory_ / "mocktail" / "config.yaml"; + } + + void TearDown() override { + std::error_code error; + std::filesystem::remove_all(directory_, error); + } + + std::string Read() { + std::ostringstream contents; + contents << std::ifstream(file_, std::ios::binary).rdbuf(); + return contents.str(); + } + + void Write(std::string_view contents) { + std::filesystem::create_directories(file_.parent_path()); + std::ofstream(file_, std::ios::binary) << contents; + } + + runtime::RuntimeConfigLoadResult LoadFile() { + return runtime::LoadRuntimeConfig(EmptyEnvironment(), file_); + } + + std::filesystem::path directory_; + std::filesystem::path file_; +}; + +TEST_F(SettingsModelTest, PagesListEverySettingOnce) { + std::set keys; + std::vector titles; + for (const SettingsPage& page : SettingsPages()) { + titles.push_back(page.title); + EXPECT_FALSE(page.section.empty()) << page.title; + EXPECT_FALSE(page.icon_name.empty()) << page.title; + for (const SettingsGroup& group : page.groups) { + for (const Setting& setting : group.settings) { + EXPECT_FALSE(setting.title.empty()) << setting.key; + EXPECT_TRUE(keys.insert(setting.key).second) << setting.key; + } + } + } + EXPECT_EQ(titles, (std::vector{ + "Graphics", "Input", "Performance", "Audio", + "Discord", "Advanced"})); +} + +TEST_F(SettingsModelTest, PagesOfOneSectionAreNextToEachOther) { + std::set closed; + std::string_view current; + for (const SettingsPage& page : SettingsPages()) { + if (page.section != current) { + EXPECT_TRUE(closed.insert(page.section).second) << page.section; + current = page.section; + } + } +} + +TEST_F(SettingsModelTest, OnlySettingsWithAControlAreAvailable) { + EXPECT_TRUE(IsAvailable(Find("graphics.backend"))); + EXPECT_TRUE(IsAvailable(Find("graphics.frame_rate_limit"))); + EXPECT_TRUE(IsAvailable(Find("integrations.discord_rpc.enabled"))); + EXPECT_FALSE(IsAvailable(Find("performance.memory_limit_mb"))); + EXPECT_FALSE(IsAvailable(Find("audio.output_device"))); + EXPECT_FALSE(IsAvailable(Find("fflags"))); +} + +TEST_F(SettingsModelTest, EveryValueTheWindowOffersSavesAndLoads) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + + for (const SettingsPage& page : SettingsPages()) { + for (const SettingsGroup& group : page.groups) { + for (const Setting& setting : group.settings) { + if (!IsAvailable(setting)) { + continue; + } + std::vector values; + if (setting.kind == SettingKind::kToggle) { + values = {"true", "false"}; + } else if (setting.kind == SettingKind::kFrameRate) { + for (const FrameRateMode mode : + {FrameRateMode::kDisplay, FrameRateMode::kUnlimited, + FrameRateMode::kCustom, FrameRateMode::kRoblox}) { + values.push_back(FrameRateValue({mode, 144})); + } + } else { + for (const SettingChoice& choice : setting.choices) { + values.emplace_back(choice.value); + } + } + for (const std::string& value : values) { + ASSERT_TRUE(document.Set(setting.key, value, &error)) + << setting.key << " = " << value << ": " << error; + EXPECT_EQ(document.Value(setting.key), value) << setting.key; + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << setting.key << " = " << value << ": " + << loaded.error; + } + } + } + } +} + +TEST_F(SettingsModelTest, SavedValuesReachTheLoadedConfig) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + ASSERT_TRUE(document.Set("graphics.backend", "opengl", &error)) << error; + ASSERT_TRUE(document.Set("graphics.vsync", "on", &error)) << error; + ASSERT_TRUE(document.Set("window.high_dpi", "true", &error)) << error; + ASSERT_TRUE(document.Set("input.raw_mouse", "true", &error)) << error; + + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_EQ(loaded.config.graphics_backend_name(), "opengl"); + EXPECT_EQ(loaded.config.vsync_mode(), "on"); + EXPECT_TRUE(loaded.config.window().high_dpi); + EXPECT_TRUE(loaded.config.input_capabilities().raw_mouse); +} + +TEST_F(SettingsModelTest, LoadCreatesTheFirstRunFile) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_TRUE(std::filesystem::exists(file_)); + EXPECT_EQ(document.Value("graphics.backend"), "direct-vulkan"); + EXPECT_EQ(document.Value("graphics.vsync"), ""); +} + +TEST_F(SettingsModelTest, LoadReportsAFileThatIsNotYaml) { + Write("graphics: [1, 2\n"); + SettingsDocument document(file_); + std::string error; + EXPECT_FALSE(document.Load(&error)); + EXPECT_FALSE(error.empty()); + EXPECT_EQ(Read(), "graphics: [1, 2\n"); +} + +TEST_F(SettingsModelTest, AnEmptyValueUnsetsTheKey) { + Write("graphics:\n vsync: on\n backend: opengl\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + ASSERT_TRUE(document.Set("graphics.vsync", "", &error)) << error; + EXPECT_EQ(document.Value("graphics.vsync"), ""); + EXPECT_EQ(Read(), "graphics:\n # vsync: on\n backend: opengl\n"); +} + +TEST_F(SettingsModelTest, AValueTheLoaderRejectsIsNotSaved) { + Write("graphics:\n backend: opengl\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_FALSE(document.Set("graphics.backend", "no-such-backend", &error)); + EXPECT_FALSE(error.empty()); + EXPECT_EQ(document.Value("graphics.backend"), "opengl"); + EXPECT_EQ(Read(), "graphics:\n backend: opengl\n"); +} + +TEST_F(SettingsModelTest, AHandEditMadeMeanwhileIsKeptAndShown) { + Write("graphics:\n backend: opengl\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + + Write("graphics:\n backend: angle-vulkan\n vsync: off\n"); + EXPECT_FALSE(document.Set("graphics.vsync", "on", &error)); + EXPECT_EQ(Read(), "graphics:\n backend: angle-vulkan\n vsync: off\n"); + EXPECT_EQ(document.Value("graphics.backend"), "angle-vulkan"); + + // The document now holds the hand edit, so the next change goes through. + ASSERT_TRUE(document.Set("graphics.vsync", "on", &error)) << error; + EXPECT_EQ(Read(), "graphics:\n backend: angle-vulkan\n vsync: on\n"); +} + +TEST_F(SettingsModelTest, ChoiceIndexMapsDefaultsAndOtherSpellings) { + const Setting& backend = Find("graphics.backend"); + EXPECT_EQ(ChoiceIndex(backend, ""), 0u); + EXPECT_EQ(ChoiceIndex(backend, "direct-vulkan"), 0u); + EXPECT_EQ(ChoiceIndex(backend, "vulkan"), 0u); + EXPECT_EQ(ChoiceIndex(backend, "opengl"), 1u); + EXPECT_EQ(ChoiceIndex(backend, "system"), 1u); + EXPECT_EQ(ChoiceIndex(backend, "gles"), 1u); + EXPECT_EQ(ChoiceIndex(backend, "angle-vulkan"), 2u); + EXPECT_EQ(ChoiceIndex(backend, "angle-swiftshader"), kNoChoice); + + const Setting& vsync = Find("graphics.vsync"); + EXPECT_EQ(ChoiceIndex(vsync, ""), 0u); + EXPECT_EQ(ChoiceIndex(vsync, "off"), 3u); +} + +TEST_F(SettingsModelTest, ToggleStateUsesTheDefaultWhenUnset) { + const Setting& raw_mouse = Find("input.raw_mouse"); + EXPECT_FALSE(ToggleState(raw_mouse, "")); + EXPECT_FALSE(ToggleState(raw_mouse, "false")); + EXPECT_TRUE(ToggleState(raw_mouse, "true")); +} + +TEST_F(SettingsModelTest, FrameRateReadsEveryForm) { + EXPECT_EQ(ParseFrameRate("").mode, FrameRateMode::kRoblox); + EXPECT_EQ(ParseFrameRate("-1").mode, FrameRateMode::kRoblox); + EXPECT_EQ(ParseFrameRate("0").mode, FrameRateMode::kRoblox); + EXPECT_EQ(ParseFrameRate("fast").mode, FrameRateMode::kRoblox); + EXPECT_EQ(ParseFrameRate("display").mode, FrameRateMode::kDisplay); + EXPECT_EQ(ParseFrameRate("unlimited").mode, FrameRateMode::kUnlimited); + EXPECT_EQ(ParseFrameRate("144").mode, FrameRateMode::kCustom); + EXPECT_EQ(ParseFrameRate("144").custom, 144); + + EXPECT_EQ(FrameRateValue({FrameRateMode::kRoblox, 144}), ""); + EXPECT_EQ(FrameRateValue({FrameRateMode::kDisplay, 0}), "display"); + EXPECT_EQ(FrameRateValue({FrameRateMode::kUnlimited, 0}), "unlimited"); + EXPECT_EQ(FrameRateValue({FrameRateMode::kCustom, 144}), "144"); + EXPECT_EQ(FrameRateValue({FrameRateMode::kCustom, 0}), "120"); +} + +} // namespace +} // namespace ui +} // namespace mocktail From f9a6d09b7730c7c650c32d76e38799bd69ebf438 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 9 Oct 2026 16:59:30 -0500 Subject: [PATCH 06/30] feat(ui): make the number and text settings rows editable Text rows save on Enter and have a button that restores the default. --- include/ui/settings_model.h | 16 +++++++-- src/ui/mocktail_settings.cc | 66 ++++++++++++++++++++++++++++------ src/ui/settings_model.cc | 37 ++++++++++++++----- tests/settings_model_test.cc | 69 ++++++++++++++++++++++++++++++++++-- 4 files changed, 163 insertions(+), 25 deletions(-) diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index 7d1a2b6f..f9d5631d 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -18,10 +18,10 @@ enum class SettingKind { kChoice, kToggle, kFrameRate, - // The window lists these so every setting has its place, and shows them - // grayed out: it has no control that edits them yet. kNumber, kText, + // The window lists this so the setting has its place, and shows it grayed + // out: it has no control that edits it yet. kEditor, }; @@ -89,6 +89,9 @@ struct FrameRate { inline constexpr int kDefaultCustomFrameRate = 120; inline constexpr int kMaximumCustomFrameRate = 9999; +// The file's value for what a number row holds. +std::string NumberValue(double number); + // Reads graphics.frame_rate_limit. Empty, "-1", and anything that is not a // limit mean Roblox's own setting decides. FrameRate ParseFrameRate(std::string_view value); @@ -106,14 +109,23 @@ class SettingsDocument { // The file's value for `key`, or empty when it is not set. std::string Value(std::string_view key) const; + // Whether the file sets `key`. A key can be set to an empty text. + bool IsSet(std::string_view key) const; + // Sets `key` and saves. An empty `value` unsets the key. On failure nothing // is saved, and the document holds whatever is on disk now, so the window // must show its rows again. bool Set(std::string_view key, std::string_view value, std::string* error); + // Like Set, but an empty `value` is saved as an empty text. + bool SetText(std::string_view key, std::string_view value, + std::string* error); + const std::filesystem::path& config_file() const { return config_file_; } private: + bool Save(std::string edited, std::string edit_error, std::string* error); + std::filesystem::path config_file_; std::string text_; }; diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 1ffb1c0e..125ab04e 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -31,6 +31,9 @@ struct Row { GtkWidget* widget = nullptr; // The custom frames-per-second row. Only a kFrameRate row has one. GtkWidget* custom = nullptr; + // The button that puts a text back to its default. Only a kText row has + // one. + GtkWidget* reset = nullptr; // A value the file holds that the row does not list. It is shown as one // more choice so opening the window never changes it. std::string unlisted; @@ -102,11 +105,16 @@ void RefreshRow(Row* row) { Text(value.empty() ? setting.default_value : value).c_str(), nullptr)); break; - case SettingKind::kText: - gtk_editable_set_text( - GTK_EDITABLE(row->widget), - Text(value.empty() ? setting.default_value : value).c_str()); + case SettingKind::kText: { + const bool is_set = row->window->document.IsSet(setting.key); + const std::string text = is_set ? value : Text(setting.default_value); + // Setting the same text again would mark the field as edited. + if (text != gtk_editable_get_text(GTK_EDITABLE(row->widget))) { + gtk_editable_set_text(GTK_EDITABLE(row->widget), text.c_str()); + } + gtk_widget_set_sensitive(row->reset, is_set); break; + } case SettingKind::kEditor: break; } @@ -120,13 +128,9 @@ void RefreshRows(SettingsWindow* window) { window->refreshing = false; } -void Apply(Row* row, const std::string& value) { - SettingsWindow* window = row->window; - if (window->refreshing) { - return; - } - std::string error; - if (window->document.Set(row->setting->key, value, &error)) { +// Shows how a save went, then shows every row as the file now has it. +void ShowSaved(SettingsWindow* window, bool saved, const std::string& error) { + if (saved) { gtk_revealer_set_reveal_child(window->notice, TRUE); } else { AdwToast* toast = adw_toast_new(error.c_str()); @@ -138,6 +142,36 @@ void Apply(Row* row, const std::string& value) { RefreshRows(window); } +void Apply(Row* row, const std::string& value) { + SettingsWindow* window = row->window; + if (window->refreshing) { + return; + } + std::string error; + const bool saved = window->document.Set(row->setting->key, value, &error); + ShowSaved(window, saved, error); +} + +void OnNumberChanged(GObject* object, GParamSpec*, gpointer user_data) { + Apply(static_cast(user_data), + mocktail::ui::NumberValue( + adw_spin_row_get_value(ADW_SPIN_ROW(object)))); +} + +void OnTextApplied(AdwEntryRow* entry, gpointer user_data) { + Row* row = static_cast(user_data); + SettingsWindow* window = row->window; + std::string error; + const bool saved = window->document.SetText( + row->setting->key, gtk_editable_get_text(GTK_EDITABLE(entry)), &error); + ShowSaved(window, saved, error); +} + +void OnTextReset(GtkButton*, gpointer user_data) { + // An empty value unsets the key, which brings the default back. + Apply(static_cast(user_data), std::string()); +} + void OnToggleChanged(GObject* object, GParamSpec*, gpointer user_data) { Apply(static_cast(user_data), adw_switch_row_get_active(ADW_SWITCH_ROW(object)) ? "true" : "false"); @@ -209,9 +243,19 @@ void AddRow(SettingsWindow* window, AdwPreferencesGroup* group, break; case SettingKind::kNumber: row.widget = adw_spin_row_new_with_range(0, 1048576, 256); + g_signal_connect(row.widget, "notify::value", + G_CALLBACK(OnNumberChanged), &row); break; case SettingKind::kText: row.widget = adw_entry_row_new(); + adw_entry_row_set_show_apply_button(ADW_ENTRY_ROW(row.widget), TRUE); + row.reset = gtk_button_new_from_icon_name("edit-undo-symbolic"); + gtk_widget_set_tooltip_text(row.reset, "Reset to default"); + gtk_widget_set_valign(row.reset, GTK_ALIGN_CENTER); + gtk_widget_add_css_class(row.reset, "flat"); + adw_entry_row_add_suffix(ADW_ENTRY_ROW(row.widget), row.reset); + g_signal_connect(row.widget, "apply", G_CALLBACK(OnTextApplied), &row); + g_signal_connect(row.reset, "clicked", G_CALLBACK(OnTextReset), &row); break; case SettingKind::kEditor: row.widget = adw_action_row_new(); diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 0bda5401..4242779b 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -1,6 +1,7 @@ #include "ui/settings_model.h" #include +#include #include "runtime/runtime_config_bootstrap.h" #include "runtime/runtime_config_editor.h" @@ -254,9 +255,11 @@ const std::vector& SettingsPages() { } bool IsAvailable(const Setting& setting) { - return setting.kind == SettingKind::kChoice || - setting.kind == SettingKind::kToggle || - setting.kind == SettingKind::kFrameRate; + return setting.kind != SettingKind::kEditor; +} + +std::string NumberValue(double number) { + return std::to_string(number > 0 ? std::llround(number) : 0LL); } std::size_t ChoiceIndex(const Setting& setting, std::string_view value) { @@ -345,20 +348,36 @@ std::string SettingsDocument::Value(std::string_view key) const { return runtime::GetRuntimeConfigValue(text_, key).value; } +bool SettingsDocument::IsSet(std::string_view key) const { + return runtime::GetRuntimeConfigValue(text_, key).found; +} + bool SettingsDocument::Set(std::string_view key, std::string_view value, std::string* error) { - const runtime::RuntimeConfigEdit edit = + runtime::RuntimeConfigEdit edit = value.empty() ? runtime::UnsetRuntimeConfigValue(text_, key) : runtime::SetRuntimeConfigString(text_, key, value); - if (!edit) { - *error = edit.error; + return Save(std::move(edit.text), std::move(edit.error), error); +} + +bool SettingsDocument::SetText(std::string_view key, std::string_view value, + std::string* error) { + runtime::RuntimeConfigEdit edit = + runtime::SetRuntimeConfigString(text_, key, value); + return Save(std::move(edit.text), std::move(edit.error), error); +} + +bool SettingsDocument::Save(std::string edited, std::string edit_error, + std::string* error) { + if (!edit_error.empty()) { + *error = std::move(edit_error); return false; } - if (edit.text == text_) { + if (edited == text_) { return true; } const runtime::RuntimeConfigSaveResult saved = - runtime::SaveRuntimeConfigText(config_file_, text_, edit.text); + runtime::SaveRuntimeConfigText(config_file_, text_, edited); if (!saved) { *error = saved.error; // The file may have changed under the window. Show what it holds now. @@ -369,7 +388,7 @@ bool SettingsDocument::Set(std::string_view key, std::string_view value, } return false; } - text_ = edit.text; + text_ = std::move(edited); return true; } diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index 02330777..c9684a8c 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -108,8 +108,9 @@ TEST_F(SettingsModelTest, OnlySettingsWithAControlAreAvailable) { EXPECT_TRUE(IsAvailable(Find("graphics.backend"))); EXPECT_TRUE(IsAvailable(Find("graphics.frame_rate_limit"))); EXPECT_TRUE(IsAvailable(Find("integrations.discord_rpc.enabled"))); - EXPECT_FALSE(IsAvailable(Find("performance.memory_limit_mb"))); - EXPECT_FALSE(IsAvailable(Find("audio.output_device"))); + EXPECT_TRUE(IsAvailable(Find("performance.memory_limit_mb"))); + EXPECT_TRUE(IsAvailable(Find("audio.output_device"))); + EXPECT_TRUE(IsAvailable(Find("integrations.discord_rpc.text.title"))); EXPECT_FALSE(IsAvailable(Find("fflags"))); } @@ -124,8 +125,23 @@ TEST_F(SettingsModelTest, EveryValueTheWindowOffersSavesAndLoads) { if (!IsAvailable(setting)) { continue; } + if (setting.kind == SettingKind::kText) { + for (const std::string value : + {std::string("Custom: {place_name} #1"), + std::string(setting.default_value)}) { + ASSERT_TRUE(document.SetText(setting.key, value, &error)) + << setting.key << " = " << value << ": " << error; + EXPECT_EQ(document.Value(setting.key), value) << setting.key; + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << setting.key << " = " << value << ": " + << loaded.error; + } + continue; + } std::vector values; - if (setting.kind == SettingKind::kToggle) { + if (setting.kind == SettingKind::kNumber) { + values = {NumberValue(4096), NumberValue(0)}; + } else if (setting.kind == SettingKind::kToggle) { values = {"true", "false"}; } else if (setting.kind == SettingKind::kFrameRate) { for (const FrameRateMode mode : @@ -159,9 +175,16 @@ TEST_F(SettingsModelTest, SavedValuesReachTheLoadedConfig) { ASSERT_TRUE(document.Set("graphics.vsync", "on", &error)) << error; ASSERT_TRUE(document.Set("window.high_dpi", "true", &error)) << error; ASSERT_TRUE(document.Set("input.raw_mouse", "true", &error)) << error; + ASSERT_TRUE(document.SetText("audio.output_device", "USB Headset", &error)) + << error; + ASSERT_TRUE(document.SetText("integrations.discord_rpc.join.button_label", + "Hop in", &error)) + << error; const runtime::RuntimeConfigLoadResult loaded = LoadFile(); ASSERT_TRUE(loaded) << loaded.error; + EXPECT_EQ(loaded.config.audio_output_device(), "USB Headset"); + EXPECT_EQ(loaded.config.discord_rpc().join_button_label, "Hop in"); EXPECT_EQ(loaded.config.graphics_backend_name(), "opengl"); EXPECT_EQ(loaded.config.vsync_mode(), "on"); EXPECT_TRUE(loaded.config.window().high_dpi); @@ -207,6 +230,46 @@ TEST_F(SettingsModelTest, AValueTheLoaderRejectsIsNotSaved) { EXPECT_EQ(Read(), "graphics:\n backend: opengl\n"); } +TEST_F(SettingsModelTest, ATextCanBeClearedAndPutBackToItsDefault) { + const std::string_view key = "integrations.discord_rpc.text.state"; + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_FALSE(document.IsSet(key)); + + ASSERT_TRUE(document.SetText(key, "", &error)) << error; + EXPECT_TRUE(document.IsSet(key)); + EXPECT_EQ(document.Value(key), ""); + runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_EQ(loaded.config.discord_rpc().text.state, ""); + + ASSERT_TRUE(document.Set(key, "", &error)) << error; + EXPECT_FALSE(document.IsSet(key)); + loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_EQ(loaded.config.discord_rpc().text.state, "by {creator_name}"); +} + +TEST_F(SettingsModelTest, ATextThatMustNotBeEmptyIsNotSavedEmpty) { + const std::string_view key = "integrations.discord_rpc.text.playing"; + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + const std::string before = Read(); + EXPECT_FALSE(document.SetText(key, "", &error)); + EXPECT_FALSE(error.empty()); + EXPECT_EQ(Read(), before); + EXPECT_FALSE(document.IsSet(key)); +} + +TEST_F(SettingsModelTest, NumberValueIsAWholeNumberThatIsNotNegative) { + EXPECT_EQ(NumberValue(0), "0"); + EXPECT_EQ(NumberValue(4096), "4096"); + EXPECT_EQ(NumberValue(4095.6), "4096"); + EXPECT_EQ(NumberValue(-3), "0"); +} + TEST_F(SettingsModelTest, AHandEditMadeMeanwhileIsKeptAndShown) { Write("graphics:\n backend: opengl\n"); SettingsDocument document(file_); From 311dd058f27ff7433f0ad13e8c80100aae1b88ca Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 9 Oct 2026 18:02:51 -0500 Subject: [PATCH 07/30] feat(ui): add the fflags.json editor and show the Nightcap logo The editor saves only text that the FFlag loader accepts. --- CMakeLists.txt | 8 +- include/runtime/runtime_config_store.h | 18 +++ include/ui/settings_model.h | 37 ++++++- src/runtime/runtime_config_store.cc | 63 ++++++++--- src/ui/mocktail_settings.cc | 145 +++++++++++++++++++++++-- src/ui/settings_model.cc | 112 ++++++++++++++++++- tests/runtime_config_store_test.cc | 59 ++++++++++ tests/settings_model_test.cc | 98 +++++++++++++++-- 8 files changed, 491 insertions(+), 49 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e4bd9386..0af8b4c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -375,12 +375,18 @@ add_library(mocktail_settings_model STATIC ) add_library(Mocktail::SettingsModel ALIAS mocktail_settings_model) target_include_directories(mocktail_settings_model PUBLIC include) -target_link_libraries(mocktail_settings_model PUBLIC Mocktail::Runtime) +target_link_libraries(mocktail_settings_model PUBLIC + Mocktail::Runtime + mocktail_services +) mocktail_apply_compile_options(mocktail_settings_model) add_executable(mocktail_settings src/ui/mocktail_settings.cc ) +target_include_directories(mocktail_settings PRIVATE + ${MOCKTAIL_PLATFORM_GENERATED_INCLUDE_DIR} +) target_link_libraries(mocktail_settings PRIVATE Mocktail::SettingsModel PkgConfig::LIBADWAITA diff --git a/include/runtime/runtime_config_store.h b/include/runtime/runtime_config_store.h index 62595240..2d634a98 100644 --- a/include/runtime/runtime_config_store.h +++ b/include/runtime/runtime_config_store.h @@ -2,6 +2,7 @@ #define MOCKTAIL_RUNTIME_RUNTIME_CONFIG_STORE_H_ #include +#include #include #include @@ -38,6 +39,23 @@ RuntimeConfigSaveResult SaveRuntimeConfigText( const std::filesystem::path& config_file, std::string_view original, std::string_view text); +// Reads a text file that may not exist. A missing file reads as empty text. +// A symlink or anything that is not a regular file is refused. +RuntimeConfigText ReadOptionalTextFile(const std::filesystem::path& file); + +// Given the path of a file holding the text about to be saved, returns why it +// must not be saved, or an empty string. +using TextFileCheck = + std::function; + +// SaveRuntimeConfigText for another text file, with `check` in place of the +// configuration loader. A missing file counts as holding empty text, and the +// save creates it. Its directory must exist. +RuntimeConfigSaveResult SaveCheckedTextFile(const std::filesystem::path& file, + std::string_view original, + std::string_view text, + const TextFileCheck& check); + } // namespace runtime } // namespace mocktail diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index f9d5631d..ecc7c8cf 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -20,8 +20,7 @@ enum class SettingKind { kFrameRate, kNumber, kText, - // The window lists this so the setting has its place, and shows it grayed - // out: it has no control that edits it yet. + // A row that opens the editor for fflags.json. kEditor, }; @@ -60,10 +59,6 @@ struct SettingsPage { const std::vector& SettingsPages(); -// Whether the window can change `setting`. One it cannot change is shown -// grayed out with the file's value. -bool IsAvailable(const Setting& setting); - inline constexpr std::size_t kNoChoice = static_cast(-1); // Index of the choice that `value`, the file's value or empty when unset, @@ -130,6 +125,36 @@ class SettingsDocument { std::string text_; }; +// Why `text` is not JSON, with the line and column of the fault. Empty when +// it is JSON. Comments are allowed, as they are in fflags.json. +std::string JsonSyntaxError(std::string_view text); + +// fflags.json as the window edits it: the whole file as one text. +class FFlagsDocument { + public: + explicit FFlagsDocument(std::filesystem::path file); + + // Reads the file. Having no file is not an error. + bool Load(std::string* error); + + // What the editor starts with: the file's text, or an empty JSON object + // when there is no file or it is empty. + std::string Text() const; + + // Saves `text` as the file. Text that is not JSON, or that Nightcap would + // refuse to start with, is not saved. When the file changed on disk since + // it was read, nothing is saved, the error says so, and saving again + // replaces the file. + bool Save(std::string_view text, std::string* error); + + const std::filesystem::path& file() const { return file_; } + + private: + std::filesystem::path file_; + // The file's text as last read or saved. Empty when there is no file. + std::string disk_; +}; + } // namespace ui } // namespace mocktail diff --git a/src/runtime/runtime_config_store.cc b/src/runtime/runtime_config_store.cc index fbcdba62..bd4b2d01 100644 --- a/src/runtime/runtime_config_store.cc +++ b/src/runtime/runtime_config_store.cc @@ -50,14 +50,19 @@ bool WriteAll(int file, std::string_view contents) { return true; } -} // namespace - -RuntimeConfigText ReadRuntimeConfigText( - const std::filesystem::path& config_file) { +// Reads `path` as text. When `missing` is not null, a file that does not +// exist reads as empty text and sets it. +RuntimeConfigText ReadText(const std::filesystem::path& path, bool* missing) { RuntimeConfigText result; - const int file = - open(config_file.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (missing != nullptr) { + *missing = false; + } + const int file = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW); if (file < 0) { + if (errno == ENOENT && missing != nullptr) { + *missing = true; + return result; + } result.error = errno == ELOOP ? "refusing symlink runtime configuration" : ErrorMessage("cannot open configuration", errno); @@ -89,15 +94,19 @@ RuntimeConfigText ReadRuntimeConfigText( return result; } -RuntimeConfigSaveResult SaveRuntimeConfigText( - const std::filesystem::path& config_file, std::string_view original, - std::string_view text) { +// Publishes `text` as `config_file` once `check` accepts it. `may_be_missing` +// lets the save create the file. +RuntimeConfigSaveResult Save(const std::filesystem::path& config_file, + std::string_view original, std::string_view text, + bool may_be_missing, const TextFileCheck& check) { RuntimeConfigSaveResult result; if (config_file.empty() || config_file.filename().empty()) { result.error = "configuration path must name a file"; return result; } - const RuntimeConfigText current = ReadRuntimeConfigText(config_file); + bool missing = false; + const RuntimeConfigText current = + ReadText(config_file, may_be_missing ? &missing : nullptr); if (!current) { result.error = current.error; return result; @@ -139,10 +148,8 @@ RuntimeConfigSaveResult SaveRuntimeConfigText( } // The bytes that would be published are the bytes that get checked. - const RuntimeConfigLoadResult loaded = - LoadRuntimeConfig(NoEnvironment(), temporary_path.data()); - if (!loaded) { - result.error = loaded.error; + result.error = check(temporary_path.data()); + if (!result.error.empty()) { discard_temporary(); return result; } @@ -163,5 +170,33 @@ RuntimeConfigSaveResult SaveRuntimeConfigText( return result; } +} // namespace + +RuntimeConfigText ReadRuntimeConfigText( + const std::filesystem::path& config_file) { + return ReadText(config_file, nullptr); +} + +RuntimeConfigSaveResult SaveRuntimeConfigText( + const std::filesystem::path& config_file, std::string_view original, + std::string_view text) { + return Save(config_file, original, text, false, + [](const std::filesystem::path& candidate) { + return LoadRuntimeConfig(NoEnvironment(), candidate).error; + }); +} + +RuntimeConfigText ReadOptionalTextFile(const std::filesystem::path& file) { + bool missing = false; + return ReadText(file, &missing); +} + +RuntimeConfigSaveResult SaveCheckedTextFile(const std::filesystem::path& file, + std::string_view original, + std::string_view text, + const TextFileCheck& check) { + return Save(file, original, text, true, check); +} + } // namespace runtime } // namespace mocktail diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 125ab04e..805ad22f 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -5,6 +5,7 @@ #include #include +#include "mocktail/platform/sdl_window_icon_data.h" #include "runtime/environment.h" #include "runtime/runtime_paths.h" #include "ui/settings_model.h" @@ -12,6 +13,7 @@ namespace { using mocktail::ui::ChoiceIndex; +using mocktail::ui::FFlagsDocument; using mocktail::ui::FrameRate; using mocktail::ui::FrameRateMode; using mocktail::ui::kNoChoice; @@ -40,10 +42,13 @@ struct Row { }; struct SettingsWindow { - explicit SettingsWindow(std::filesystem::path config_file) - : document(std::move(config_file)) {} + SettingsWindow(std::filesystem::path config_file, + std::filesystem::path fflags_file) + : document(std::move(config_file)), fflags(std::move(fflags_file)) {} SettingsDocument document; + FFlagsDocument fflags; + GtkWidget* widget = nullptr; std::deque rows; AdwNavigationSplitView* split = nullptr; AdwNavigationPage* content_page = nullptr; @@ -128,15 +133,19 @@ void RefreshRows(SettingsWindow* window) { window->refreshing = false; } +void ShowError(SettingsWindow* window, const std::string& error) { + AdwToast* toast = adw_toast_new(error.c_str()); + adw_toast_set_use_markup(toast, FALSE); + adw_toast_set_timeout(toast, 0); + adw_toast_overlay_add_toast(window->toasts, toast); +} + // Shows how a save went, then shows every row as the file now has it. void ShowSaved(SettingsWindow* window, bool saved, const std::string& error) { if (saved) { gtk_revealer_set_reveal_child(window->notice, TRUE); } else { - AdwToast* toast = adw_toast_new(error.c_str()); - adw_toast_set_use_markup(toast, FALSE); - adw_toast_set_timeout(toast, 0); - adw_toast_overlay_add_toast(window->toasts, toast); + ShowError(window, error); } // On failure the rows go back to what the file holds. RefreshRows(window); @@ -200,6 +209,90 @@ void OnFrameRateChanged(GObject*, GParamSpec*, gpointer user_data) { Apply(row, mocktail::ui::FrameRateValue(rate)); } +// The dialog that edits fflags.json as text. +struct FFlagsEditor { + SettingsWindow* window = nullptr; + AdwDialog* dialog = nullptr; + GtkTextBuffer* buffer = nullptr; + AdwBanner* problem = nullptr; +}; + +void OnFFlagsCancel(GtkButton*, gpointer user_data) { + adw_dialog_close(static_cast(user_data)->dialog); +} + +void OnFFlagsSave(GtkButton*, gpointer user_data) { + auto* editor = static_cast(user_data); + GtkTextIter start; + GtkTextIter end; + gtk_text_buffer_get_bounds(editor->buffer, &start, &end); + char* text = gtk_text_buffer_get_text(editor->buffer, &start, &end, FALSE); + std::string error; + const bool saved = editor->window->fflags.Save(text, &error); + g_free(text); + if (!saved) { + // The dialog stays open with the text, so nothing typed is lost. + adw_banner_set_title(editor->problem, error.c_str()); + adw_banner_set_revealed(editor->problem, TRUE); + return; + } + gtk_revealer_set_reveal_child(editor->window->notice, TRUE); + adw_dialog_close(editor->dialog); +} + +void OnFFlagsActivated(AdwActionRow*, gpointer user_data) { + auto* window = static_cast(user_data); + std::string error; + if (!window->fflags.Load(&error)) { + ShowError(window, error); + return; + } + + auto* editor = new FFlagsEditor; + editor->window = window; + editor->dialog = adw_dialog_new(); + adw_dialog_set_title(editor->dialog, "FFlag overrides"); + adw_dialog_set_content_width(editor->dialog, 640); + adw_dialog_set_content_height(editor->dialog, 520); + g_object_set_data_full( + G_OBJECT(editor->dialog), "editor", editor, + [](gpointer data) { delete static_cast(data); }); + + GtkWidget* cancel = gtk_button_new_with_label("Cancel"); + GtkWidget* save = gtk_button_new_with_label("Save"); + gtk_widget_add_css_class(save, "suggested-action"); + g_signal_connect(cancel, "clicked", G_CALLBACK(OnFFlagsCancel), editor); + g_signal_connect(save, "clicked", G_CALLBACK(OnFFlagsSave), editor); + GtkWidget* header = adw_header_bar_new(); + adw_header_bar_set_show_start_title_buttons(ADW_HEADER_BAR(header), FALSE); + adw_header_bar_set_show_end_title_buttons(ADW_HEADER_BAR(header), FALSE); + adw_header_bar_pack_start(ADW_HEADER_BAR(header), cancel); + adw_header_bar_pack_end(ADW_HEADER_BAR(header), save); + + GtkWidget* problem = adw_banner_new(""); + editor->problem = ADW_BANNER(problem); + adw_banner_set_use_markup(editor->problem, FALSE); + + GtkWidget* view = gtk_text_view_new(); + gtk_text_view_set_monospace(GTK_TEXT_VIEW(view), TRUE); + gtk_text_view_set_top_margin(GTK_TEXT_VIEW(view), 12); + gtk_text_view_set_bottom_margin(GTK_TEXT_VIEW(view), 12); + gtk_text_view_set_left_margin(GTK_TEXT_VIEW(view), 12); + gtk_text_view_set_right_margin(GTK_TEXT_VIEW(view), 12); + editor->buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(view)); + gtk_text_buffer_set_text(editor->buffer, window->fflags.Text().c_str(), -1); + GtkWidget* scrolled = gtk_scrolled_window_new(); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), view); + + GtkWidget* toolbar = adw_toolbar_view_new(); + adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(toolbar), header); + adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(toolbar), problem); + adw_toolbar_view_set_content(ADW_TOOLBAR_VIEW(toolbar), scrolled); + adw_dialog_set_child(editor->dialog, toolbar); + adw_dialog_present(editor->dialog, window->widget); + gtk_widget_grab_focus(view); +} + void SetRowText(GtkWidget* widget, std::string_view title, std::string_view subtitle) { adw_preferences_row_set_use_markup(ADW_PREFERENCES_ROW(widget), FALSE); @@ -262,11 +355,11 @@ void AddRow(SettingsWindow* window, AdwPreferencesGroup* group, adw_action_row_add_suffix( ADW_ACTION_ROW(row.widget), gtk_image_new_from_icon_name("go-next-symbolic")); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row.widget), TRUE); + g_signal_connect(row.widget, "activated", + G_CALLBACK(OnFFlagsActivated), window); break; } - // A setting with no working control yet shows the file's value, grayed - // out. - gtk_widget_set_sensitive(row.widget, mocktail::ui::IsAvailable(setting)); SetRowText(row.widget, setting.title, setting.subtitle); adw_preferences_group_add(group, row.widget); if (row.custom != nullptr) { @@ -316,6 +409,27 @@ void OnPageActivated(GtkListBox*, GtkListBoxRow*, gpointer user_data) { static_cast(user_data)->split, TRUE); } +// The Nightcap artwork beside the name. The artwork is built in, so it shows +// whether or not the icon theme has Nightcap's icon installed. +GtkWidget* BuildSidebarTitle() { + // The artwork has clear space around it, so the gap to the name is small. + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + GBytes* bytes = g_bytes_new_static( + mocktail::platform::internal::kMocktailWindowIconPng, + mocktail::platform::internal::kMocktailWindowIconPngSize); + if (GdkTexture* texture = gdk_texture_new_from_bytes(bytes, nullptr)) { + GtkWidget* image = gtk_image_new_from_paintable(GDK_PAINTABLE(texture)); + gtk_image_set_pixel_size(GTK_IMAGE(image), 24); + gtk_box_append(GTK_BOX(box), image); + g_object_unref(texture); + } + g_bytes_unref(bytes); + GtkWidget* label = gtk_label_new("Nightcap"); + gtk_widget_add_css_class(label, "title"); + gtk_box_append(GTK_BOX(box), label); + return box; +} + GtkWidget* BuildSidebar(SettingsWindow* window) { GtkWidget* list = gtk_list_box_new(); gtk_widget_add_css_class(list, "navigation-sidebar"); @@ -340,9 +454,14 @@ GtkWidget* BuildSidebar(SettingsWindow* window) { GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), list); + // The artwork and name sit at the left, in line with the rows below. + GtkWidget* header = adw_header_bar_new(); + adw_header_bar_set_show_title(ADW_HEADER_BAR(header), FALSE); + GtkWidget* title = BuildSidebarTitle(); + gtk_widget_set_margin_start(title, 4); + adw_header_bar_pack_start(ADW_HEADER_BAR(header), title); GtkWidget* toolbar = adw_toolbar_view_new(); - adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(toolbar), - adw_header_bar_new()); + adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(toolbar), header); adw_toolbar_view_set_content(ADW_TOOLBAR_VIEW(toolbar), scrolled); gtk_list_box_select_row( GTK_LIST_BOX(list), @@ -459,6 +578,7 @@ void OnActivate(GtkApplication* application, gpointer user_data) { } GtkWidget* window_widget = adw_application_window_new(application); + window->widget = window_widget; gtk_window_set_title(GTK_WINDOW(window_widget), "Nightcap Settings"); gtk_window_set_default_size(GTK_WINDOW(window_widget), 860, 620); gtk_widget_set_size_request(window_widget, 360, 420); @@ -489,7 +609,8 @@ int main(int argc, char* argv[]) { const mocktail::runtime::RuntimePaths paths = mocktail::runtime::RuntimePaths::FromEnvironment( mocktail::runtime::ProcessEnvironment()); - SettingsWindow window(paths.config_file()); + SettingsWindow window(paths.config_file(), + paths.config_root() / "fflags.json"); // The application ID matches the desktop entry so the window gets // Nightcap's icon. Nightcap itself does not own the name on the session diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 4242779b..084292c7 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -1,14 +1,48 @@ #include "ui/settings_model.h" +#define JSON_NOEXCEPTION 1 +#include #include #include +#include #include "runtime/runtime_config_bootstrap.h" #include "runtime/runtime_config_editor.h" #include "runtime/runtime_config_store.h" +#include "services/client_settings_service.h" namespace mocktail { namespace ui { +namespace { + +// Accepts every value and records where the parser gave up. +class JsonSyntaxProbe final : public nlohmann::json_sax { + public: + bool null() override { return true; } + bool boolean(bool) override { return true; } + bool number_integer(number_integer_t) override { return true; } + bool number_unsigned(number_unsigned_t) override { return true; } + bool number_float(number_float_t, const string_t&) override { return true; } + bool string(string_t&) override { return true; } + bool binary(binary_t&) override { return true; } + bool start_object(std::size_t) override { return true; } + bool key(string_t&) override { return true; } + bool end_object() override { return true; } + bool start_array(std::size_t) override { return true; } + bool end_array() override { return true; } + bool parse_error(std::size_t position, const std::string&, + const nlohmann::detail::exception&) override { + failed = true; + fault = position; + return false; + } + + bool failed = false; + // How many bytes the parser had read when it gave up. + std::size_t fault = 0; +}; + +} // namespace const std::vector& SettingsPages() { static const std::vector pages = { @@ -243,7 +277,8 @@ const std::vector& SettingsPages() { { {"fflags", "FFlag overrides", - "Edit fflags.json. Invalid JSON is refused.", + "Edit fflags.json. Text that is not valid JSON is not " + "saved.", SettingKind::kEditor, {}, "", @@ -254,10 +289,6 @@ const std::vector& SettingsPages() { return pages; } -bool IsAvailable(const Setting& setting) { - return setting.kind != SettingKind::kEditor; -} - std::string NumberValue(double number) { return std::to_string(number > 0 ? std::llround(number) : 0LL); } @@ -392,5 +423,76 @@ bool SettingsDocument::Save(std::string edited, std::string edit_error, return true; } +std::string JsonSyntaxError(std::string_view text) { + JsonSyntaxProbe probe; + nlohmann::json::sax_parse(text, &probe, nlohmann::json::input_format_t::json, + true, true); + if (!probe.failed) { + return {}; + } + // The fault is the last byte read. + const std::size_t end = + probe.fault == 0 ? 0 : std::min(probe.fault - 1, text.size()); + std::size_t line = 1; + std::size_t column = 1; + for (const char character : text.substr(0, end)) { + if (character == '\n') { + ++line; + column = 1; + } else { + ++column; + } + } + return "Not valid JSON at line " + std::to_string(line) + ", column " + + std::to_string(column); +} + +FFlagsDocument::FFlagsDocument(std::filesystem::path file) + : file_(std::move(file)) {} + +bool FFlagsDocument::Load(std::string* error) { + runtime::RuntimeConfigText read = runtime::ReadOptionalTextFile(file_); + if (!read) { + *error = read.error; + return false; + } + disk_ = std::move(read.text); + return true; +} + +std::string FFlagsDocument::Text() const { + if (disk_.find_first_not_of(" \t\r\n") == std::string::npos) { + return "{\n}\n"; + } + return disk_; +} + +bool FFlagsDocument::Save(std::string_view text, std::string* error) { + std::string syntax = JsonSyntaxError(text); + if (!syntax.empty()) { + *error = std::move(syntax); + return false; + } + // Nightcap refuses to start on a file its loader rejects, so the loader + // itself checks the text. + const runtime::RuntimeConfigSaveResult saved = runtime::SaveCheckedTextFile( + file_, disk_, text, [](const std::filesystem::path& candidate) { + return services::LoadAndMergeFflagsFile(candidate, "{}").error; + }); + if (!saved) { + *error = saved.error; + runtime::RuntimeConfigText read = runtime::ReadOptionalTextFile(file_); + if (read && read.text != disk_) { + disk_ = std::move(read.text); + *error = + "fflags.json changed on disk since it was opened. Save again to " + "replace it."; + } + return false; + } + disk_ = std::string(text); + return true; +} + } // namespace ui } // namespace mocktail diff --git a/tests/runtime_config_store_test.cc b/tests/runtime_config_store_test.cc index 7c847a46..088e2cca 100644 --- a/tests/runtime_config_store_test.cc +++ b/tests/runtime_config_store_test.cc @@ -180,6 +180,65 @@ TEST_F(RuntimeConfigStoreTest, AnEditedFirstRunFileSavesAndReadsBack) { EXPECT_EQ(Mode(file_), 0600u); } +std::string AcceptAnything(const std::filesystem::path&) { return {}; } + +TEST_F(RuntimeConfigStoreTest, AnOptionalFileThatIsMissingReadsAsEmpty) { + const std::filesystem::path extra = directory_ / "extra.json"; + const RuntimeConfigText read = ReadOptionalTextFile(extra); + ASSERT_TRUE(read) << read.error; + EXPECT_EQ(read.text, ""); + + Write(extra, "{}\n"); + EXPECT_EQ(ReadOptionalTextFile(extra).text, "{}\n"); +} + +TEST_F(RuntimeConfigStoreTest, ACheckedSaveCreatesAMissingFile) { + const std::filesystem::path extra = directory_ / "extra.json"; + std::string checked; + const RuntimeConfigSaveResult saved = SaveCheckedTextFile( + extra, "", "{\"a\": 1}\n", [&](const std::filesystem::path& candidate) { + checked = Read(candidate); + return std::string(); + }); + ASSERT_TRUE(saved) << saved.error; + // The check saw the bytes that were then published. + EXPECT_EQ(checked, "{\"a\": 1}\n"); + EXPECT_EQ(Read(extra), "{\"a\": 1}\n"); + EXPECT_EQ(Mode(extra), 0600u); + EXPECT_EQ(EntryCount(), 1u); +} + +TEST_F(RuntimeConfigStoreTest, ACheckedSaveThatFailsItsCheckChangesNothing) { + const std::filesystem::path extra = directory_ / "extra.json"; + Write(extra, "{}\n"); + const RuntimeConfigSaveResult saved = SaveCheckedTextFile( + extra, "{}\n", "nonsense", + [](const std::filesystem::path&) { return std::string("no good"); }); + EXPECT_FALSE(saved); + EXPECT_EQ(saved.error, "no good"); + EXPECT_EQ(Read(extra), "{}\n"); + EXPECT_EQ(EntryCount(), 1u); +} + +TEST_F(RuntimeConfigStoreTest, ACheckedSaveRefusesAFileThatAppearedOrChanged) { + const std::filesystem::path extra = directory_ / "extra.json"; + Write(extra, "{\"by_hand\": 1}\n"); + EXPECT_FALSE(SaveCheckedTextFile(extra, "", "{}\n", AcceptAnything)); + EXPECT_FALSE(SaveCheckedTextFile(extra, "{}\n", "{}\n", AcceptAnything)); + EXPECT_EQ(Read(extra), "{\"by_hand\": 1}\n"); +} + +TEST_F(RuntimeConfigStoreTest, ACheckedSaveRefusesASymlink) { + const std::filesystem::path target = directory_ / "target.json"; + const std::filesystem::path extra = directory_ / "extra.json"; + Write(target, "{}\n"); + std::filesystem::create_symlink(target, extra); + EXPECT_FALSE(SaveCheckedTextFile(extra, "{}\n", "{\"a\": 1}\n", + AcceptAnything)); + EXPECT_FALSE(ReadOptionalTextFile(extra)); + EXPECT_EQ(Read(target), "{}\n"); +} + } // namespace } // namespace runtime } // namespace mocktail diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index c9684a8c..b07fe92e 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -13,6 +13,7 @@ #include "runtime/environment.h" #include "runtime/runtime_config_file.h" +#include "services/client_settings_service.h" namespace mocktail { namespace ui { @@ -70,6 +71,18 @@ class SettingsModelTest : public testing::Test { return runtime::LoadRuntimeConfig(EmptyEnvironment(), file_); } + // fflags.json beside config.yaml, in a folder that exists. + std::filesystem::path FFlagsFile() { + std::filesystem::create_directories(file_.parent_path()); + return file_.parent_path() / "fflags.json"; + } + + std::string ReadFFlags() { + std::ostringstream contents; + contents << std::ifstream(FFlagsFile(), std::ios::binary).rdbuf(); + return contents.str(); + } + std::filesystem::path directory_; std::filesystem::path file_; }; @@ -104,16 +117,6 @@ TEST_F(SettingsModelTest, PagesOfOneSectionAreNextToEachOther) { } } -TEST_F(SettingsModelTest, OnlySettingsWithAControlAreAvailable) { - EXPECT_TRUE(IsAvailable(Find("graphics.backend"))); - EXPECT_TRUE(IsAvailable(Find("graphics.frame_rate_limit"))); - EXPECT_TRUE(IsAvailable(Find("integrations.discord_rpc.enabled"))); - EXPECT_TRUE(IsAvailable(Find("performance.memory_limit_mb"))); - EXPECT_TRUE(IsAvailable(Find("audio.output_device"))); - EXPECT_TRUE(IsAvailable(Find("integrations.discord_rpc.text.title"))); - EXPECT_FALSE(IsAvailable(Find("fflags"))); -} - TEST_F(SettingsModelTest, EveryValueTheWindowOffersSavesAndLoads) { SettingsDocument document(file_); std::string error; @@ -122,7 +125,7 @@ TEST_F(SettingsModelTest, EveryValueTheWindowOffersSavesAndLoads) { for (const SettingsPage& page : SettingsPages()) { for (const SettingsGroup& group : page.groups) { for (const Setting& setting : group.settings) { - if (!IsAvailable(setting)) { + if (setting.kind == SettingKind::kEditor) { continue; } if (setting.kind == SettingKind::kText) { @@ -286,6 +289,79 @@ TEST_F(SettingsModelTest, AHandEditMadeMeanwhileIsKeptAndShown) { EXPECT_EQ(Read(), "graphics:\n backend: angle-vulkan\n vsync: on\n"); } +TEST_F(SettingsModelTest, FFlagsStartAsAnEmptyObjectWhenThereIsNoFile) { + FFlagsDocument document(FFlagsFile()); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_EQ(document.Text(), "{\n}\n"); + EXPECT_FALSE(std::filesystem::exists(FFlagsFile())); +} + +TEST_F(SettingsModelTest, SavedFFlagsAreWhatNightcapLoads) { + FFlagsDocument document(FFlagsFile()); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + const std::string text = + "{\n // keep the frame rate open\n \"DFIntTaskSchedulerTargetFps\": 240,\n" + " \"FFlagDebugSkyGray\": true\n}\n"; + ASSERT_TRUE(document.Save(text, &error)) << error; + EXPECT_EQ(ReadFFlags(), text); + EXPECT_EQ(document.Text(), text); + + const services::FflagsMergeResult loaded = + services::LoadAndMergeFflagsFile(FFlagsFile(), "{}"); + ASSERT_TRUE(loaded.error.empty()) << loaded.error; + EXPECT_EQ(loaded.count, 2u); + + // A second save starts from what the first one wrote. + ASSERT_TRUE(document.Save("{}\n", &error)) << error; + EXPECT_EQ(ReadFFlags(), "{}\n"); +} + +TEST_F(SettingsModelTest, BrokenFFlagsAreRefusedWithTheLineAndColumn) { + FFlagsDocument document(FFlagsFile()); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + ASSERT_TRUE(document.Save("{}\n", &error)) << error; + + EXPECT_FALSE(document.Save("{\n \"a\": 1,\n \"b\" 2\n}\n", &error)); + EXPECT_NE(error.find("line 3"), std::string::npos) << error; + EXPECT_NE(error.find("column 7"), std::string::npos) << error; + EXPECT_EQ(ReadFFlags(), "{}\n"); + + EXPECT_FALSE(document.Save("", &error)); + EXPECT_EQ(ReadFFlags(), "{}\n"); +} + +TEST_F(SettingsModelTest, FFlagsNightcapWouldRejectAreRefused) { + FFlagsDocument document(FFlagsFile()); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + for (const std::string_view text : + {"[1, 2]\n", "{\"a\": {\"nested\": 1}}\n", "{\"a\": 1.5}\n", + "{\"\": 1}\n", "{\"a\": null}\n"}) { + error.clear(); + EXPECT_FALSE(document.Save(text, &error)) << text; + EXPECT_FALSE(error.empty()) << text; + EXPECT_FALSE(std::filesystem::exists(FFlagsFile())) << text; + } +} + +TEST_F(SettingsModelTest, FFlagsEditedByHandMeanwhileNeedASecondSave) { + FFlagsDocument document(FFlagsFile()); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + ASSERT_TRUE(document.Save("{}\n", &error)) << error; + + std::ofstream(FFlagsFile(), std::ios::binary) << "{\"by_hand\": 1}\n"; + EXPECT_FALSE(document.Save("{\"mine\": 2}\n", &error)); + EXPECT_NE(error.find("Save again"), std::string::npos) << error; + EXPECT_EQ(ReadFFlags(), "{\"by_hand\": 1}\n"); + + ASSERT_TRUE(document.Save("{\"mine\": 2}\n", &error)) << error; + EXPECT_EQ(ReadFFlags(), "{\"mine\": 2}\n"); +} + TEST_F(SettingsModelTest, ChoiceIndexMapsDefaultsAndOtherSpellings) { const Setting& backend = Find("graphics.backend"); EXPECT_EQ(ChoiceIndex(backend, ""), 0u); From 64dca7592e255abf9ac2d4031e454250b1fddc9c Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 9 Oct 2026 18:42:18 -0500 Subject: [PATCH 08/30] feat(ui): open the settings window with --settings and from the app menu --- CMakeLists.txt | 7 +- include/runtime/command_line.h | 2 + include/runtime/settings_launcher.h | 29 ++++ packaging/anylinux.env | 1 + .../io.github.CoderDayton.nightcap.desktop | 5 + packaging/mocktail-launcher.sh | 12 +- scripts/build_release.sh | 1 + scripts/package_anylinux.sh | 4 +- scripts/package_portable.sh | 2 + scripts/register_url_handler.sh | 8 +- src/main.cc | 13 ++ src/runtime/command_line.cc | 7 + src/runtime/settings_launcher.cc | 89 ++++++++++++ tests/CMakeLists.txt | 8 ++ tests/anylinux_packaging_test.sh | 15 +- tests/command_line_test.cc | 23 ++- tests/portable_packaging_test.sh | 3 + tests/settings_launcher_test.cc | 131 ++++++++++++++++++ tests/system_install_test.sh | 3 + tests/url_handler_registration_test.sh | 6 + 20 files changed, 355 insertions(+), 14 deletions(-) create mode 100644 include/runtime/settings_launcher.h create mode 100644 src/runtime/settings_launcher.cc create mode 100644 tests/settings_launcher_test.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index 0af8b4c1..eec760f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -356,6 +356,7 @@ target_link_libraries(mocktail_runtime PRIVATE # Keep GTK out of the process that loads Android libraries. add_library(mocktail_failure_dialog_runtime STATIC src/runtime/failure_dialog.cc + src/runtime/settings_launcher.cc ) add_library(Mocktail::FailureDialog ALIAS mocktail_failure_dialog_runtime) target_include_directories(mocktail_failure_dialog_runtime PUBLIC include) @@ -694,6 +695,7 @@ install(TARGETS mocktail mocktail_updater mocktail_failure_dialog + mocktail_settings mocktail_webview_helper mocktail_audio_sdl mocktail_opensles @@ -732,6 +734,9 @@ install(CODE " string(REPLACE \"Exec=env SDL_VIDEODRIVER=wayland,x11 mocktail %u\" \"Exec=env SDL_VIDEODRIVER=wayland,x11 \${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/mocktail %u\" MOCKTAIL_DESKTOP_ENTRY \"\${MOCKTAIL_DESKTOP_ENTRY}\") + string(REPLACE \"Exec=mocktail --settings\" + \"Exec=\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/mocktail --settings\" + MOCKTAIL_DESKTOP_ENTRY \"\${MOCKTAIL_DESKTOP_ENTRY}\") file(WRITE \"${CMAKE_BINARY_DIR}/io.github.CoderDayton.nightcap.desktop\" \"\${MOCKTAIL_DESKTOP_ENTRY}\") file(INSTALL \"${CMAKE_BINARY_DIR}/io.github.CoderDayton.nightcap.desktop\" @@ -835,7 +840,7 @@ set(CPACK_RPM_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}") include(CPack) add_dependencies(mocktail all_stubs) -add_dependencies(mocktail mocktail_failure_dialog) +add_dependencies(mocktail mocktail_failure_dialog mocktail_settings) add_dependencies(mocktail mocktail_webview_helper) if(BUILD_TESTING) diff --git a/include/runtime/command_line.h b/include/runtime/command_line.h index f0d1c7fa..731be000 100644 --- a/include/runtime/command_line.h +++ b/include/runtime/command_line.h @@ -10,6 +10,8 @@ namespace runtime { enum class CommandMode { kRun, kHelp, + // Open the settings window in place of the game. + kSettings, }; enum class WindowMode { diff --git a/include/runtime/settings_launcher.h b/include/runtime/settings_launcher.h new file mode 100644 index 00000000..1dc9152e --- /dev/null +++ b/include/runtime/settings_launcher.h @@ -0,0 +1,29 @@ +#ifndef MOCKTAIL_RUNTIME_SETTINGS_LAUNCHER_H_ +#define MOCKTAIL_RUNTIME_SETTINGS_LAUNCHER_H_ + +#include +#include + +#include "runtime/environment.h" + +namespace mocktail { +namespace runtime { + +// The settings window is its own program, so that GTK stays out of the +// process that loads Roblox. + +// Path of the settings window program, or empty when there is none to run. +// MOCKTAIL_SETTINGS_HELPER names it outright: it must be an absolute path to +// a regular executable file, and nothing else is tried when it is set. +// Otherwise the program is looked for under MOCKTAIL_PROJECT_ROOT, then +// beside this executable and in its install directories. +std::filesystem::path FindSettingsWindow(const Environment& environment); + +// Replaces this process with the settings window. Returns only on failure, +// with `error` set. +bool ExecSettingsWindow(const Environment& environment, std::string* error); + +} // namespace runtime +} // namespace mocktail + +#endif // MOCKTAIL_RUNTIME_SETTINGS_LAUNCHER_H_ diff --git a/packaging/anylinux.env b/packaging/anylinux.env index d8c8cc17..6155f9c2 100644 --- a/packaging/anylinux.env +++ b/packaging/anylinux.env @@ -12,6 +12,7 @@ MOCKTAIL_UPDATE_HELPER=${SHARUN_DIR}/bin/mocktail_updater MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog MOCKTAIL_UPDATE_PROGRESS_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog +MOCKTAIL_SETTINGS_HELPER=${SHARUN_DIR}/bin/mocktail_settings MOCKTAIL_FREEBSD_SOCKET_HELPER=${SHARUN_DIR}/share/mocktail/helpers/mocktail_freebsd_socket_helper # sharun reloads .env for every child. Publish a default separately so it # cannot overwrite a candidate-specific manifest supplied by the updater. diff --git a/packaging/io.github.CoderDayton.nightcap.desktop b/packaging/io.github.CoderDayton.nightcap.desktop index af53fcf4..31b42842 100644 --- a/packaging/io.github.CoderDayton.nightcap.desktop +++ b/packaging/io.github.CoderDayton.nightcap.desktop @@ -11,3 +11,8 @@ Categories=Game; Terminal=false MimeType=x-scheme-handler/roblox;x-scheme-handler/roblox-player; X-Mocktail-Managed=true +Actions=settings; + +[Desktop Action settings] +Name=Settings +Exec=mocktail --settings diff --git a/packaging/mocktail-launcher.sh b/packaging/mocktail-launcher.sh index 5ea56330..f8a5e899 100755 --- a/packaging/mocktail-launcher.sh +++ b/packaging/mocktail-launcher.sh @@ -42,6 +42,7 @@ fi MAIN_BINARY="${BIN_DIR}/mocktail" UPDATE_HELPER="${BIN_DIR}/mocktail_updater" FAILURE_DIALOG_HELPER="${BIN_DIR}/mocktail_failure_dialog" +SETTINGS_HELPER="${BIN_DIR}/mocktail_settings" WEBVIEW_HELPER="${BIN_DIR}/mocktail_webview_helper" FREEBSD_SOCKET_HELPER="${BUNDLED_BIN_DIR}/mocktail_freebsd_socket_helper" ANDROID_BUILD_TOOLS="${RUNTIME_ROOT}/runtime/android-tools/bin" @@ -424,6 +425,7 @@ CheckSystem() { if [[ "${ABI_MODE}" == thin ]]; then CheckElfDependencies "${MAIN_BINARY}" runtime || status=1 CheckElfDependencies "${FAILURE_DIALOG_HELPER}" libadwaita/GTK || status=1 + CheckElfDependencies "${SETTINGS_HELPER}" libadwaita/GTK || status=1 CheckElfDependencies "${WEBVIEW_HELPER}" WebKit/GTK || status=1 fi @@ -486,6 +488,8 @@ CheckSystem() { [[ -x "${UPDATE_HELPER}" ]] || Die "missing executable: ${UPDATE_HELPER}" [[ -x "${FAILURE_DIALOG_HELPER}" ]] || Die "missing executable: ${FAILURE_DIALOG_HELPER}" +[[ -x "${SETTINGS_HELPER}" ]] || + Die "missing executable: ${SETTINGS_HELPER}" [[ -x "${WEBVIEW_HELPER}" ]] || Die "missing WebView helper: ${WEBVIEW_HELPER}" [[ -x "${ANDROID_BUILD_TOOLS}/aapt" && -x "${ANDROID_BUILD_TOOLS}/apksigner" ]] || @@ -505,11 +509,13 @@ if [[ "${1:-}" == --check-system ]]; then fi data_root="${MOCKTAIL_DATA_ROOT:-${XDG_DATA_HOME:-${HOME}/.local/share}/mocktail}" -information_only=false +# The host check is for starting the game. Help and the settings window do +# not start it, and Settings must open on a host that cannot run it yet. +starts_game=true case "${1:-}" in - --help|-h) information_only=true ;; + --help|-h|--settings) starts_game=false ;; esac -if [[ "${information_only}" == false && -z "${ROBLOX_LIB_PATH:-}" && +if [[ "${starts_game}" == true && -z "${ROBLOX_LIB_PATH:-}" && ! -f "${data_root}/current.json" && "${MOCKTAIL_SKIP_HOST_CHECK:-0}" != 1 ]]; then printf 'Mocktail first-run host check...\n' diff --git a/scripts/build_release.sh b/scripts/build_release.sh index cf379b90..c14a09b3 100755 --- a/scripts/build_release.sh +++ b/scripts/build_release.sh @@ -224,6 +224,7 @@ CollectBuildArtifacts() { output+=("${artifact}") done < <(find "${BUILD_DIR}" -maxdepth 1 -type f \ \( -name mocktail -o -name mocktail_failure_dialog -o \ + -name mocktail_settings -o \ -name mocktail_webview_helper -o -name '*.so' \) \ -print0 | sort -z) (( ${#output[@]} > 0 )) || Die "no linked ELF artifacts in ${BUILD_DIR}" diff --git a/scripts/package_anylinux.sh b/scripts/package_anylinux.sh index 0ade2634..cbfec645 100755 --- a/scripts/package_anylinux.sh +++ b/scripts/package_anylinux.sh @@ -76,6 +76,7 @@ AnyLinuxVerifyInstalled() { for entry in /usr/bin/mocktail \ /usr/lib/mocktail/mocktail_updater \ /usr/lib/mocktail/mocktail_failure_dialog \ + /usr/lib/mocktail/mocktail_settings \ /usr/lib/mocktail/mocktail_webview_helper; do [[ -x "${entry}" ]] || AnyLinuxDie "missing installed executable: ${entry}" done @@ -101,7 +102,7 @@ AnyLinuxVerifyAppDir() { [[ ! -e "${app_dir}/share/mocktail-bundle" ]] || AnyLinuxDie "a portable bundle was embedded in AppDir" for entry in mocktail mocktail_updater mocktail_failure_dialog \ - mocktail_webview_helper; do + mocktail_settings mocktail_webview_helper; do [[ -x "${app_dir}/bin/${entry}" ]] || AnyLinuxDie "quick-sharun did not deploy ${entry}" done @@ -194,6 +195,7 @@ EOF /usr/bin/mocktail \ /usr/lib/mocktail/mocktail_webview_helper \ /usr/lib/mocktail/mocktail_failure_dialog \ + /usr/lib/mocktail/mocktail_settings \ /usr/lib/mocktail/mocktail_updater \ /usr/lib/mocktail /usr/share/mocktail /usr/bin/bash AnyLinuxVerifyAppDir "${app_dir}" diff --git a/scripts/package_portable.sh b/scripts/package_portable.sh index 32a6f52d..c499ecbf 100755 --- a/scripts/package_portable.sh +++ b/scripts/package_portable.sh @@ -35,6 +35,7 @@ readonly -a PROJECT_ARTIFACTS=( mocktail mocktail_updater mocktail_failure_dialog + mocktail_settings mocktail_webview_helper libmocktail_audio_sdl.so libc.so @@ -759,6 +760,7 @@ PatchRuntimePaths() { patchelf --set-rpath '$ORIGIN:$ORIGIN/../lib' \ "${STAGING}/mocktail/bin/mocktail" \ "${STAGING}/mocktail/bin/mocktail_failure_dialog" \ + "${STAGING}/mocktail/bin/mocktail_settings" \ "${STAGING}/mocktail/bin/mocktail_webview_helper" } diff --git a/scripts/register_url_handler.sh b/scripts/register_url_handler.sh index 0157d5fa..e224d7e0 100755 --- a/scripts/register_url_handler.sh +++ b/scripts/register_url_handler.sh @@ -146,7 +146,8 @@ desktop_source="$(ResolveRegularFile "${desktop_source}" "desktop template")" "$(grep -Fxc 'MimeType=x-scheme-handler/roblox;x-scheme-handler/roblox-player;' \ "${desktop_source}")" == 1 && "$(grep -Fxc 'X-Mocktail-Managed=true' "${desktop_source}")" == 1 && - "$(grep -c '^Exec=' "${desktop_source}")" == 1 ]] || + "$(grep -Fxc 'Exec=mocktail --settings' "${desktop_source}")" == 1 && + "$(grep -c '^Exec=' "${desktop_source}")" == 2 ]] || Die "desktop template does not match the Mocktail URL-handler contract" [[ -n "${HOME:-}" && "${HOME}" == /* ]] || @@ -174,7 +175,10 @@ fi temporary="$(mktemp "${applications_dir}/.${DESKTOP_ID}.XXXXXX.desktop")" trap 'rm -f -- "${temporary}"' EXIT while IFS= read -r line || [[ -n "${line}" ]]; do - if [[ "${line}" == Exec=* ]]; then + if [[ "${line}" == 'Exec=mocktail --settings' ]]; then + # The Settings action. Path is a key of the main entry only. + printf 'Exec=%s --settings\n' "${mocktail_executable}" + elif [[ "${line}" == Exec=* ]]; then printf 'Exec=env SDL_VIDEODRIVER=wayland,x11 %s %%u\n' \ "${mocktail_executable}" if [[ -n "${launch_working_directory}" ]]; then diff --git a/src/main.cc b/src/main.cc index f1c9dfd2..c5295fe8 100644 --- a/src/main.cc +++ b/src/main.cc @@ -47,6 +47,7 @@ #include "runtime/runtime_config_file.h" #include "runtime/runtime_paths.h" #include "runtime/session_log.h" +#include "runtime/settings_launcher.h" #include "runtime/single_instance_lock.h" #include "runtime/support_bundle.h" #include "runtime/supported_launch_policy.h" @@ -260,6 +261,18 @@ int main(int argc, char* argv[]) { command_line.options.program_name); return EXIT_SUCCESS; } + if (command_line.options.mode == mocktail::runtime::CommandMode::kSettings) { + // The window reports a broken config itself, so it starts before the + // config is loaded. + const mocktail::runtime::ProcessEnvironment settings_environment; + std::string settings_error; + mocktail::runtime::ExecSettingsWindow(settings_environment, + &settings_error); + std::cerr << settings_error << '\n'; + // Started from the app menu there is no terminal to read that on. + mocktail::runtime::ShowFailureDialog(settings_environment, settings_error); + return EXIT_FAILURE; + } // Normalize inherited process state before bootstrap or helpers can create // threads. Keep research/canary resource limits under their caller's control. const std::string process_launch_diagnostics = diff --git a/src/runtime/command_line.cc b/src/runtime/command_line.cc index 0d302108..2c7d0ac3 100644 --- a/src/runtime/command_line.cc +++ b/src/runtime/command_line.cc @@ -67,6 +67,8 @@ CommandLineParseResult ParseCommandLine(int argc, const char* const argv[]) { &result.error)) { return result; } + } else if (argument == "--settings") { + result.options.mode = CommandMode::kSettings; } else if (argument == "--headless") { result.options.window_mode = WindowMode::kHeadless; } else if (argument == "--windowed") { @@ -145,6 +147,10 @@ CommandLineParseResult ParseCommandLine(int argc, const char* const argv[]) { return result; } } + if (result.options.mode == CommandMode::kSettings && argc != 2) { + result.error = "--settings must be used on its own"; + return result; + } if (result.options.force_run_latest && (!result.options.roblox_library_path.empty() || result.options.window_mode != WindowMode::kUnspecified || @@ -281,6 +287,7 @@ std::string CommandLineUsage(const std::string& program_name) { "website link\n" << " --profile Write a Chrome trace of Vulkan adapter " "work for ui.perfetto.dev\n" + << " --settings Open the settings window\n" << " --help, -h Show this help\n\n" << "Auth:\n" << " When no saved Roblox session is found, Roblox's welcome screen " diff --git a/src/runtime/settings_launcher.cc b/src/runtime/settings_launcher.cc new file mode 100644 index 00000000..ffc3301c --- /dev/null +++ b/src/runtime/settings_launcher.cc @@ -0,0 +1,89 @@ +#include "runtime/settings_launcher.h" + +#include + +#include +#include +#include +#include +#include + +#ifndef MOCKTAIL_INSTALL_LIBDIR +#define MOCKTAIL_INSTALL_LIBDIR "lib" +#endif + +namespace mocktail { +namespace runtime { +namespace { + +constexpr char kProgramName[] = "mocktail_settings"; + +bool IsExecutableRegularFile(const std::filesystem::path& path) { + std::error_code error; + return std::filesystem::is_regular_file(path, error) && + !std::filesystem::is_symlink(path, error) && + access(path.c_str(), X_OK) == 0; +} + +} // namespace + +std::filesystem::path FindSettingsWindow(const Environment& environment) { + const std::optional named = + environment.Get("MOCKTAIL_SETTINGS_HELPER"); + if (named.has_value()) { + const std::filesystem::path helper(*named); + return helper.is_absolute() && IsExecutableRegularFile(helper) + ? helper + : std::filesystem::path{}; + } + + const std::optional project_root = + environment.Get("MOCKTAIL_PROJECT_ROOT"); + if (project_root.has_value()) { + const std::filesystem::path root(*project_root); + if (root.is_absolute()) { + const std::filesystem::path helper = root / "bin" / kProgramName; + if (IsExecutableRegularFile(helper)) { + return helper; + } + } + } + + std::error_code error; + const std::filesystem::path executable = + std::filesystem::read_symlink("/proc/self/exe", error); + if (error || executable.empty()) { + return {}; + } + const std::filesystem::path executable_dir = executable.parent_path(); + const std::vector candidates = { + executable_dir / kProgramName, + executable_dir.parent_path() / MOCKTAIL_INSTALL_LIBDIR / "mocktail" / + kProgramName, + executable_dir.parent_path() / "libexec" / "mocktail" / kProgramName, + }; + for (const std::filesystem::path& candidate : candidates) { + if (IsExecutableRegularFile(candidate)) { + return candidate; + } + } + return {}; +} + +bool ExecSettingsWindow(const Environment& environment, std::string* error) { + const std::filesystem::path helper = FindSettingsWindow(environment); + if (helper.empty()) { + *error = "the settings window program (mocktail_settings) is not " + "installed"; + return false; + } + std::string helper_string = helper.string(); + char* arguments[] = {helper_string.data(), nullptr}; + execv(helper.c_str(), arguments); + *error = "cannot start the settings window: " + + std::string(std::strerror(errno)); + return false; +} + +} // namespace runtime +} // namespace mocktail diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ce426815..adf28b9a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -148,6 +148,13 @@ target_link_libraries(failure_dialog_test PRIVATE GTest::gtest_main ) +add_executable(settings_launcher_test settings_launcher_test.cc) +target_link_libraries(settings_launcher_test PRIVATE + Mocktail::FailureDialog + Mocktail::Runtime + GTest::gtest_main +) + add_executable(frame_rate_policy_test frame_rate_policy_test.cc) target_link_libraries(frame_rate_policy_test PRIVATE Mocktail::Runtime @@ -1073,6 +1080,7 @@ gtest_discover_tests(support_bundle_runtime_test) gtest_discover_tests(vulkan_icd_selection_test) gtest_discover_tests(session_log_test) gtest_discover_tests(failure_dialog_test) +gtest_discover_tests(settings_launcher_test) gtest_discover_tests(frame_rate_policy_test) gtest_discover_tests(performance_policy_test) gtest_discover_tests(texture_memory_policy_test) diff --git a/tests/anylinux_packaging_test.sh b/tests/anylinux_packaging_test.sh index 57709648..446c948e 100644 --- a/tests/anylinux_packaging_test.sh +++ b/tests/anylinux_packaging_test.sh @@ -94,12 +94,13 @@ if [[ "$1" == --make-appimage ]]; then fi exit 0 fi -[[ "$#" == 7 && "$1" == /usr/bin/mocktail && +[[ "$#" == 8 && "$1" == /usr/bin/mocktail && "$2" == /usr/lib/mocktail/mocktail_webview_helper && "$3" == /usr/lib/mocktail/mocktail_failure_dialog && - "$4" == /usr/lib/mocktail/mocktail_updater && - "$5" == /usr/lib/mocktail && "$6" == /usr/share/mocktail && - "$7" == /usr/bin/bash ]] + "$4" == /usr/lib/mocktail/mocktail_settings && + "$5" == /usr/lib/mocktail/mocktail_updater && + "$6" == /usr/lib/mocktail && "$7" == /usr/share/mocktail && + "$8" == /usr/bin/bash ]] [[ "${DESKTOP}" == /usr/share/applications/io.github.CoderDayton.nightcap.desktop ]] [[ "${MAIN_BIN}" == mocktail && "${STRACE_MODE}" == 1 ]] [[ "${STRACE_BINARY}" == mocktail && "${STRACE_FLAGS}" == --help ]] @@ -123,7 +124,8 @@ for metadata in roblox_compatibility roblox_bootstrap_sources \ roblox_host_abi_reference roblox_signing_certificates; do printf '{}\n' >"${APPDIR}/share/mocktail/metadata/${metadata}.json" done -for name in mocktail_updater mocktail_failure_dialog mocktail_webview_helper \ +for name in mocktail_updater mocktail_failure_dialog mocktail_settings \ + mocktail_webview_helper \ WebKitNetworkProcess WebKitWebProcess WebKitGPUProcess; do cp -- "$(type -P true)" "${APPDIR}/bin/${name}" done @@ -261,7 +263,8 @@ set -Eeuo pipefail prefix="${DESTDIR}/usr" mkdir -p "${prefix}/bin" "${prefix}/lib/mocktail" "${prefix}/share/mocktail/metadata" cp -- "${MOCKTAIL_PACKAGING_FIXTURES}/mocktail" "${prefix}/bin/mocktail" -for helper in mocktail_updater mocktail_failure_dialog mocktail_webview_helper; do +for helper in mocktail_updater mocktail_failure_dialog mocktail_settings \ + mocktail_webview_helper; do cp -- "$(type -P true)" "${prefix}/lib/mocktail/${helper}" done for adapter in libEGL.so libvulkan.so libmediandk.so; do diff --git a/tests/command_line_test.cc b/tests/command_line_test.cc index 8de12d41..a9eb3687 100644 --- a/tests/command_line_test.cc +++ b/tests/command_line_test.cc @@ -302,13 +302,34 @@ TEST(CommandLineTest, HelpIsATypedAuxiliaryMode) { EXPECT_EQ(help_result.options.mode, CommandMode::kHelp); } +TEST(CommandLineTest, SettingsIsATypedAuxiliaryMode) { + const std::array settings = {"mocktail", "--settings"}; + const CommandLineParseResult result = + ParseCommandLine(settings.size(), settings.data()); + ASSERT_TRUE(result) << result.error; + EXPECT_EQ(result.options.mode, CommandMode::kSettings); +} + +TEST(CommandLineTest, SettingsMustBeUsedOnItsOwn) { + for (const std::array& arguments : + {std::array{"mocktail", "--settings", "--headless"}, + std::array{"mocktail", "--headless", "--settings"}, + std::array{"mocktail", "--settings", + "--force-run-latest"}}) { + const CommandLineParseResult result = + ParseCommandLine(arguments.size(), arguments.data()); + EXPECT_FALSE(result) << arguments[1] << ' ' << arguments[2]; + EXPECT_EQ(result.error, "--settings must be used on its own"); + } +} + TEST(CommandLineTest, UsageContainsEverySupportedOption) { const std::string usage = CommandLineUsage("mocktail-test"); EXPECT_NE(usage.find("mocktail-test"), std::string::npos); for (const char* option : {"--roblox-lib", "--headless", "--windowed", "--graphics", "--allow-unverified-build", "--force-run-latest", "--launch-uri", - "--profile", "--help"}) { + "--profile", "--settings", "--help"}) { EXPECT_NE(usage.find(option), std::string::npos) << option; } EXPECT_EQ(usage.find("--login"), std::string::npos); diff --git a/tests/portable_packaging_test.sh b/tests/portable_packaging_test.sh index 4572fb6d..e2d11a6a 100755 --- a/tests/portable_packaging_test.sh +++ b/tests/portable_packaging_test.sh @@ -121,6 +121,7 @@ for path in \ mocktail/bin/mocktail \ mocktail/bin/mocktail_updater \ mocktail/bin/mocktail_failure_dialog \ + mocktail/bin/mocktail_settings \ mocktail/bin/mocktail_webview_helper \ mocktail/bin/mocktail_freebsd_socket_helper \ mocktail/bin/libEGL.so \ @@ -271,6 +272,8 @@ int main(void) { EOF cp -- "${musl_runtime}/bin/mocktail" \ "${musl_runtime}/bin/mocktail_failure_dialog" + cp -- "${musl_runtime}/bin/mocktail" \ + "${musl_runtime}/bin/mocktail_settings" cp -- "${musl_runtime}/bin/mocktail" \ "${musl_runtime}/bin/mocktail_updater" cp -- "${musl_runtime}/bin/mocktail" \ diff --git a/tests/settings_launcher_test.cc b/tests/settings_launcher_test.cc new file mode 100644 index 00000000..6a0315a5 --- /dev/null +++ b/tests/settings_launcher_test.cc @@ -0,0 +1,131 @@ +#include "runtime/settings_launcher.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mocktail { +namespace runtime { +namespace { + +class MapEnvironment final : public Environment { + public: + explicit MapEnvironment(std::unordered_map values) + : values_(std::move(values)) {} + + std::optional Get(std::string_view name) const override { + const auto found = values_.find(std::string(name)); + return found == values_.end() ? std::nullopt + : std::optional(found->second); + } + + private: + std::unordered_map values_; +}; + +class SettingsLauncherTest : public testing::Test { + protected: + void SetUp() override { + char pattern[] = "/tmp/mocktail_settings_launcher_XXXXXX"; + const char* created = mkdtemp(pattern); + ASSERT_NE(created, nullptr); + directory_ = created; + } + + void TearDown() override { + std::error_code error; + std::filesystem::remove_all(directory_, error); + } + + // A program that records that it ran, and with which arguments. + std::filesystem::path WriteHelper(const std::filesystem::path& path) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path) << "#!/bin/sh\nprintf 'ran %s\\n' \"$*\" >'" + << (directory_ / "ran").string() << "'\n"; + chmod(path.c_str(), 0755); + return path; + } + + std::string Ran() { + std::ostringstream contents; + contents << std::ifstream(directory_ / "ran").rdbuf(); + return contents.str(); + } + + std::filesystem::path directory_; +}; + +TEST_F(SettingsLauncherTest, UsesTheProgramTheEnvironmentNames) { + const std::filesystem::path helper = WriteHelper(directory_ / "settings"); + const MapEnvironment environment( + {{"MOCKTAIL_SETTINGS_HELPER", helper.string()}}); + EXPECT_EQ(FindSettingsWindow(environment), helper); +} + +TEST_F(SettingsLauncherTest, ANamedProgramThatCannotRunIsNotReplaced) { + // A project root that would otherwise supply a program. + WriteHelper(directory_ / "bin" / "mocktail_settings"); + for (const std::string named : + {(directory_ / "missing").string(), std::string("relative/settings"), + directory_.string()}) { + const MapEnvironment environment( + {{"MOCKTAIL_SETTINGS_HELPER", named}, + {"MOCKTAIL_PROJECT_ROOT", directory_.string()}}); + EXPECT_TRUE(FindSettingsWindow(environment).empty()) << named; + } + + const std::filesystem::path target = WriteHelper(directory_ / "target"); + const std::filesystem::path link = directory_ / "link"; + std::filesystem::create_symlink(target, link); + const MapEnvironment linked({{"MOCKTAIL_SETTINGS_HELPER", link.string()}}); + EXPECT_TRUE(FindSettingsWindow(linked).empty()); +} + +TEST_F(SettingsLauncherTest, FindsTheProgramUnderTheProjectRoot) { + const std::filesystem::path helper = + WriteHelper(directory_ / "bin" / "mocktail_settings"); + const MapEnvironment environment( + {{"MOCKTAIL_PROJECT_ROOT", directory_.string()}}); + EXPECT_EQ(FindSettingsWindow(environment), helper); +} + +TEST_F(SettingsLauncherTest, ExecReplacesTheProcessWithTheProgram) { + const std::filesystem::path helper = WriteHelper(directory_ / "settings"); + const MapEnvironment environment( + {{"MOCKTAIL_SETTINGS_HELPER", helper.string()}}); + const pid_t child = fork(); + ASSERT_GE(child, 0); + if (child == 0) { + std::string error; + ExecSettingsWindow(environment, &error); + _exit(97); + } + int status = 0; + ASSERT_EQ(waitpid(child, &status, 0), child); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(WEXITSTATUS(status), 0); + EXPECT_EQ(Ran(), "ran \n"); +} + +TEST_F(SettingsLauncherTest, ExecReportsAProgramThatIsNotInstalled) { + const MapEnvironment environment( + {{"MOCKTAIL_SETTINGS_HELPER", (directory_ / "missing").string()}}); + std::string error; + EXPECT_FALSE(ExecSettingsWindow(environment, &error)); + EXPECT_NE(error.find("settings"), std::string::npos) << error; +} + +} // namespace +} // namespace runtime +} // namespace mocktail diff --git a/tests/system_install_test.sh b/tests/system_install_test.sh index 6b977e2a..3b4b6935 100644 --- a/tests/system_install_test.sh +++ b/tests/system_install_test.sh @@ -26,6 +26,7 @@ readonly icon="${install_root}/usr/share/icons/hicolor/scalable/apps/io.github.C [[ -x "${binary}" ]] [[ -x "${runtime}/mocktail_updater" ]] [[ -x "${runtime}/mocktail_failure_dialog" ]] +[[ -x "${runtime}/mocktail_settings" ]] [[ -x "${runtime}/mocktail_webview_helper" ]] [[ -x "${runtime}/mocktail_freebsd_socket_helper" ]] LC_ALL=C readelf -h "${runtime}/mocktail_freebsd_socket_helper" | @@ -34,6 +35,7 @@ readelf -h "${binary}" | grep -Fq 'ELF64' readelf -h "${runtime}/mocktail_updater" | grep -Fq 'ELF64' readelf -d "${binary}" | grep -Eq '\$ORIGIN/\.\./(lib|lib64)/mocktail' readelf -d "${runtime}/mocktail_failure_dialog" | grep -Fq 'libadwaita-1.so.0' +readelf -d "${runtime}/mocktail_settings" | grep -Fq 'libadwaita-1.so.0' ! readelf -d "${binary}" | grep -Fq 'libadwaita-1.so.0' [[ -f "${runtime}/libOpenSLES.so" ]] @@ -50,6 +52,7 @@ fi [[ -f "${desktop}" ]] grep -Fq 'Name=Nightcap' "${desktop}" grep -Fxq 'Exec=env SDL_VIDEODRIVER=wayland,x11 /usr/bin/mocktail %u' "${desktop}" +grep -Fxq 'Exec=/usr/bin/mocktail --settings' "${desktop}" grep -Fq 'Icon=io.github.CoderDayton.nightcap' "${desktop}" [[ -f "${metainfo}" ]] grep -Fq 'io.github.CoderDayton.nightcap' "${metainfo}" diff --git a/tests/url_handler_registration_test.sh b/tests/url_handler_registration_test.sh index 831f6de0..2cb20901 100755 --- a/tests/url_handler_registration_test.sh +++ b/tests/url_handler_registration_test.sh @@ -62,6 +62,8 @@ readonly installed_desktop="${XDG_DATA_HOME}/applications/io.github.CoderDayton. [[ -f "${installed_desktop}" && ! -L "${installed_desktop}" ]] grep -Fxq "Exec=env SDL_VIDEODRIVER=wayland,x11 ${mocktail_binary} %u" \ "${installed_desktop}" +grep -Fxq "Exec=${mocktail_binary} --settings" "${installed_desktop}" +[[ "$(grep -c '^Exec=' "${installed_desktop}")" == 2 ]] grep -Fxq \ 'MimeType=x-scheme-handler/roblox;x-scheme-handler/roblox-player;' \ "${installed_desktop}" @@ -90,6 +92,10 @@ grep -Fxq \ "Exec=env SDL_VIDEODRIVER=wayland,x11 ${fixture_project}/build/mocktail %u" \ "${installed_desktop}" grep -Fxq "Path=${fixture_project}" "${installed_desktop}" +grep -Fxq "Exec=${fixture_project}/build/mocktail --settings" \ + "${installed_desktop}" +# Path belongs to the main entry only, never to the action below it. +[[ "$(grep -c '^Path=' "${installed_desktop}")" == 1 ]] unsafe_binary="${test_root}/mocktail unsafe" cp -- "${mocktail_binary}" "${unsafe_binary}" From 569ce83dacf124ec92a495161a4bb21aec65c4f7 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 9 Oct 2026 18:42:18 -0500 Subject: [PATCH 09/30] fix(ui): keep unsaved text and name the file in save errors --- include/runtime/runtime_config_editor.h | 10 ++--- src/runtime/runtime_config_editor.cc | 13 ------- src/runtime/runtime_config_store.cc | 26 +++++++------ src/ui/mocktail_settings.cc | 49 ++++++++++++++++++------- src/ui/settings_model.cc | 30 ++++++++------- tests/runtime_config_editor_test.cc | 13 +++++++ 6 files changed, 84 insertions(+), 57 deletions(-) diff --git a/include/runtime/runtime_config_editor.h b/include/runtime/runtime_config_editor.h index 055b4b6b..2e66eeda 100644 --- a/include/runtime/runtime_config_editor.h +++ b/include/runtime/runtime_config_editor.h @@ -30,17 +30,15 @@ struct RuntimeConfigEdit { // `text` must be valid UTF-8 YAML whose top level is a mapping or empty. Every // result is parsed again and compared with `text`; an edit that would change // any other setting is refused with an error and no text. -RuntimeConfigEdit SetRuntimeConfigBool(std::string_view text, - std::string_view key, bool value); -RuntimeConfigEdit SetRuntimeConfigInteger(std::string_view text, - std::string_view key, - long long value); +// +// `value` is written as plain text when YAML allows it, so "true" and "144" +// are read back as a boolean and a number, and is quoted otherwise. RuntimeConfigEdit SetRuntimeConfigString(std::string_view text, std::string_view key, std::string_view value); // Returns `text` without `key`. The key's lines stay as comments, in the form -// the Set functions uncomment, and so does a section it leaves with no keys. +// SetRuntimeConfigString uncomments, and so does a section it leaves with no keys. // A key the file does not have leaves `text` unchanged. RuntimeConfigEdit UnsetRuntimeConfigValue(std::string_view text, std::string_view key); diff --git a/src/runtime/runtime_config_editor.cc b/src/runtime/runtime_config_editor.cc index e5b1eda8..fe21adba 100644 --- a/src/runtime/runtime_config_editor.cc +++ b/src/runtime/runtime_config_editor.cc @@ -1215,19 +1215,6 @@ RuntimeConfigEdit Change(std::string_view text, std::string_view key, } // namespace -RuntimeConfigEdit SetRuntimeConfigBool(std::string_view text, - std::string_view key, bool value) { - const std::string scalar = value ? "true" : "false"; - return Change(text, key, &scalar, scalar); -} - -RuntimeConfigEdit SetRuntimeConfigInteger(std::string_view text, - std::string_view key, - long long value) { - const std::string scalar = std::to_string(value); - return Change(text, key, &scalar, scalar); -} - RuntimeConfigEdit SetRuntimeConfigString(std::string_view text, std::string_view key, std::string_view value) { diff --git a/src/runtime/runtime_config_store.cc b/src/runtime/runtime_config_store.cc index bd4b2d01..e8cad5cf 100644 --- a/src/runtime/runtime_config_store.cc +++ b/src/runtime/runtime_config_store.cc @@ -54,6 +54,8 @@ bool WriteAll(int file, std::string_view contents) { // exist reads as empty text and sets it. RuntimeConfigText ReadText(const std::filesystem::path& path, bool* missing) { RuntimeConfigText result; + // Errors name the file, since more than one file is read this way. + const std::string name = path.filename().string() + ": "; if (missing != nullptr) { *missing = false; } @@ -63,14 +65,14 @@ RuntimeConfigText ReadText(const std::filesystem::path& path, bool* missing) { *missing = true; return result; } - result.error = errno == ELOOP - ? "refusing symlink runtime configuration" - : ErrorMessage("cannot open configuration", errno); + result.error = + name + (errno == ELOOP ? "refusing to read through a symlink" + : ErrorMessage("cannot open the file", errno)); return result; } struct stat metadata = {}; if (fstat(file, &metadata) != 0 || !S_ISREG(metadata.st_mode)) { - result.error = "runtime configuration must be a regular file"; + result.error = name + "the file must be a regular file"; (void)close(file); return result; } @@ -81,7 +83,7 @@ RuntimeConfigText ReadText(const std::filesystem::path& path, bool* missing) { continue; } if (count < 0) { - result.error = ErrorMessage("cannot read configuration", errno); + result.error = name + ErrorMessage("cannot read the file", errno); result.text.clear(); break; } @@ -101,9 +103,10 @@ RuntimeConfigSaveResult Save(const std::filesystem::path& config_file, bool may_be_missing, const TextFileCheck& check) { RuntimeConfigSaveResult result; if (config_file.empty() || config_file.filename().empty()) { - result.error = "configuration path must name a file"; + result.error = "the path must name a file"; return result; } + const std::string name = config_file.filename().string() + ": "; bool missing = false; const RuntimeConfigText current = ReadText(config_file, may_be_missing ? &missing : nullptr); @@ -112,7 +115,7 @@ RuntimeConfigSaveResult Save(const std::filesystem::path& config_file, return result; } if (current.text != original) { - result.error = "the configuration file changed on disk since it was read"; + result.error = name + "the file changed on disk since it was read"; return result; } @@ -128,7 +131,8 @@ RuntimeConfigSaveResult Save(const std::filesystem::path& config_file, temporary_path.push_back('\0'); const int temporary_file = mkstemp(temporary_path.data()); if (temporary_file < 0) { - result.error = ErrorMessage("cannot create temporary configuration", errno); + result.error = + name + ErrorMessage("cannot create a temporary file", errno); return result; } const auto discard_temporary = [&temporary_path]() { @@ -141,8 +145,8 @@ RuntimeConfigSaveResult Save(const std::filesystem::path& config_file, fsync(temporary_file) == 0; const int write_error = errno; if (close(temporary_file) != 0 || !written) { - result.error = ErrorMessage("cannot write temporary configuration", - written ? errno : write_error); + result.error = name + ErrorMessage("cannot write the temporary file", + written ? errno : write_error); discard_temporary(); return result; } @@ -155,7 +159,7 @@ RuntimeConfigSaveResult Save(const std::filesystem::path& config_file, } if (rename(temporary_path.data(), config_file.c_str()) != 0) { - result.error = ErrorMessage("cannot replace configuration", errno); + result.error = name + ErrorMessage("cannot replace the file", errno); discard_temporary(); return result; } diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 805ad22f..7f1ed6d3 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -39,6 +39,11 @@ struct Row { // A value the file holds that the row does not list. It is shown as one // more choice so opening the window never changes it. std::string unlisted; + // Whether the choices have been put in the drop-down yet. + bool choices_set = false; + // The text a kText row was last filled with. A field that holds anything + // else has an edit that is not saved yet. + std::string shown; }; struct SettingsWindow { @@ -75,7 +80,9 @@ void SetChoices(Row* row) { g_object_unref(labels); } -void RefreshRow(Row* row) { +// Shows the file's value in `row`. With `keep_edit`, text typed into the row +// and not saved yet is left alone. +void RefreshRow(Row* row, bool keep_edit) { const Setting& setting = *row->setting; const std::string value = row->window->document.Value(setting.key); switch (setting.kind) { @@ -85,8 +92,13 @@ void RefreshRow(Row* row) { break; case SettingKind::kChoice: { const std::size_t index = ChoiceIndex(setting, value); - row->unlisted = index == kNoChoice ? value : std::string(); - SetChoices(row); + std::string unlisted = index == kNoChoice ? value : std::string(); + // The list changes only when the unlisted value does. + if (!row->choices_set || unlisted != row->unlisted) { + row->unlisted = std::move(unlisted); + row->choices_set = true; + SetChoices(row); + } adw_combo_row_set_selected( ADW_COMBO_ROW(row->widget), static_cast(index == kNoChoice ? setting.choices.size() @@ -113,9 +125,14 @@ void RefreshRow(Row* row) { case SettingKind::kText: { const bool is_set = row->window->document.IsSet(setting.key); const std::string text = is_set ? value : Text(setting.default_value); - // Setting the same text again would mark the field as edited. - if (text != gtk_editable_get_text(GTK_EDITABLE(row->widget))) { - gtk_editable_set_text(GTK_EDITABLE(row->widget), text.c_str()); + const std::string current = + gtk_editable_get_text(GTK_EDITABLE(row->widget)); + if (!keep_edit || current == row->shown) { + // Setting the same text again would mark the field as edited. + if (text != current) { + gtk_editable_set_text(GTK_EDITABLE(row->widget), text.c_str()); + } + row->shown = text; } gtk_widget_set_sensitive(row->reset, is_set); break; @@ -125,10 +142,12 @@ void RefreshRow(Row* row) { } } -void RefreshRows(SettingsWindow* window) { +// Shows every row as the file has it. Text typed into a row and not saved +// yet stays, except in `saved_row`, whose text was just saved. +void RefreshRows(SettingsWindow* window, const Row* saved_row = nullptr) { window->refreshing = true; for (Row& row : window->rows) { - RefreshRow(&row); + RefreshRow(&row, &row != saved_row); } window->refreshing = false; } @@ -140,15 +159,17 @@ void ShowError(SettingsWindow* window, const std::string& error) { adw_toast_overlay_add_toast(window->toasts, toast); } -// Shows how a save went, then shows every row as the file now has it. -void ShowSaved(SettingsWindow* window, bool saved, const std::string& error) { +// Shows how saving `row` went, then shows every row as the file now has it. +void ShowSaved(Row* row, bool saved, const std::string& error) { + SettingsWindow* window = row->window; if (saved) { gtk_revealer_set_reveal_child(window->notice, TRUE); } else { ShowError(window, error); } - // On failure the rows go back to what the file holds. - RefreshRows(window); + // On failure the rows go back to what the file holds. A text that was + // refused stays in its field, so it can be corrected. + RefreshRows(window, saved ? row : nullptr); } void Apply(Row* row, const std::string& value) { @@ -158,7 +179,7 @@ void Apply(Row* row, const std::string& value) { } std::string error; const bool saved = window->document.Set(row->setting->key, value, &error); - ShowSaved(window, saved, error); + ShowSaved(row, saved, error); } void OnNumberChanged(GObject* object, GParamSpec*, gpointer user_data) { @@ -173,7 +194,7 @@ void OnTextApplied(AdwEntryRow* entry, gpointer user_data) { std::string error; const bool saved = window->document.SetText( row->setting->key, gtk_editable_get_text(GTK_EDITABLE(entry)), &error); - ShowSaved(window, saved, error); + ShowSaved(row, saved, error); } void OnTextReset(GtkButton*, gpointer user_data) { diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 084292c7..58e84ecf 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -2,10 +2,10 @@ #define JSON_NOEXCEPTION 1 #include -#include #include #include +#include "runtime/frame_rate_policy.h" #include "runtime/runtime_config_bootstrap.h" #include "runtime/runtime_config_editor.h" #include "runtime/runtime_config_store.h" @@ -315,20 +315,24 @@ bool ToggleState(const Setting& setting, std::string_view value) { } FrameRate ParseFrameRate(std::string_view value) { + // The loader's own reading of the key, so the window and Nightcap cannot + // disagree about what a value means. + const runtime::FrameRatePolicy policy = runtime::ParseFrameRatePolicy(value); FrameRate rate; - if (value == "display") { - rate.mode = FrameRateMode::kDisplay; - } else if (value == "unlimited") { - rate.mode = FrameRateMode::kUnlimited; - } else { - int custom = 0; - const auto parsed = - std::from_chars(value.data(), value.data() + value.size(), custom); - if (parsed.ec == std::errc() && - parsed.ptr == value.data() + value.size() && custom > 0) { + switch (policy.mode) { + case runtime::FrameRateLimitMode::kDisplay: + rate.mode = FrameRateMode::kDisplay; + break; + case runtime::FrameRateLimitMode::kUnlimited: + rate.mode = FrameRateMode::kUnlimited; + break; + case runtime::FrameRateLimitMode::kFixed: rate.mode = FrameRateMode::kCustom; - rate.custom = custom; - } + rate.custom = policy.fixed_fps; + break; + case runtime::FrameRateLimitMode::kUnmanaged: + case runtime::FrameRateLimitMode::kInvalid: + break; } return rate; } diff --git a/tests/runtime_config_editor_test.cc b/tests/runtime_config_editor_test.cc index 6b790052..c9e316be 100644 --- a/tests/runtime_config_editor_test.cc +++ b/tests/runtime_config_editor_test.cc @@ -27,6 +27,19 @@ class EmptyEnvironment final : public Environment { } }; +// Booleans and whole numbers are set as their plain text, the way the +// settings window sets them. +RuntimeConfigEdit SetRuntimeConfigBool(std::string_view text, + std::string_view key, bool value) { + return SetRuntimeConfigString(text, key, value ? "true" : "false"); +} + +RuntimeConfigEdit SetRuntimeConfigInteger(std::string_view text, + std::string_view key, + long long value) { + return SetRuntimeConfigString(text, key, std::to_string(value)); +} + RuntimeConfigLoadResult Load(std::string_view contents) { char pattern[] = "/tmp/mocktail_config_editor_XXXXXX"; const char* created = mkdtemp(pattern); From 710a352a8eb8d1ef74c3d43d5bc42b11ed79e427 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 9 Oct 2026 18:42:18 -0500 Subject: [PATCH 10/30] docs: describe the settings window --- CHANGELOG.md | 3 +++ FAQ.md | 10 ++++++++-- README.md | 18 +++++++++++++++++- docs/ROADMAP.md | 23 ++++++----------------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc3230c8..4578ba55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Release notes on GitHub are taken from the matching section here. ### Added +- Added a settings window. Open it from the app menu (right-click Nightcap, then Settings) or with + `mocktail --settings`. It changes `config.yaml` and `fflags.json` for you, keeps your comments, and does + not save a value Nightcap could not start with. Changes apply the next time Nightcap starts. - An asset overlay folder, `~/.local/share/mocktail/asset_overlay`. A file there replaces the file Roblox ships at the same path, such as a cursor, a font or a sky texture, and it stays in place when Roblox updates. The layout matches Sober's `asset_overlay` folder. `MOCKTAIL_ASSET_OVERLAY_DIR` moves it. Thanks to diff --git a/FAQ.md b/FAQ.md index a225c034..3d523fbb 100644 --- a/FAQ.md +++ b/FAQ.md @@ -4,8 +4,14 @@ ## How do I turn Discord RPC off? RPC is on by default. It never signs in to Discord and never reads an -account token. To turn it off, launch Nightcap once to create the config, -then close it. Open `config.yaml`: +account token. + +The quick way: open Settings (right-click Nightcap in the app menu, or run +`mocktail --settings`), go to Discord, and switch off "Show activity in +Discord". Then restart Nightcap. + +To do it by hand, launch Nightcap once to create the config, then close +it. Open `config.yaml`: - Native / AppImage: `~/.config/mocktail/config.yaml` - Flatpak: `~/.var/app/io.github.CoderDayton.nightcap/config/mocktail/config.yaml` diff --git a/README.md b/README.md index 7ce94928..8560a50f 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ removing the marker. Put a JSON object in `$XDG_CONFIG_HOME/mocktail/fflags.json` (usually `~/.config/mocktail/fflags.json`). Nightcap applies it on the next launch. +The Advanced page of the [settings window](#settings) edits this file and +does not save text that Nightcap could not load. PC profiles use the legacy Charts page by default to avoid the blank screen caused by `Color3` errors in the newer SDUI page. An explicit @@ -149,7 +151,21 @@ it yourself turns the switching off and your value applies everywhere. See ``` ## Settings -Edit `~/.config/mocktail/config.yaml`. Useful options: +Open the settings window from the app menu: right-click Nightcap and pick +Settings. Or run: + +```sh +mocktail --settings +``` + +With the Flatpak, run +`flatpak run io.github.CoderDayton.nightcap --settings`. + +The window edits `~/.config/mocktail/config.yaml`. It keeps your comments +and layout, saves each change at once, and does not save a value Nightcap +could not start with. Changes apply the next time Nightcap starts. + +You can also edit the file by hand. Useful options: - `graphics.frame_rate_limit: display` to unlock FPS to your monitor's rate. - `graphics.vsync: off` for the lowest lag, at the cost of tearing. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4fccbfa0..1b16738f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,26 +4,15 @@ What Nightcap is working toward, in priority order. Items move to [Shipped](#shipped) when they land on `main`. Open an issue to propose a change to this list. -## 1. Settings UI - -Nightcap is configured by editing `~/.config/mocktail/config.yaml`. Most -people never open it, so they never find the FPS unlock or the vsync switch. - -Goal: a small GTK4 settings window, reachable from the app menu and from a -`--settings` flag, that reads and writes the same `config.yaml`. - -- Graphics tab: backend, frame rate limit, vsync, small texture upscale. -- Input tab: the `input.raw_mouse` toggle. -- Advanced tab: FFlag overrides with a plain text editor for `fflags.json`. -- Discord tab: every field in the `discord_rpc` block, so a friend can see -what you are playing and you can switch it all off in one click. -- Changes apply on next launch. A restart button restarts Nightcap. - -Done when every option in `config/mocktail.example.yaml` that a player would -touch can be set without a text editor. +Nothing is queued right now. ## Shipped +- Settings window, from the app menu or `mocktail --settings`: graphics, +input, performance, audio, and Discord options, and a text editor for +`fflags.json`. It edits the same `config.yaml`, keeps your comments, and +does not save a value Nightcap could not start with. Changes apply on the +next launch. - Lower input latency: frames are presented as soon as they are ready. - Controllers hot-plug without a restart, and a pad that is unplugged mid-game releases its held buttons and sticks instead of leaving them stuck. From 774df919c761becb95511fc2f67135da53da28df Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 9 Oct 2026 21:33:16 -0500 Subject: [PATCH 11/30] feat(ui): add a touch input switch and harden the FFlag editor The switch writes touch into the device block, since the loader refuses input.touch_enabled beside a device key. The editor asks before dropping unsaved text, keeps undo from clearing the file's text, puts the cursor on a JSON fault, names the flag at fault, refuses a flag listed twice, and does not open a file that is not UTF-8 or is over the 64 KiB read limit. --- include/runtime/runtime_config_store.h | 10 +- include/services/client_settings_service.h | 4 + include/ui/settings_model.h | 36 ++- src/runtime/runtime_config_store.cc | 28 +- src/services/client_settings_service.cc | 1 - src/ui/mocktail_settings.cc | 114 ++++++++- src/ui/settings_model.cc | 282 ++++++++++++++++++--- tests/runtime_config_store_test.cc | 11 + tests/settings_model_test.cc | 159 ++++++++++++ 9 files changed, 584 insertions(+), 61 deletions(-) diff --git a/include/runtime/runtime_config_store.h b/include/runtime/runtime_config_store.h index 2d634a98..9f3cf118 100644 --- a/include/runtime/runtime_config_store.h +++ b/include/runtime/runtime_config_store.h @@ -1,6 +1,7 @@ #ifndef MOCKTAIL_RUNTIME_RUNTIME_CONFIG_STORE_H_ #define MOCKTAIL_RUNTIME_RUNTIME_CONFIG_STORE_H_ +#include #include #include #include @@ -39,9 +40,14 @@ RuntimeConfigSaveResult SaveRuntimeConfigText( const std::filesystem::path& config_file, std::string_view original, std::string_view text); +inline constexpr std::size_t kNoReadLimit = static_cast(-1); + // Reads a text file that may not exist. A missing file reads as empty text. -// A symlink or anything that is not a regular file is refused. -RuntimeConfigText ReadOptionalTextFile(const std::filesystem::path& file); +// A symlink or anything that is not a regular file is refused. At most one +// byte more than `limit` is read, so a text longer than `limit` means the +// file is larger than that. +RuntimeConfigText ReadOptionalTextFile(const std::filesystem::path& file, + std::size_t limit = kNoReadLimit); // Given the path of a file holding the text about to be saved, returns why it // must not be saved, or an empty string. diff --git a/include/services/client_settings_service.h b/include/services/client_settings_service.h index 902162e0..49d9ffd2 100644 --- a/include/services/client_settings_service.h +++ b/include/services/client_settings_service.h @@ -2,6 +2,7 @@ #define MOCKTAIL_SERVICES_CLIENT_SETTINGS_SERVICE_H_ #include +#include #include #include #include @@ -49,6 +50,9 @@ struct FflagsMergeResult { std::string error; }; +// The largest fflags file LoadAndMergeFflagsFile reads. +inline constexpr std::uintmax_t kMaximumFflagsFileBytes = 64U * 1024U; + // Loads a flat, optional fflags JSON file underneath caller-provided values. // count is the number of file entries added after caller values take // precedence. diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index ecc7c8cf..b1d1be45 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -3,11 +3,14 @@ #include #include +#include #include #include #include #include +#include "runtime/runtime_config_store.h" + namespace mocktail { namespace ui { @@ -101,13 +104,17 @@ class SettingsDocument { // Reads the file, creating the first-run file when there is none. bool Load(std::string* error); - // The file's value for `key`, or empty when it is not set. + // The file's value for `key`, or empty when it is not set. The touch + // switch, "device.touch", is never empty: unset, it is what the file's + // device uses. std::string Value(std::string_view key) const; // Whether the file sets `key`. A key can be set to an empty text. bool IsSet(std::string_view key) const; - // Sets `key` and saves. An empty `value` unsets the key. On failure nothing + // Sets `key` and saves. An empty `value` unsets the key. The touch switch + // is written into the device block; a device given as one name becomes the + // block form of the same device. On failure nothing // is saved, and the document holds whatever is on disk now, so the window // must show its rows again. bool Set(std::string_view key, std::string_view value, std::string* error); @@ -125,8 +132,19 @@ class SettingsDocument { std::string text_; }; +// A place in a text. Both numbers start at 1, and the column counts +// characters, not bytes. +struct TextPosition { + std::size_t line = 1; + std::size_t column = 1; +}; + +// Where `text` stops being JSON, or nothing when it is JSON. Comments are +// allowed, as they are in fflags.json. +std::optional JsonSyntaxFault(std::string_view text); + // Why `text` is not JSON, with the line and column of the fault. Empty when -// it is JSON. Comments are allowed, as they are in fflags.json. +// it is JSON. std::string JsonSyntaxError(std::string_view text); // fflags.json as the window edits it: the whole file as one text. @@ -134,7 +152,9 @@ class FFlagsDocument { public: explicit FFlagsDocument(std::filesystem::path file); - // Reads the file. Having no file is not an error. + // Reads the file. Having no file is not an error. A file that is not + // UTF-8 text, or is larger than Nightcap reads, is an error: the editor + // could not show it whole. bool Load(std::string* error); // What the editor starts with: the file's text, or an empty JSON object @@ -142,7 +162,9 @@ class FFlagsDocument { std::string Text() const; // Saves `text` as the file. Text that is not JSON, or that Nightcap would - // refuse to start with, is not saved. When the file changed on disk since + // refuse to start with, is not saved, and the error names the flag at + // fault when there is one. A flag listed twice is refused too, since only + // one of the two values would be used. When the file changed on disk since // it was read, nothing is saved, the error says so, and saving again // replaces the file. bool Save(std::string_view text, std::string* error); @@ -150,6 +172,10 @@ class FFlagsDocument { const std::filesystem::path& file() const { return file_; } private: + // The file's text, or an error. A file larger than Nightcap reads is an + // error, and is not read whole. + runtime::RuntimeConfigText Read() const; + std::filesystem::path file_; // The file's text as last read or saved. Empty when there is no file. std::string disk_; diff --git a/src/runtime/runtime_config_store.cc b/src/runtime/runtime_config_store.cc index e8cad5cf..9afecc4a 100644 --- a/src/runtime/runtime_config_store.cc +++ b/src/runtime/runtime_config_store.cc @@ -51,15 +51,19 @@ bool WriteAll(int file, std::string_view contents) { } // Reads `path` as text. When `missing` is not null, a file that does not -// exist reads as empty text and sets it. -RuntimeConfigText ReadText(const std::filesystem::path& path, bool* missing) { +// exist reads as empty text and sets it. At most one byte more than `limit` +// is read. +RuntimeConfigText ReadText(const std::filesystem::path& path, bool* missing, + std::size_t limit = kNoReadLimit) { RuntimeConfigText result; // Errors name the file, since more than one file is read this way. const std::string name = path.filename().string() + ": "; if (missing != nullptr) { *missing = false; } - const int file = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + // O_NONBLOCK keeps a named pipe at this path from blocking the open. + const int file = + open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); if (file < 0) { if (errno == ENOENT && missing != nullptr) { *missing = true; @@ -78,7 +82,13 @@ RuntimeConfigText ReadText(const std::filesystem::path& path, bool* missing) { } char buffer[4096]; while (true) { - const ssize_t count = read(file, buffer, sizeof(buffer)); + if (result.text.size() > limit) { + break; + } + // One byte past the limit shows that the file is larger. + const std::size_t room = limit - result.text.size(); + const ssize_t count = read( + file, buffer, room < sizeof(buffer) ? room + 1 : sizeof(buffer)); if (count < 0 && errno == EINTR) { continue; } @@ -108,8 +118,9 @@ RuntimeConfigSaveResult Save(const std::filesystem::path& config_file, } const std::string name = config_file.filename().string() + ": "; bool missing = false; - const RuntimeConfigText current = - ReadText(config_file, may_be_missing ? &missing : nullptr); + // A file longer than `original` has changed, so no more of it is read. + const RuntimeConfigText current = ReadText( + config_file, may_be_missing ? &missing : nullptr, original.size()); if (!current) { result.error = current.error; return result; @@ -190,9 +201,10 @@ RuntimeConfigSaveResult SaveRuntimeConfigText( }); } -RuntimeConfigText ReadOptionalTextFile(const std::filesystem::path& file) { +RuntimeConfigText ReadOptionalTextFile(const std::filesystem::path& file, + std::size_t limit) { bool missing = false; - return ReadText(file, &missing); + return ReadText(file, &missing, limit); } RuntimeConfigSaveResult SaveCheckedTextFile(const std::filesystem::path& file, diff --git a/src/services/client_settings_service.cc b/src/services/client_settings_service.cc index 9595ef91..505cfb8a 100644 --- a/src/services/client_settings_service.cc +++ b/src/services/client_settings_service.cc @@ -21,7 +21,6 @@ namespace services { namespace { constexpr const char kEmptyDefaults[] = "{\"applicationSettings\":{}}"; -constexpr std::uintmax_t kMaximumFflagsFileBytes = 64U * 1024U; class ScopedFileDescriptor final { public: diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 7f1ed6d3..6ab53345 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -234,31 +234,96 @@ void OnFrameRateChanged(GObject*, GParamSpec*, gpointer user_data) { struct FFlagsEditor { SettingsWindow* window = nullptr; AdwDialog* dialog = nullptr; + GtkTextView* view = nullptr; GtkTextBuffer* buffer = nullptr; AdwBanner* problem = nullptr; + GtkWidget* save = nullptr; }; +// Closing asks first while there is unsaved text. See OnFFlagsCloseAttempt. void OnFFlagsCancel(GtkButton*, gpointer user_data) { adw_dialog_close(static_cast(user_data)->dialog); } -void OnFFlagsSave(GtkButton*, gpointer user_data) { - auto* editor = static_cast(user_data); +void SaveFFlags(FFlagsEditor* editor) { + if (!gtk_text_buffer_get_modified(editor->buffer)) { + return; + } GtkTextIter start; GtkTextIter end; gtk_text_buffer_get_bounds(editor->buffer, &start, &end); - char* text = gtk_text_buffer_get_text(editor->buffer, &start, &end, FALSE); + char* characters = + gtk_text_buffer_get_text(editor->buffer, &start, &end, FALSE); + const std::string text = characters; + g_free(characters); std::string error; - const bool saved = editor->window->fflags.Save(text, &error); - g_free(text); - if (!saved) { - // The dialog stays open with the text, so nothing typed is lost. - adw_banner_set_title(editor->problem, error.c_str()); - adw_banner_set_revealed(editor->problem, TRUE); + if (editor->window->fflags.Save(text, &error)) { + gtk_revealer_set_reveal_child(editor->window->notice, TRUE); + adw_dialog_force_close(editor->dialog); return; } - gtk_revealer_set_reveal_child(editor->window->notice, TRUE); - adw_dialog_close(editor->dialog); + // The dialog stays open with the text, so nothing typed is lost. + adw_banner_set_title(editor->problem, error.c_str()); + adw_banner_set_revealed(editor->problem, TRUE); + if (const auto fault = mocktail::ui::JsonSyntaxFault(text)) { + GtkTextIter where; + gtk_text_buffer_get_iter_at_line_offset( + editor->buffer, &where, static_cast(fault->line - 1), + static_cast(fault->column - 1)); + gtk_text_buffer_place_cursor(editor->buffer, &where); + gtk_text_view_scroll_to_mark(editor->view, + gtk_text_buffer_get_insert(editor->buffer), + 0.1, FALSE, 0, 0); + } + gtk_widget_grab_focus(GTK_WIDGET(editor->view)); +} + +void OnFFlagsSave(GtkButton*, gpointer user_data) { + SaveFFlags(static_cast(user_data)); +} + +gboolean OnFFlagsSaveShortcut(GtkWidget*, GVariant*, gpointer user_data) { + SaveFFlags(static_cast(user_data)); + return TRUE; +} + +// Save has work to do, and closing needs a question, only while the text +// differs from what was opened. +void OnFFlagsModifiedChanged(GtkTextBuffer* buffer, gpointer user_data) { + auto* editor = static_cast(user_data); + const gboolean modified = gtk_text_buffer_get_modified(buffer); + gtk_widget_set_sensitive(editor->save, modified); + adw_dialog_set_can_close(editor->dialog, !modified); +} + +// A problem shown for the text as it was says nothing about the text now. +void OnFFlagsChanged(GtkTextBuffer*, gpointer user_data) { + adw_banner_set_revealed(static_cast(user_data)->problem, + FALSE); +} + +void OnFFlagsDiscardResponse(AdwAlertDialog*, const char* response, + gpointer user_data) { + if (g_strcmp0(response, "discard") == 0) { + adw_dialog_force_close(static_cast(user_data)->dialog); + } +} + +// Runs when the dialog is asked to close with unsaved text in it. +void OnFFlagsCloseAttempt(AdwDialog* dialog, gpointer user_data) { + AdwDialog* question = adw_alert_dialog_new( + "Discard changes?", "Your changes to fflags.json are not saved."); + adw_alert_dialog_add_responses(ADW_ALERT_DIALOG(question), "keep", + "Keep Editing", "discard", "Discard", + nullptr); + adw_alert_dialog_set_response_appearance(ADW_ALERT_DIALOG(question), + "discard", + ADW_RESPONSE_DESTRUCTIVE); + adw_alert_dialog_set_default_response(ADW_ALERT_DIALOG(question), "keep"); + adw_alert_dialog_set_close_response(ADW_ALERT_DIALOG(question), "keep"); + g_signal_connect(question, "response", G_CALLBACK(OnFFlagsDiscardResponse), + user_data); + adw_dialog_present(question, GTK_WIDGET(dialog)); } void OnFFlagsActivated(AdwActionRow*, gpointer user_data) { @@ -281,9 +346,21 @@ void OnFFlagsActivated(AdwActionRow*, gpointer user_data) { GtkWidget* cancel = gtk_button_new_with_label("Cancel"); GtkWidget* save = gtk_button_new_with_label("Save"); + editor->save = save; gtk_widget_add_css_class(save, "suggested-action"); + gtk_widget_set_tooltip_text(save, "Save (Ctrl+S)"); + gtk_widget_set_sensitive(save, FALSE); g_signal_connect(cancel, "clicked", G_CALLBACK(OnFFlagsCancel), editor); g_signal_connect(save, "clicked", G_CALLBACK(OnFFlagsSave), editor); + g_signal_connect(editor->dialog, "close-attempt", + G_CALLBACK(OnFFlagsCloseAttempt), editor); + GtkEventController* shortcuts = gtk_shortcut_controller_new(); + gtk_shortcut_controller_add_shortcut( + GTK_SHORTCUT_CONTROLLER(shortcuts), + gtk_shortcut_new( + gtk_keyval_trigger_new(GDK_KEY_s, GDK_CONTROL_MASK), + gtk_callback_action_new(OnFFlagsSaveShortcut, editor, nullptr))); + gtk_widget_add_controller(GTK_WIDGET(editor->dialog), shortcuts); GtkWidget* header = adw_header_bar_new(); adw_header_bar_set_show_start_title_buttons(ADW_HEADER_BAR(header), FALSE); adw_header_bar_set_show_end_title_buttons(ADW_HEADER_BAR(header), FALSE); @@ -300,8 +377,23 @@ void OnFFlagsActivated(AdwActionRow*, gpointer user_data) { gtk_text_view_set_bottom_margin(GTK_TEXT_VIEW(view), 12); gtk_text_view_set_left_margin(GTK_TEXT_VIEW(view), 12); gtk_text_view_set_right_margin(GTK_TEXT_VIEW(view), 12); + gtk_accessible_update_property(GTK_ACCESSIBLE(view), + GTK_ACCESSIBLE_PROPERTY_LABEL, "fflags.json", + -1); + editor->view = GTK_TEXT_VIEW(view); editor->buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(view)); + // Undo must not go back past the file's own text. + gtk_text_buffer_begin_irreversible_action(editor->buffer); gtk_text_buffer_set_text(editor->buffer, window->fflags.Text().c_str(), -1); + gtk_text_buffer_end_irreversible_action(editor->buffer); + gtk_text_buffer_set_modified(editor->buffer, FALSE); + GtkTextIter top; + gtk_text_buffer_get_start_iter(editor->buffer, &top); + gtk_text_buffer_place_cursor(editor->buffer, &top); + g_signal_connect(editor->buffer, "modified-changed", + G_CALLBACK(OnFFlagsModifiedChanged), editor); + g_signal_connect(editor->buffer, "changed", G_CALLBACK(OnFFlagsChanged), + editor); GtkWidget* scrolled = gtk_scrolled_window_new(); gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), view); diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 58e84ecf..8ba1f041 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -4,7 +4,11 @@ #include #include #include +#include +#include +#include +#include "runtime/device_profile.h" #include "runtime/frame_rate_policy.h" #include "runtime/runtime_config_bootstrap.h" #include "runtime/runtime_config_editor.h" @@ -15,7 +19,56 @@ namespace mocktail { namespace ui { namespace { -// Accepts every value and records where the parser gave up. +// The touch switch. The file holds it inside the device block, which the +// loader does not let input.touch_enabled be combined with. +constexpr std::string_view kTouchKey = "device.touch"; +// Where a file with no device key holds it. +constexpr std::string_view kTouchKeyWithoutDevice = "input.touch_enabled"; + +// Whether Nightcap turns touch input on for the config `text`. +bool TouchInput(std::string_view text) { + for (const std::string_view key : {kTouchKey, kTouchKeyWithoutDevice}) { + const runtime::RuntimeConfigValue value = + runtime::GetRuntimeConfigValue(text, key); + if (value.found) { + return value.value == "true"; + } + } + // Unset, the device decides. + runtime::RuntimeConfigValue device = + runtime::GetRuntimeConfigValue(text, "device"); + if (!device.found) { + device = runtime::GetRuntimeConfigValue(text, "device.type"); + } + const runtime::DeviceProfile* profile = runtime::FindDeviceProfile( + device.found ? std::string_view(device.value) + : runtime::kDefaultDeviceProfileName); + return profile != nullptr && profile->touch_enabled; +} + +// `text` with touch input set to `value`, which is "true" or "false". +runtime::RuntimeConfigEdit SetTouchInput(std::string_view text, + std::string_view value) { + const runtime::RuntimeConfigValue preset = + runtime::GetRuntimeConfigValue(text, "device"); + if (preset.found) { + // A device given as one name has no place for the switch. It becomes + // the block form of the same device. + runtime::RuntimeConfigEdit block = + runtime::SetRuntimeConfigString(text, "device.type", preset.value); + if (!block) { + return block; + } + return runtime::SetRuntimeConfigString(block.text, kTouchKey, value); + } + if (runtime::GetRuntimeConfigValue(text, "device.type").found) { + return runtime::SetRuntimeConfigString(text, kTouchKey, value); + } + return runtime::SetRuntimeConfigString(text, kTouchKeyWithoutDevice, value); +} + +// Accepts every value, and records where the parser gave up and the first +// name the outermost object lists twice. class JsonSyntaxProbe final : public nlohmann::json_sax { public: bool null() override { return true; } @@ -25,11 +78,16 @@ class JsonSyntaxProbe final : public nlohmann::json_sax { bool number_float(number_float_t, const string_t&) override { return true; } bool string(string_t&) override { return true; } bool binary(binary_t&) override { return true; } - bool start_object(std::size_t) override { return true; } - bool key(string_t&) override { return true; } - bool end_object() override { return true; } - bool start_array(std::size_t) override { return true; } - bool end_array() override { return true; } + bool start_object(std::size_t) override { return Enter(); } + bool key(string_t& name) override { + if (depth_ == 1 && !names_.insert(name).second && !repeated.has_value()) { + repeated = name; + } + return true; + } + bool end_object() override { return Leave(); } + bool start_array(std::size_t) override { return Enter(); } + bool end_array() override { return Leave(); } bool parse_error(std::size_t position, const std::string&, const nlohmann::detail::exception&) override { failed = true; @@ -40,8 +98,143 @@ class JsonSyntaxProbe final : public nlohmann::json_sax { bool failed = false; // How many bytes the parser had read when it gave up. std::size_t fault = 0; + std::optional repeated; + + private: + bool Enter() { + ++depth_; + return true; + } + bool Leave() { + --depth_; + return true; + } + + // How many objects and arrays the parser is inside. + std::size_t depth_ = 0; + std::set names_; }; +JsonSyntaxProbe ProbeJson(std::string_view text) { + JsonSyntaxProbe probe; + nlohmann::json::sax_parse(text, &probe, nlohmann::json::input_format_t::json, + true, true); + return probe; +} + +TextPosition FaultPosition(const JsonSyntaxProbe& probe, + std::string_view text) { + // The fault is the last byte read. + const std::size_t end = + probe.fault == 0 ? 0 : std::min(probe.fault - 1, text.size()); + TextPosition position; + for (std::size_t index = 0; index < end; ++index) { + const char character = text[index]; + // A carriage return ends a line by itself, as it does in the editor, + // unless a line feed follows and ends it. + const bool lone_return = + character == '\r' && + (index + 1 == text.size() || text[index + 1] != '\n'); + if (character == '\n' || lone_return) { + ++position.line; + position.column = 1; + } else if ((static_cast(character) & 0xC0) != 0x80) { + // Only the first byte of a character counts. + ++position.column; + } + } + return position; +} + +std::string FaultMessage(const TextPosition& position) { + return "Not valid JSON at line " + std::to_string(position.line) + + ", column " + std::to_string(position.column); +} + +std::string Quoted(std::string_view name) { + return "\"" + std::string(name) + "\""; +} + +// Why Nightcap would not use `text` as fflags.json, or empty. The file's +// loader has the last word on a save; this names the flag at fault. +std::string FFlagsContentError(std::string_view text) { + const JsonSyntaxProbe probe = ProbeJson(text); + if (probe.failed) { + return FaultMessage(FaultPosition(probe, text)); + } + const nlohmann::json values = + nlohmann::json::parse(text, nullptr, false, true); + if (!values.is_object()) { + return "fflags.json must be one JSON object, like " + "{ \"FFlagName\": \"True\" }"; + } + if (probe.repeated.has_value()) { + return Quoted(*probe.repeated) + " is listed twice"; + } + for (const auto& [name, value] : values.items()) { + if (name.empty()) { + return "A flag has an empty name"; + } + if (!value.is_string() && !value.is_boolean() && + !value.is_number_integer()) { + return Quoted(name) + + ": the value must be text, true or false, or a whole number"; + } + } + return {}; +} + +// Whether `text` is UTF-8 with no NUL byte, which a text view needs. +bool IsUtf8Text(std::string_view text) { + std::size_t index = 0; + while (index < text.size()) { + const unsigned char lead = static_cast(text[index]); + if (lead == 0) { + return false; + } + if (lead < 0x80) { + ++index; + continue; + } + std::size_t length = 0; + char32_t point = 0; + // The smallest value this length may encode. + char32_t least = 0; + if ((lead & 0xE0) == 0xC0) { + length = 2; + point = lead & 0x1F; + least = 0x80; + } else if ((lead & 0xF0) == 0xE0) { + length = 3; + point = lead & 0x0F; + least = 0x800; + } else if ((lead & 0xF8) == 0xF0) { + length = 4; + point = lead & 0x07; + least = 0x10000; + } else { + return false; + } + if (text.size() - index < length) { + return false; + } + for (std::size_t offset = 1; offset < length; ++offset) { + const unsigned char next = + static_cast(text[index + offset]); + if ((next & 0xC0) != 0x80) { + return false; + } + point = (point << 6) | (next & 0x3F); + } + if (point < least || point > 0x10FFFF || + (point >= 0xD800 && point <= 0xDFFF)) { + return false; + } + index += length; + } + return true; +} + } // namespace const std::vector& SettingsPages() { @@ -134,6 +327,16 @@ const std::vector& SettingsPages() { "false", {}}, }}, + {"Touch", + { + {kTouchKey, + "Touch input", + "Turn on for a touch screen or tablet.", + SettingKind::kToggle, + {}, + "false", + {}}, + }}, }}, {"performance", "Performance", @@ -380,6 +583,9 @@ bool SettingsDocument::Load(std::string* error) { } std::string SettingsDocument::Value(std::string_view key) const { + if (key == kTouchKey) { + return TouchInput(text_) ? "true" : "false"; + } return runtime::GetRuntimeConfigValue(text_, key).value; } @@ -390,8 +596,9 @@ bool SettingsDocument::IsSet(std::string_view key) const { bool SettingsDocument::Set(std::string_view key, std::string_view value, std::string* error) { runtime::RuntimeConfigEdit edit = - value.empty() ? runtime::UnsetRuntimeConfigValue(text_, key) - : runtime::SetRuntimeConfigString(text_, key, value); + key == kTouchKey ? SetTouchInput(text_, value) + : value.empty() ? runtime::UnsetRuntimeConfigValue(text_, key) + : runtime::SetRuntimeConfigString(text_, key, value); return Save(std::move(edit.text), std::move(edit.error), error); } @@ -427,39 +634,44 @@ bool SettingsDocument::Save(std::string edited, std::string edit_error, return true; } -std::string JsonSyntaxError(std::string_view text) { - JsonSyntaxProbe probe; - nlohmann::json::sax_parse(text, &probe, nlohmann::json::input_format_t::json, - true, true); +std::optional JsonSyntaxFault(std::string_view text) { + const JsonSyntaxProbe probe = ProbeJson(text); if (!probe.failed) { - return {}; - } - // The fault is the last byte read. - const std::size_t end = - probe.fault == 0 ? 0 : std::min(probe.fault - 1, text.size()); - std::size_t line = 1; - std::size_t column = 1; - for (const char character : text.substr(0, end)) { - if (character == '\n') { - ++line; - column = 1; - } else { - ++column; - } + return std::nullopt; } - return "Not valid JSON at line " + std::to_string(line) + ", column " + - std::to_string(column); + return FaultPosition(probe, text); +} + +std::string JsonSyntaxError(std::string_view text) { + const std::optional fault = JsonSyntaxFault(text); + return fault.has_value() ? FaultMessage(*fault) : std::string(); } FFlagsDocument::FFlagsDocument(std::filesystem::path file) : file_(std::move(file)) {} +runtime::RuntimeConfigText FFlagsDocument::Read() const { + // No more of the file is read than Nightcap itself reads. + runtime::RuntimeConfigText read = runtime::ReadOptionalTextFile( + file_, static_cast(services::kMaximumFflagsFileBytes)); + if (read && read.text.size() > services::kMaximumFflagsFileBytes) { + read.error = "fflags.json is larger than 64 KiB, the most Nightcap " + "reads. Shorten or remove " + + file_.string(); + } + return read; +} + bool FFlagsDocument::Load(std::string* error) { - runtime::RuntimeConfigText read = runtime::ReadOptionalTextFile(file_); + runtime::RuntimeConfigText read = Read(); if (!read) { *error = read.error; return false; } + if (!IsUtf8Text(read.text)) { + *error = "fflags.json is not UTF-8 text. Fix or remove " + file_.string(); + return false; + } disk_ = std::move(read.text); return true; } @@ -472,9 +684,9 @@ std::string FFlagsDocument::Text() const { } bool FFlagsDocument::Save(std::string_view text, std::string* error) { - std::string syntax = JsonSyntaxError(text); - if (!syntax.empty()) { - *error = std::move(syntax); + std::string content_error = FFlagsContentError(text); + if (!content_error.empty()) { + *error = std::move(content_error); return false; } // Nightcap refuses to start on a file its loader rejects, so the loader @@ -485,8 +697,10 @@ bool FFlagsDocument::Save(std::string_view text, std::string* error) { }); if (!saved) { *error = saved.error; - runtime::RuntimeConfigText read = runtime::ReadOptionalTextFile(file_); - if (read && read.text != disk_) { + runtime::RuntimeConfigText read = Read(); + if (!read) { + *error = read.error; + } else if (read.text != disk_) { disk_ = std::move(read.text); *error = "fflags.json changed on disk since it was opened. Save again to " diff --git a/tests/runtime_config_store_test.cc b/tests/runtime_config_store_test.cc index 088e2cca..5bb6337b 100644 --- a/tests/runtime_config_store_test.cc +++ b/tests/runtime_config_store_test.cc @@ -192,6 +192,17 @@ TEST_F(RuntimeConfigStoreTest, AnOptionalFileThatIsMissingReadsAsEmpty) { EXPECT_EQ(ReadOptionalTextFile(extra).text, "{}\n"); } +TEST_F(RuntimeConfigStoreTest, AnOptionalFileIsReadOnlyJustPastItsLimit) { + const std::filesystem::path extra = directory_ / "extra.json"; + Write(extra, std::string(10000, 'x')); + EXPECT_EQ(ReadOptionalTextFile(extra, 10000).text.size(), 10000u); + EXPECT_EQ(ReadOptionalTextFile(extra, 9999).text.size(), 10000u); + // One byte past the limit is enough to tell the file is larger. + const RuntimeConfigText read = ReadOptionalTextFile(extra, 4); + ASSERT_TRUE(read) << read.error; + EXPECT_EQ(read.text, "xxxxx"); +} + TEST_F(RuntimeConfigStoreTest, ACheckedSaveCreatesAMissingFile) { const std::filesystem::path extra = directory_ / "extra.json"; std::string checked; diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index b07fe92e..6f4c9fc4 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -194,6 +194,77 @@ TEST_F(SettingsModelTest, SavedValuesReachTheLoadedConfig) { EXPECT_TRUE(loaded.config.input_capabilities().raw_mouse); } +TEST_F(SettingsModelTest, TouchInputIsASwitchThatReachesTheLoadedConfig) { + const Setting& touch = Find("device.touch"); + EXPECT_EQ(touch.kind, SettingKind::kToggle); + + struct Case { + // Empty stands for the first-run file. + std::string_view file; + std::string_view device; + bool starts_on; + }; + for (const Case& test : { + Case{"", "pc-windows-11", false}, + Case{"device:\n type: mobile\n model: Tablet\n mouse: true\n", + "mobile-pixel-7", true}, + Case{"device:\n type: mobile\n model: Tablet\n mouse: true\n" + " touch: false\n", + "mobile-pixel-7", false}, + Case{"device:\n type: pc\n model: Tablet\n", "pc-windows-11", + false}, + // A file from before the device key. + Case{"input:\n raw_mouse: true\n", "pc-windows-11", false}, + Case{"input:\n touch_enabled: true\n", "pc-windows-11", true}, + }) { + SCOPED_TRACE(std::string(test.file)); + std::filesystem::remove(file_); + if (!test.file.empty()) { + Write(test.file); + } + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + + // The switch shows what Nightcap uses. + runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_EQ(loaded.config.input_capabilities().touch_enabled, + test.starts_on); + EXPECT_EQ(ToggleState(touch, document.Value(touch.key)), test.starts_on); + + for (const bool on : {!test.starts_on, test.starts_on}) { + ASSERT_TRUE(document.Set(touch.key, on ? "true" : "false", &error)) + << error << "\n" << Read(); + EXPECT_EQ(ToggleState(touch, document.Value(touch.key)), on) << Read(); + loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error << "\n" << Read(); + EXPECT_EQ(loaded.config.input_capabilities().touch_enabled, on) + << Read(); + // Nothing else about the device changes. + EXPECT_EQ(loaded.config.device_profile().name, test.device) << Read(); + EXPECT_TRUE(loaded.config.input_capabilities().mouse_enabled || + test.device != "pc-windows-11") + << Read(); + } + if (test.file.find("Tablet") != std::string_view::npos) { + EXPECT_EQ(loaded.config.device_profile().model, "Tablet") << Read(); + } + } +} + +TEST_F(SettingsModelTest, TouchInputStaysOnForADeviceWithNoOtherInput) { + Write("device: mobile\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_EQ(document.Value("device.touch"), "true"); + EXPECT_FALSE(document.Set("device.touch", "false", &error)); + EXPECT_FALSE(error.empty()); + EXPECT_EQ(Read(), "device: mobile\n"); + EXPECT_EQ(document.Value("device.touch"), "true"); +} + TEST_F(SettingsModelTest, LoadCreatesTheFirstRunFile) { SettingsDocument document(file_); std::string error; @@ -333,6 +404,94 @@ TEST_F(SettingsModelTest, BrokenFFlagsAreRefusedWithTheLineAndColumn) { EXPECT_EQ(ReadFFlags(), "{}\n"); } +TEST_F(SettingsModelTest, AJsonFaultIsCountedInCharacters) { + EXPECT_FALSE(JsonSyntaxFault("{\"a\": 1}").has_value()); + EXPECT_FALSE(JsonSyntaxFault("// note\n{}\n").has_value()); + + // The two-byte letter is one column. + const std::optional fault = + JsonSyntaxFault("{\n \"\xc3\xa9\": }\n"); + ASSERT_TRUE(fault.has_value()); + EXPECT_EQ(fault->line, 2u); + EXPECT_EQ(fault->column, 8u); + EXPECT_EQ(JsonSyntaxError("{\n \"\xc3\xa9\": }\n"), + "Not valid JSON at line 2, column 8"); + + // A line ends the way the editor ends it: at a lone carriage return too. + for (const std::string_view text : + {"{\r \"a\": }\r", "{\r\n \"a\": }\r\n"}) { + const std::optional returned = JsonSyntaxFault(text); + ASSERT_TRUE(returned.has_value()); + EXPECT_EQ(returned->line, 2u); + EXPECT_EQ(returned->column, 8u); + } + + const std::optional empty = JsonSyntaxFault(""); + ASSERT_TRUE(empty.has_value()); + EXPECT_EQ(empty->line, 1u); + EXPECT_EQ(empty->column, 1u); +} + +TEST_F(SettingsModelTest, AnFFlagsRefusalSaysWhatIsWrong) { + FFlagsDocument document(FFlagsFile()); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + const std::pair cases[] = { + {"[1, 2]\n", "one JSON object"}, + {"\"text\"\n", "one JSON object"}, + {"{\"DFIntA\": 1.5}\n", "\"DFIntA\""}, + {"{\"ok\": 1, \"FFlagB\": null}\n", "\"FFlagB\""}, + {"{\"FFlagC\": [1]}\n", "\"FFlagC\""}, + {"{\"\": 1}\n", "empty name"}, + {"{\"FFlagD\": 1, \"FFlagD\": 2}\n", "\"FFlagD\" is listed twice"}, + }; + for (const auto& [text, expected] : cases) { + error.clear(); + EXPECT_FALSE(document.Save(text, &error)) << text; + EXPECT_NE(error.find(expected), std::string::npos) + << text << " gave: " << error; + EXPECT_FALSE(std::filesystem::exists(FFlagsFile())) << text; + } + + // A name used again inside a value is not a repeat. + ASSERT_TRUE(document.Save("{\"a\": \"a\", \"b\": \"a\"}\n", &error)) + << error; +} + +TEST_F(SettingsModelTest, AnFFlagsFileThatIsNotTextIsNotOpened) { + for (const std::string& bytes : + {std::string("{\"a\": \"\xff\xfe\"}\n"), + std::string("{}\n\0{\"a\": 1}\n", 13)}) { + std::ofstream(FFlagsFile(), std::ios::binary) << bytes; + FFlagsDocument document(FFlagsFile()); + std::string error; + EXPECT_FALSE(document.Load(&error)); + EXPECT_NE(error.find("UTF-8"), std::string::npos) << error; + EXPECT_EQ(ReadFFlags(), bytes); + } +} + +TEST_F(SettingsModelTest, AnFFlagsFileOverTheReadLimitIsNotOpened) { + const std::string bytes = "{}" + std::string(64 * 1024, ' '); + std::ofstream(FFlagsFile(), std::ios::binary) << bytes; + FFlagsDocument document(FFlagsFile()); + std::string error; + EXPECT_FALSE(document.Load(&error)); + EXPECT_NE(error.find("64 KiB"), std::string::npos) << error; + EXPECT_EQ(ReadFFlags(), bytes); +} + +TEST_F(SettingsModelTest, AnFFlagsSymlinkIsRefusedAsASymlinkWhateverItsSize) { + const std::filesystem::path target = directory_ / "large.json"; + std::ofstream(target, std::ios::binary) + << "{}" + std::string(64 * 1024, ' '); + std::filesystem::create_symlink(target, FFlagsFile()); + FFlagsDocument document(FFlagsFile()); + std::string error; + EXPECT_FALSE(document.Load(&error)); + EXPECT_NE(error.find("symlink"), std::string::npos) << error; +} + TEST_F(SettingsModelTest, FFlagsNightcapWouldRejectAreRefused) { FFlagsDocument document(FFlagsFile()); std::string error; From 5ed4cedce5d5805bcf7aec447e33cef3d51fe928 Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 12 Oct 2026 02:33:11 -0500 Subject: [PATCH 12/30] feat(ui): pick the audio devices from a list The names come from SDL, which is what Nightcap picks a device by. --- CMakeLists.txt | 1 + include/ui/settings_model.h | 15 ++++++++ src/ui/mocktail_settings.cc | 70 ++++++++++++++++++++++++++++++++++++ src/ui/settings_model.cc | 47 +++++++++++++++++++++--- tests/settings_model_test.cc | 66 ++++++++++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index eec760f2..0ca88e1b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -391,6 +391,7 @@ target_include_directories(mocktail_settings PRIVATE target_link_libraries(mocktail_settings PRIVATE Mocktail::SettingsModel PkgConfig::LIBADWAITA + SDL3::SDL3 ) mocktail_apply_compile_options(mocktail_settings) diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index b1d1be45..c37bf358 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -23,6 +23,8 @@ enum class SettingKind { kFrameRate, kNumber, kText, + // A drop-down of the setting's choices and the audio devices found. + kDevice, // A row that opens the editor for fflags.json. kEditor, }; @@ -68,6 +70,19 @@ inline constexpr std::size_t kNoChoice = static_cast(-1); // stands for. kNoChoice when the file holds a value the window does not list. std::size_t ChoiceIndex(const Setting& setting, std::string_view value); +struct DeviceChoice { + std::string value; + std::string label; +}; + +// What a kDevice row offers: the setting's choices, then each name in +// `devices`, then `value`, the file's value, when nothing listed stands for +// it. A name Nightcap could not pick the device by is left out: one that two +// devices share, and one that is also a choice's value. +std::vector DeviceChoices(const Setting& setting, + const std::vector& devices, + std::string_view value); + // Whether a toggle is on for `value`, the file's value or empty when unset. bool ToggleState(const Setting& setting, std::string_view value); diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 6ab53345..803f8cb9 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -1,9 +1,11 @@ +#include #include #include #include #include #include +#include #include "mocktail/platform/sdl_window_icon_data.h" #include "runtime/environment.h" @@ -13,6 +15,7 @@ namespace { using mocktail::ui::ChoiceIndex; +using mocktail::ui::DeviceChoice; using mocktail::ui::FFlagsDocument; using mocktail::ui::FrameRate; using mocktail::ui::FrameRateMode; @@ -41,6 +44,7 @@ struct Row { std::string unlisted; // Whether the choices have been put in the drop-down yet. bool choices_set = false; + std::vector devices; // The text a kText row was last filled with. A field that holds anything // else has an edit that is not saved yet. std::string shown; @@ -59,6 +63,8 @@ struct SettingsWindow { AdwNavigationPage* content_page = nullptr; GtkStack* stack = nullptr; AdwToastOverlay* toasts = nullptr; + std::vector output_devices; + std::vector input_devices; // The card under the settings that says when changes take effect. GtkRevealer* notice = nullptr; // Set while rows are filled from the file, so that filling one is not @@ -80,6 +86,49 @@ void SetChoices(Row* row) { g_object_unref(labels); } +// The names SDL gives the audio devices, which are the names Nightcap picks +// a device by. Empty when SDL finds no audio system. +std::vector AudioDeviceNames(bool recording) { + std::vector names; + if (!SDL_InitSubSystem(SDL_INIT_AUDIO)) { + return names; + } + int count = 0; + SDL_AudioDeviceID* ids = recording ? SDL_GetAudioRecordingDevices(&count) + : SDL_GetAudioPlaybackDevices(&count); + for (int index = 0; ids != nullptr && index < count; ++index) { + if (const char* name = SDL_GetAudioDeviceName(ids[index])) { + names.emplace_back(name); + } + } + SDL_free(ids); + SDL_QuitSubSystem(SDL_INIT_AUDIO); + return names; +} + +void RefreshDeviceRow(Row* row, const std::string& value) { + const SettingsWindow& window = *row->window; + std::vector devices = mocktail::ui::DeviceChoices( + *row->setting, + row->setting->key == "audio.input_device" ? window.input_devices + : window.output_devices, + value); + const std::string chosen = + value.empty() ? Text(row->setting->default_value) : value; + guint selected = 0; + GtkStringList* labels = gtk_string_list_new(nullptr); + for (std::size_t index = 0; index < devices.size(); ++index) { + gtk_string_list_append(labels, devices[index].label.c_str()); + if (devices[index].value == chosen) { + selected = static_cast(index); + } + } + row->devices = std::move(devices); + adw_combo_row_set_model(ADW_COMBO_ROW(row->widget), G_LIST_MODEL(labels)); + g_object_unref(labels); + adw_combo_row_set_selected(ADW_COMBO_ROW(row->widget), selected); +} + // Shows the file's value in `row`. With `keep_edit`, text typed into the row // and not saved yet is left alone. void RefreshRow(Row* row, bool keep_edit) { @@ -137,6 +186,9 @@ void RefreshRow(Row* row, bool keep_edit) { gtk_widget_set_sensitive(row->reset, is_set); break; } + case SettingKind::kDevice: + RefreshDeviceRow(row, value); + break; case SettingKind::kEditor: break; } @@ -217,6 +269,17 @@ void OnChoiceChanged(GObject* object, GParamSpec*, gpointer user_data) { Apply(row, Text(row->setting->choices[selected].value)); } +void OnDeviceChanged(GObject* object, GParamSpec*, gpointer user_data) { + Row* row = static_cast(user_data); + const guint selected = adw_combo_row_get_selected(ADW_COMBO_ROW(object)); + if (selected >= row->devices.size()) { + return; + } + // Apply fills the rows again, which replaces row->devices. + const std::string value = row->devices[selected].value; + Apply(row, value); +} + void OnFrameRateChanged(GObject*, GParamSpec*, gpointer user_data) { Row* row = static_cast(user_data); const guint selected = adw_combo_row_get_selected(ADW_COMBO_ROW(row->widget)); @@ -434,6 +497,11 @@ void AddRow(SettingsWindow* window, AdwPreferencesGroup* group, g_signal_connect(row.widget, "notify::selected", G_CALLBACK(OnChoiceChanged), &row); break; + case SettingKind::kDevice: + row.widget = adw_combo_row_new(); + g_signal_connect(row.widget, "notify::selected", + G_CALLBACK(OnDeviceChanged), &row); + break; case SettingKind::kFrameRate: row.widget = adw_combo_row_new(); SetChoices(&row); @@ -700,6 +768,8 @@ void OnActivate(GtkApplication* application, gpointer user_data) { GtkWidget* content = nullptr; std::string error; if (window->document.Load(&error)) { + window->output_devices = AudioDeviceNames(false); + window->input_devices = AudioDeviceNames(true); content = BuildSplitView(window, ADW_APPLICATION_WINDOW(window_widget)); RefreshRows(window); } else { diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 8ba1f041..77d214ae 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -394,10 +394,20 @@ const std::vector& SettingsPages() { { {"Devices", { - {"audio.output_device", "Output device", "", - SettingKind::kText, {}, "default", {}}, - {"audio.input_device", "Input device", "", - SettingKind::kText, {}, "default", {}}, + {"audio.output_device", + "Output device", + "", + SettingKind::kDevice, + {{"default", "System default"}}, + "default", + {}}, + {"audio.input_device", + "Input device", + "Off keeps the microphone closed.", + SettingKind::kDevice, + {{"default", "System default"}, {"disabled", "Off"}}, + "default", + {}}, }}, }}, {"discord", @@ -513,6 +523,35 @@ std::size_t ChoiceIndex(const Setting& setting, std::string_view value) { return kNoChoice; } +std::vector DeviceChoices(const Setting& setting, + const std::vector& devices, + std::string_view value) { + std::vector choices; + for (const SettingChoice& choice : setting.choices) { + choices.push_back({std::string(choice.value), std::string(choice.label)}); + } + const auto listed = [&choices](std::string_view name) { + return std::any_of(choices.begin(), choices.end(), + [name](const DeviceChoice& choice) { + return choice.value == name; + }); + }; + for (const std::string& device : devices) { + if (device.empty() || listed(device) || + std::count(devices.begin(), devices.end(), device) != 1) { + continue; + } + choices.push_back({device, device}); + } + if (value.empty()) { + value = setting.default_value; + } + if (!listed(value)) { + choices.push_back({std::string(value), std::string(value) + " (not found)"}); + } + return choices; +} + bool ToggleState(const Setting& setting, std::string_view value) { return (value.empty() ? setting.default_value : value) == "true"; } diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index 6f4c9fc4..dea2a7df 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -194,6 +194,72 @@ TEST_F(SettingsModelTest, SavedValuesReachTheLoadedConfig) { EXPECT_TRUE(loaded.config.input_capabilities().raw_mouse); } +std::vector Values(const std::vector& choices) { + std::vector values; + for (const DeviceChoice& choice : choices) { + values.push_back(choice.value); + } + return values; +} + +TEST_F(SettingsModelTest, DeviceRowsOfferTheirChoicesThenTheDevices) { + const Setting& output = Find("audio.output_device"); + const Setting& input = Find("audio.input_device"); + ASSERT_EQ(output.kind, SettingKind::kDevice); + ASSERT_EQ(input.kind, SettingKind::kDevice); + + const std::vector devices = {"USB Headset", "Built-in Audio"}; + EXPECT_EQ(Values(DeviceChoices(output, devices, "")), + (std::vector{"default", "USB Headset", + "Built-in Audio"})); + const std::vector inputs = DeviceChoices(input, devices, ""); + EXPECT_EQ(Values(inputs), + (std::vector{"default", "disabled", "USB Headset", + "Built-in Audio"})); + EXPECT_EQ(inputs[0].label, "System default"); + EXPECT_EQ(inputs[1].label, "Off"); + EXPECT_EQ(inputs[2].label, "USB Headset"); +} + +TEST_F(SettingsModelTest, ADeviceNightcapCouldNotPickByNameIsLeftOut) { + const Setting& input = Find("audio.input_device"); + EXPECT_EQ(Values(DeviceChoices( + input, + {"Webcam", "USB Mic", "Webcam", "disabled", "default", ""}, + "")), + (std::vector{"default", "disabled", "USB Mic"})); +} + +TEST_F(SettingsModelTest, ADeviceTheFileNamesStaysListedWhenItIsNotFound) { + const Setting& input = Find("audio.input_device"); + for (const std::string value : {"Old Mic", "id:7"}) { + const std::vector choices = + DeviceChoices(input, {"USB Mic"}, value); + ASSERT_EQ(Values(choices), + (std::vector{"default", "disabled", "USB Mic", + value})); + EXPECT_EQ(choices.back().label, value + " (not found)"); + } + // A device that is found is listed once. + EXPECT_EQ(Values(DeviceChoices(input, {"USB Mic"}, "USB Mic")), + (std::vector{"default", "disabled", "USB Mic"})); +} + +TEST_F(SettingsModelTest, APickedDeviceReachesTheLoadedConfig) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + const std::string name = "Alder Lake: PCH-P #2 [HiFi]"; + ASSERT_TRUE(document.Set("audio.output_device", name, &error)) << error; + ASSERT_TRUE(document.Set("audio.input_device", "disabled", &error)) << error; + EXPECT_EQ(document.Value("audio.output_device"), name); + + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_EQ(loaded.config.audio_output_device(), name); + EXPECT_FALSE(loaded.config.microphone_enabled()); +} + TEST_F(SettingsModelTest, TouchInputIsASwitchThatReachesTheLoadedConfig) { const Setting& touch = Find("device.touch"); EXPECT_EQ(touch.kind, SettingKind::kToggle); From 393d7c535305b4e39d4b5938a8604e8923ec24bf Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 12 Oct 2026 02:40:04 -0500 Subject: [PATCH 13/30] fix(ui): say when a device name is shared by two devices --- src/ui/settings_model.cc | 6 +++++- tests/settings_model_test.cc | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 77d214ae..a08908ac 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -547,7 +547,11 @@ std::vector DeviceChoices(const Setting& setting, value = setting.default_value; } if (!listed(value)) { - choices.push_back({std::string(value), std::string(value) + " (not found)"}); + const bool shared = + std::find(devices.begin(), devices.end(), value) != devices.end(); + choices.push_back( + {std::string(value), std::string(value) + + (shared ? " (name not unique)" : " (not found)")}); } return choices; } diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index dea2a7df..620d3505 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -240,6 +240,12 @@ TEST_F(SettingsModelTest, ADeviceTheFileNamesStaysListedWhenItIsNotFound) { value})); EXPECT_EQ(choices.back().label, value + " (not found)"); } + // Two devices share the name the file holds, so neither can be picked. + const std::vector shared = + DeviceChoices(input, {"Webcam", "Webcam"}, "Webcam"); + ASSERT_EQ(Values(shared), + (std::vector{"default", "disabled", "Webcam"})); + EXPECT_EQ(shared.back().label, "Webcam (name not unique)"); // A device that is found is listed once. EXPECT_EQ(Values(DeviceChoices(input, {"USB Mic"}, "USB Mic")), (std::vector{"default", "disabled", "USB Mic"})); From 6da870995bd6138ed2243ceca0f3238065d4af86 Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 12 Oct 2026 02:40:05 -0500 Subject: [PATCH 14/30] feat(ui): add an undo button to the saved notice Undo puts config.yaml back as it was before the last saved change, and refuses when the file was edited by hand since. --- include/ui/settings_model.h | 8 ++++++ src/ui/mocktail_settings.cc | 21 ++++++++++++++++ src/ui/settings_model.cc | 15 +++++++++++- tests/settings_model_test.cc | 47 ++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index c37bf358..49f6c337 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -138,6 +138,13 @@ class SettingsDocument { bool SetText(std::string_view key, std::string_view value, std::string* error); + // Whether a change saved from this document can be taken back. + bool CanUndo() const { return undo_.has_value(); } + + // Puts the file back as it was before the last change that was saved. A + // file edited by hand since then is left alone, and that is an error. + bool Undo(std::string* error); + const std::filesystem::path& config_file() const { return config_file_; } private: @@ -145,6 +152,7 @@ class SettingsDocument { std::filesystem::path config_file_; std::string text_; + std::optional undo_; }; // A place in a text. Both numbers start at 1, and the column counts diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 803f8cb9..ba128d89 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -67,6 +67,8 @@ struct SettingsWindow { std::vector input_devices; // The card under the settings that says when changes take effect. GtkRevealer* notice = nullptr; + // The button on that card that takes the last saved change back. + GtkWidget* undo = nullptr; // Set while rows are filled from the file, so that filling one is not // taken for a change by the user. bool refreshing = false; @@ -219,6 +221,7 @@ void ShowSaved(Row* row, bool saved, const std::string& error) { } else { ShowError(window, error); } + gtk_widget_set_visible(window->undo, window->document.CanUndo()); // On failure the rows go back to what the file holds. A text that was // refused stays in its field, so it can be corrected. RefreshRows(window, saved ? row : nullptr); @@ -234,6 +237,16 @@ void Apply(Row* row, const std::string& value) { ShowSaved(row, saved, error); } +void OnUndo(GtkButton*, gpointer user_data) { + auto* window = static_cast(user_data); + std::string error; + if (!window->document.Undo(&error)) { + ShowError(window, error); + } + gtk_widget_set_visible(window->undo, FALSE); + RefreshRows(window); +} + void OnNumberChanged(GObject* object, GParamSpec*, gpointer user_data) { Apply(static_cast(user_data), mocktail::ui::NumberValue( @@ -657,8 +670,15 @@ GtkWidget* BuildNotice(SettingsWindow* window) { "Saved. Changes apply the next time Nightcap starts."); gtk_label_set_xalign(GTK_LABEL(label), 0); gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_widget_set_hexpand(label, TRUE); gtk_widget_set_margin_start(icon, 14); gtk_widget_set_margin_end(label, 14); + window->undo = gtk_button_new_with_label("Undo"); + gtk_widget_set_tooltip_text(window->undo, "Take the last change back"); + gtk_widget_set_valign(window->undo, GTK_ALIGN_CENTER); + gtk_widget_set_margin_end(window->undo, 8); + gtk_widget_add_css_class(window->undo, "flat"); + g_signal_connect(window->undo, "clicked", G_CALLBACK(OnUndo), window); gtk_widget_set_margin_top(label, 12); gtk_widget_set_margin_bottom(label, 12); @@ -667,6 +687,7 @@ GtkWidget* BuildNotice(SettingsWindow* window) { gtk_widget_add_css_class(card, "card"); gtk_box_append(GTK_BOX(card), icon); gtk_box_append(GTK_BOX(card), label); + gtk_box_append(GTK_BOX(card), window->undo); gtk_widget_set_margin_start(card, 12); gtk_widget_set_margin_end(card, 12); gtk_widget_set_margin_bottom(card, 12); diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index a08908ac..2d78b25e 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -652,6 +652,17 @@ bool SettingsDocument::SetText(std::string_view key, std::string_view value, return Save(std::move(edit.text), std::move(edit.error), error); } +bool SettingsDocument::Undo(std::string* error) { + if (!undo_.has_value()) { + return true; + } + std::string previous = std::move(*undo_); + const bool saved = Save(std::move(previous), std::string(), error); + // An undo is not itself a change to take back. + undo_.reset(); + return saved; +} + bool SettingsDocument::Save(std::string edited, std::string edit_error, std::string* error) { if (!edit_error.empty()) { @@ -668,11 +679,13 @@ bool SettingsDocument::Save(std::string edited, std::string edit_error, // The file may have changed under the window. Show what it holds now. runtime::RuntimeConfigText read = runtime::ReadRuntimeConfigText(config_file_); - if (read) { + if (read && read.text != text_) { text_ = std::move(read.text); + undo_.reset(); } return false; } + undo_ = std::move(text_); text_ = std::move(edited); return true; } diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index 620d3505..5181ce83 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -266,6 +266,53 @@ TEST_F(SettingsModelTest, APickedDeviceReachesTheLoadedConfig) { EXPECT_FALSE(loaded.config.microphone_enabled()); } +TEST_F(SettingsModelTest, UndoPutsTheFileBackAsItWasBeforeTheLastChange) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_FALSE(document.CanUndo()); + + for (const std::string_view key : {"input.raw_mouse", "device.touch"}) { + const std::string before = Read(); + ASSERT_TRUE(document.Set(key, "true", &error)) << key << ": " << error; + ASSERT_NE(Read(), before) << key; + ASSERT_TRUE(document.CanUndo()) << key; + + ASSERT_TRUE(document.Undo(&error)) << key << ": " << error; + EXPECT_EQ(Read(), before) << key; + EXPECT_EQ(document.Value(key), "false") << key; + EXPECT_FALSE(document.CanUndo()) << key; + } +} + +TEST_F(SettingsModelTest, AChangeThatIsNotSavedLeavesNothingToUndo) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_FALSE(document.Set("graphics.backend", "directx", &error)); + EXPECT_FALSE(document.CanUndo()); + // Saving the value the file already holds changes nothing. + ASSERT_TRUE(document.Set("graphics.backend", + document.Value("graphics.backend"), &error)) + << error; + EXPECT_FALSE(document.CanUndo()); +} + +TEST_F(SettingsModelTest, UndoLeavesAHandEditMadeMeanwhileAlone) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + ASSERT_TRUE(document.Set("input.raw_mouse", "true", &error)) << error; + const std::string edited = Read() + "# a note\n"; + Write(edited); + + EXPECT_FALSE(document.Undo(&error)); + EXPECT_FALSE(error.empty()); + EXPECT_EQ(Read(), edited); + EXPECT_FALSE(document.CanUndo()); + EXPECT_EQ(document.Value("input.raw_mouse"), "true"); +} + TEST_F(SettingsModelTest, TouchInputIsASwitchThatReachesTheLoadedConfig) { const Setting& touch = Find("device.touch"); EXPECT_EQ(touch.kind, SettingKind::kToggle); From 34959790cc37cc1d3754986235fa86c02465ea67 Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 12 Oct 2026 22:56:42 -0500 Subject: [PATCH 15/30] feat(ui): add an open config folder row --- include/ui/settings_model.h | 2 ++ src/ui/mocktail_settings.cc | 34 ++++++++++++++++++++++++++++++++++ src/ui/settings_model.cc | 10 ++++++++++ tests/settings_model_test.cc | 8 ++++++++ 4 files changed, 54 insertions(+) diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index 49f6c337..12751945 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -27,6 +27,8 @@ enum class SettingKind { kDevice, // A row that opens the editor for fflags.json. kEditor, + // A row that opens the folder config.yaml is in. + kFolder, }; struct SettingChoice { diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index ba128d89..0a88fb0a 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -192,6 +192,7 @@ void RefreshRow(Row* row, bool keep_edit) { RefreshDeviceRow(row, value); break; case SettingKind::kEditor: + case SettingKind::kFolder: break; } } @@ -482,6 +483,30 @@ void OnFFlagsActivated(AdwActionRow*, gpointer user_data) { gtk_widget_grab_focus(view); } +void OnFolderOpened(GObject* source, GAsyncResult* result, + gpointer user_data) { + GError* error = nullptr; + if (gtk_file_launcher_open_containing_folder_finish( + GTK_FILE_LAUNCHER(source), result, &error)) { + return; + } + if (!g_error_matches(error, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED)) { + ShowError(static_cast(user_data), error->message); + } + g_error_free(error); +} + +// Shows config.yaml in the file manager. +void OnFolderActivated(AdwActionRow*, gpointer user_data) { + auto* window = static_cast(user_data); + GFile* file = g_file_new_for_path(window->document.config_file().c_str()); + GtkFileLauncher* launcher = gtk_file_launcher_new(file); + gtk_file_launcher_open_containing_folder( + launcher, GTK_WINDOW(window->widget), nullptr, OnFolderOpened, window); + g_object_unref(launcher); + g_object_unref(file); +} + void SetRowText(GtkWidget* widget, std::string_view title, std::string_view subtitle) { adw_preferences_row_set_use_markup(ADW_PREFERENCES_ROW(widget), FALSE); @@ -553,6 +578,15 @@ void AddRow(SettingsWindow* window, AdwPreferencesGroup* group, g_signal_connect(row.widget, "activated", G_CALLBACK(OnFFlagsActivated), window); break; + case SettingKind::kFolder: + row.widget = adw_action_row_new(); + adw_action_row_add_suffix( + ADW_ACTION_ROW(row.widget), + gtk_image_new_from_icon_name("folder-open-symbolic")); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row.widget), TRUE); + g_signal_connect(row.widget, "activated", + G_CALLBACK(OnFolderActivated), window); + break; } SetRowText(row.widget, setting.title, setting.subtitle); adw_preferences_group_add(group, row.widget); diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 2d78b25e..92b263d3 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -497,6 +497,16 @@ const std::vector& SettingsPages() { "", {}}, }}, + {"Files", + { + {"config_folder", + "Open config folder", + "Where config.yaml and fflags.json are kept.", + SettingKind::kFolder, + {}, + "", + {}}, + }}, }}, }; return pages; diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index 5181ce83..da576ce0 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -117,6 +117,14 @@ TEST_F(SettingsModelTest, PagesOfOneSectionAreNextToEachOther) { } } +TEST_F(SettingsModelTest, TheAdvancedPageOpensTheConfigFolder) { + const SettingsPage& advanced = SettingsPages().back(); + ASSERT_EQ(advanced.name, "advanced"); + const Setting& folder = Find("config_folder"); + EXPECT_EQ(folder.kind, SettingKind::kFolder); + EXPECT_EQ(&advanced.groups.back().settings.back(), &folder); +} + TEST_F(SettingsModelTest, EveryValueTheWindowOffersSavesAndLoads) { SettingsDocument document(file_); std::string error; From 69ffac2b2574f4fad3bca419a13db4500b509465 Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 12 Oct 2026 22:56:42 -0500 Subject: [PATCH 16/30] feat(ui): add a switch for automatic Roblox updates --- src/ui/settings_model.cc | 11 +++++++++++ tests/native_update_test.cc | 20 ++++++++++++++++++++ tests/settings_model_test.cc | 26 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 92b263d3..4c73a985 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -497,6 +497,17 @@ const std::vector& SettingsPages() { "", {}}, }}, + {"Updates", + { + {"updates.automatic", + "Update Roblox automatically", + "Off keeps the Roblox build you have. Roblox stops old " + "builds from joining after a while.", + SettingKind::kToggle, + {}, + "true", + {}}, + }}, {"Files", { {"config_folder", diff --git a/tests/native_update_test.cc b/tests/native_update_test.cc index 5f2f89cb..8bdca9ec 100644 --- a/tests/native_update_test.cc +++ b/tests/native_update_test.cc @@ -1526,6 +1526,26 @@ TEST_P(UpdateCoordinatorTest, RetriesTheSameCandidateOnNextStartupAfterEagain) { EXPECT_FALSE(std::filesystem::exists(Marker())); } +TEST_P(UpdateCoordinatorTest, StartupKeepsTheCurrentPayloadWhenUpdatesAreOff) { + request.canary_spawn = Spawn(); + Write(paths.config_file, + "version: 1\nupdates:\n automatic: false\n source: apk-pure\n"); + const auto off = RunUpdate(paths, request); + ASSERT_TRUE(off) << off.error; + EXPECT_FALSE(off.changed); + EXPECT_EQ(off.payload_id, active_id); + EXPECT_EQ(CurrentId(), active_id); + EXPECT_EQ(spawns, 0); + EXPECT_NE(off.message.find("updates are disabled"), std::string::npos); + + Write(paths.config_file, + "version: 1\nupdates:\n automatic: true\n source: apk-pure\n"); + const auto on = RunUpdate(paths, request); + ASSERT_TRUE(on) << on.error; + EXPECT_TRUE(on.changed); + EXPECT_EQ(CurrentId(), newer_id); +} + TEST_P(UpdateCoordinatorTest, RealRejectionIsFilteredOnNextStartup) { RejectOnce(); const auto second = RunUpdate(paths, request); diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index da576ce0..74f0545c 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -125,6 +125,32 @@ TEST_F(SettingsModelTest, TheAdvancedPageOpensTheConfigFolder) { EXPECT_EQ(&advanced.groups.back().settings.back(), &folder); } +TEST_F(SettingsModelTest, AutomaticUpdatesIsASwitchThatIsOnUntilTurnedOff) { + const Setting& updates = Find("updates.automatic"); + EXPECT_EQ(updates.kind, SettingKind::kToggle); + + // The first-run file, then a file from before the updates section. + for (const std::string_view file : {"", "graphics:\n backend: opengl\n"}) { + SCOPED_TRACE(std::string(file)); + std::filesystem::remove(file_); + if (!file.empty()) { + Write(file); + } + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_TRUE(ToggleState(updates, document.Value(updates.key))); + + ASSERT_TRUE(document.Set(updates.key, "false", &error)) << error; + EXPECT_FALSE(ToggleState(updates, document.Value(updates.key))); + EXPECT_NE(Read().find("updates:\n"), std::string::npos) << Read(); + EXPECT_NE(Read().find("\n automatic: false\n"), std::string::npos) + << Read(); + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + EXPECT_TRUE(loaded) << loaded.error; + } +} + TEST_F(SettingsModelTest, EveryValueTheWindowOffersSavesAndLoads) { SettingsDocument document(file_); std::string error; From 622abce42b41b4e15472589768b18a75b03109bb Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 12 Oct 2026 23:21:36 -0500 Subject: [PATCH 17/30] fix(ui): show the saved notice only when the file changed --- include/ui/settings_model.h | 5 +++++ src/ui/mocktail_settings.cc | 16 ++++++++++------ src/ui/settings_model.cc | 1 + tests/settings_model_test.cc | 17 +++++++++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index 12751945..9ffc330b 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -147,6 +147,10 @@ class SettingsDocument { // file edited by hand since then is left alone, and that is an error. bool Undo(std::string* error); + // How many times this document has changed the file. Setting a value the + // file already holds does not count. + std::size_t saved_changes() const { return saved_changes_; } + const std::filesystem::path& config_file() const { return config_file_; } private: @@ -155,6 +159,7 @@ class SettingsDocument { std::filesystem::path config_file_; std::string text_; std::optional undo_; + std::size_t saved_changes_ = 0; }; // A place in a text. Both numbers start at 1, and the column counts diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 0a88fb0a..f42da35b 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -215,12 +215,14 @@ void ShowError(SettingsWindow* window, const std::string& error) { } // Shows how saving `row` went, then shows every row as the file now has it. -void ShowSaved(Row* row, bool saved, const std::string& error) { +// `before` is the document's count of saved changes from before the save. +void ShowSaved(Row* row, std::size_t before, bool saved, + const std::string& error) { SettingsWindow* window = row->window; - if (saved) { - gtk_revealer_set_reveal_child(window->notice, TRUE); - } else { + if (!saved) { ShowError(window, error); + } else if (window->document.saved_changes() != before) { + gtk_revealer_set_reveal_child(window->notice, TRUE); } gtk_widget_set_visible(window->undo, window->document.CanUndo()); // On failure the rows go back to what the file holds. A text that was @@ -234,8 +236,9 @@ void Apply(Row* row, const std::string& value) { return; } std::string error; + const std::size_t before = window->document.saved_changes(); const bool saved = window->document.Set(row->setting->key, value, &error); - ShowSaved(row, saved, error); + ShowSaved(row, before, saved, error); } void OnUndo(GtkButton*, gpointer user_data) { @@ -258,9 +261,10 @@ void OnTextApplied(AdwEntryRow* entry, gpointer user_data) { Row* row = static_cast(user_data); SettingsWindow* window = row->window; std::string error; + const std::size_t before = window->document.saved_changes(); const bool saved = window->document.SetText( row->setting->key, gtk_editable_get_text(GTK_EDITABLE(entry)), &error); - ShowSaved(row, saved, error); + ShowSaved(row, before, saved, error); } void OnTextReset(GtkButton*, gpointer user_data) { diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 4c73a985..67a7958c 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -708,6 +708,7 @@ bool SettingsDocument::Save(std::string edited, std::string edit_error, } undo_ = std::move(text_); text_ = std::move(edited); + ++saved_changes_; return true; } diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index 74f0545c..10d3e552 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -332,6 +332,23 @@ TEST_F(SettingsModelTest, AChangeThatIsNotSavedLeavesNothingToUndo) { EXPECT_FALSE(document.CanUndo()); } +TEST_F(SettingsModelTest, OnlyAChangeThatReachesTheFileCountsAsSaved) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_EQ(document.saved_changes(), 0u); + + // The value the file already holds. + ASSERT_TRUE(document.Set("input.raw_mouse", "false", &error)) << error; + EXPECT_EQ(document.saved_changes(), 0u); + + ASSERT_TRUE(document.Set("input.raw_mouse", "true", &error)) << error; + EXPECT_EQ(document.saved_changes(), 1u); + + EXPECT_FALSE(document.Set("graphics.backend", "directx", &error)); + EXPECT_EQ(document.saved_changes(), 1u); +} + TEST_F(SettingsModelTest, UndoLeavesAHandEditMadeMeanwhileAlone) { SettingsDocument document(file_); std::string error; From 9a09d80c1fd98d824654f3a23461e51a3c73dd4a Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 12 Oct 2026 23:21:36 -0500 Subject: [PATCH 18/30] fix(ui): label an input device given by number --- src/ui/settings_model.cc | 25 +++++++++++++++++++------ tests/settings_model_test.cc | 27 ++++++++++++++++++++------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 67a7958c..0c02934c 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -567,13 +567,26 @@ std::vector DeviceChoices(const Setting& setting, if (value.empty()) { value = setting.default_value; } - if (!listed(value)) { - const bool shared = - std::find(devices.begin(), devices.end(), value) != devices.end(); + if (listed(value)) { + return choices; + } + // The input device can be given as "id:" and SDL's number for it. + constexpr std::string_view kNumberPrefix = "id:"; + const std::string_view number = + value.substr(std::min(kNumberPrefix.size(), value.size())); + if (setting.key == "audio.input_device" && + value.substr(0, kNumberPrefix.size()) == kNumberPrefix && + !number.empty() && + number.find_first_not_of("0123456789") == std::string_view::npos) { choices.push_back( - {std::string(value), std::string(value) + - (shared ? " (name not unique)" : " (not found)")}); - } + {std::string(value), "Device number " + std::string(number)}); + return choices; + } + const bool shared = + std::find(devices.begin(), devices.end(), value) != devices.end(); + choices.push_back( + {std::string(value), + std::string(value) + (shared ? " (name not unique)" : " (not found)")}); return choices; } diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index 10d3e552..8e8e7cec 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -266,14 +266,27 @@ TEST_F(SettingsModelTest, ADeviceNightcapCouldNotPickByNameIsLeftOut) { TEST_F(SettingsModelTest, ADeviceTheFileNamesStaysListedWhenItIsNotFound) { const Setting& input = Find("audio.input_device"); - for (const std::string value : {"Old Mic", "id:7"}) { - const std::vector choices = - DeviceChoices(input, {"USB Mic"}, value); - ASSERT_EQ(Values(choices), - (std::vector{"default", "disabled", "USB Mic", - value})); - EXPECT_EQ(choices.back().label, value + " (not found)"); + const std::vector missing = + DeviceChoices(input, {"USB Mic"}, "Old Mic"); + ASSERT_EQ(Values(missing), + (std::vector{"default", "disabled", "USB Mic", + "Old Mic"})); + EXPECT_EQ(missing.back().label, "Old Mic (not found)"); + // The input device can be given by its number, which names no device. + const std::vector numbered = + DeviceChoices(input, {"USB Mic"}, "id:7"); + ASSERT_EQ(Values(numbered), + (std::vector{"default", "disabled", "USB Mic", + "id:7"})); + EXPECT_EQ(numbered.back().label, "Device number 7"); + for (const std::string value : {"id:", "id:7a", "id: 7"}) { + EXPECT_EQ(DeviceChoices(input, {}, value).back().label, + value + " (not found)"); } + EXPECT_EQ(DeviceChoices(Find("audio.output_device"), {}, "id:7") + .back() + .label, + "id:7 (not found)"); // Two devices share the name the file holds, so neither can be picked. const std::vector shared = DeviceChoices(input, {"Webcam", "Webcam"}, "Webcam"); From 7827d2108b7df1c0eb9dbc6075279933710a81cb Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 12 Oct 2026 23:21:37 -0500 Subject: [PATCH 19/30] fix(ui): hide undo after saving fflags.json --- include/ui/settings_model.h | 3 +++ src/ui/mocktail_settings.cc | 3 +++ tests/settings_model_test.cc | 13 +++++++++++++ 3 files changed, 19 insertions(+) diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index 9ffc330b..c9ecde19 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -147,6 +147,9 @@ class SettingsDocument { // file edited by hand since then is left alone, and that is an error. bool Undo(std::string* error); + // Drops the change Undo would take back. The file is left as it is. + void ForgetUndo() { undo_.reset(); } + // How many times this document has changed the file. Setting a value the // file already holds does not count. std::size_t saved_changes() const { return saved_changes_; } diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index f42da35b..aa8f2078 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -339,6 +339,9 @@ void SaveFFlags(FFlagsEditor* editor) { g_free(characters); std::string error; if (editor->window->fflags.Save(text, &error)) { + // Undo cannot take back a save of fflags.json. + editor->window->document.ForgetUndo(); + gtk_widget_set_visible(editor->window->undo, FALSE); gtk_revealer_set_reveal_child(editor->window->notice, TRUE); adw_dialog_force_close(editor->dialog); return; diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index 8e8e7cec..cdea9593 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -362,6 +362,19 @@ TEST_F(SettingsModelTest, OnlyAChangeThatReachesTheFileCountsAsSaved) { EXPECT_EQ(document.saved_changes(), 1u); } +TEST_F(SettingsModelTest, AForgottenChangeCannotBeUndone) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + ASSERT_TRUE(document.Set("input.raw_mouse", "true", &error)) << error; + const std::string saved = Read(); + + document.ForgetUndo(); + EXPECT_FALSE(document.CanUndo()); + EXPECT_TRUE(document.Undo(&error)) << error; + EXPECT_EQ(Read(), saved); +} + TEST_F(SettingsModelTest, UndoLeavesAHandEditMadeMeanwhileAlone) { SettingsDocument document(file_); std::string error; From 8df79a53c896ea40913c816e63abb43f4cf37abd Mon Sep 17 00:00:00 2001 From: vii Date: Mon, 12 Oct 2026 23:21:37 -0500 Subject: [PATCH 20/30] fix(ui): ask before closing with text that is not applied --- src/ui/mocktail_settings.cc | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index aa8f2078..967d0b6b 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -813,6 +813,47 @@ GtkWidget* BuildProblemPage(const SettingsWindow& window, return page; } +void OnDiscardTextResponse(AdwAlertDialog*, const char* response, + gpointer user_data) { + if (g_strcmp0(response, "discard") == 0) { + gtk_window_destroy( + GTK_WINDOW(static_cast(user_data)->widget)); + } +} + +// Closing asks first while a text row holds text that is not applied. +gboolean OnCloseRequest(GtkWindow*, gpointer user_data) { + auto* window = static_cast(user_data); + const Row* edited = nullptr; + for (const Row& row : window->rows) { + if (row.setting->kind == SettingKind::kText && + row.shown != gtk_editable_get_text(GTK_EDITABLE(row.widget))) { + edited = &row; + break; + } + } + if (edited == nullptr) { + return FALSE; + } + const std::string body = "The text typed into \"" + + Text(edited->setting->title) + + "\" is not applied."; + AdwDialog* question = adw_alert_dialog_new("Discard changes?", nullptr); + adw_alert_dialog_set_body(ADW_ALERT_DIALOG(question), body.c_str()); + adw_alert_dialog_add_responses(ADW_ALERT_DIALOG(question), "keep", + "Keep Editing", "discard", "Discard", + nullptr); + adw_alert_dialog_set_response_appearance(ADW_ALERT_DIALOG(question), + "discard", + ADW_RESPONSE_DESTRUCTIVE); + adw_alert_dialog_set_default_response(ADW_ALERT_DIALOG(question), "keep"); + adw_alert_dialog_set_close_response(ADW_ALERT_DIALOG(question), "keep"); + g_signal_connect(question, "response", G_CALLBACK(OnDiscardTextResponse), + window); + adw_dialog_present(question, window->widget); + return TRUE; +} + void OnActivate(GtkApplication* application, gpointer user_data) { auto* window = static_cast(user_data); if (GtkWindow* existing = gtk_application_get_active_window(application)) { @@ -826,6 +867,8 @@ void OnActivate(GtkApplication* application, gpointer user_data) { gtk_window_set_default_size(GTK_WINDOW(window_widget), 860, 620); gtk_widget_set_size_request(window_widget, 360, 420); gtk_window_set_icon_name(GTK_WINDOW(window_widget), kApplicationId); + g_signal_connect(window_widget, "close-request", + G_CALLBACK(OnCloseRequest), window); GtkWidget* content = nullptr; std::string error; From bacbd41d86aef977bc0d87f360f40a2a414ce775 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 11:12:43 -0500 Subject: [PATCH 21/30] feat(ui): cover every config setting and restart Nightcap from settings - Add rows for the device, window, proxy, Fleasion, update, runtime and device identity keys, so every config.yaml setting has one. - Offer to restart a running Nightcap after a change, with the options and environment it was started with. Off in Flatpak. - Show a reset button and per-page counts for settings changed from their defaults, with a tighter sidebar and a full-width notice bar. --- CHANGELOG.md | 7 +- CMakeLists.txt | 1 + include/runtime/running_instance.h | 66 ++++ include/runtime/single_instance_lock.h | 6 + include/ui/settings_model.h | 26 +- src/main.cc | 8 + src/runtime/running_instance.cc | 239 ++++++++++++ src/runtime/single_instance_lock.cc | 8 + src/ui/mocktail_settings.cc | 495 ++++++++++++++++++++++--- src/ui/settings_model.cc | 453 +++++++++++++++++++--- tests/CMakeLists.txt | 8 + tests/running_instance_test.cc | 267 +++++++++++++ tests/settings_model_test.cc | 399 +++++++++++++++++++- tests/single_instance_lock_test.cc | 8 + 14 files changed, 1884 insertions(+), 107 deletions(-) create mode 100644 include/runtime/running_instance.h create mode 100644 src/runtime/running_instance.cc create mode 100644 tests/running_instance_test.cc diff --git a/CHANGELOG.md b/CHANGELOG.md index 4578ba55..73ec3e7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,12 @@ Release notes on GitHub are taken from the matching section here. - Added a settings window. Open it from the app menu (right-click Nightcap, then Settings) or with `mocktail --settings`. It changes `config.yaml` and `fflags.json` for you, keeps your comments, and does - not save a value Nightcap could not start with. Changes apply the next time Nightcap starts. + not save a value Nightcap could not start with. Changes apply the next time Nightcap starts. If Nightcap + is open when you change something, the window offers to restart it for you, with the same options and + environment it was started with. It opens on the main menu. Every `config.yaml` setting has a row, + including the device, the window size, the proxy, Fleasion and the update source. A setting you changed + from its default gets a reset button that shows when you point at it. The sidebar counts changed + settings per page, and its total jumps to the first one. - An asset overlay folder, `~/.local/share/mocktail/asset_overlay`. A file there replaces the file Roblox ships at the same path, such as a cursor, a font or a sky texture, and it stays in place when Roblox updates. The layout matches Sober's `asset_overlay` folder. `MOCKTAIL_ASSET_OVERLAY_DIR` moves it. Thanks to diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ca88e1b..6284d1c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -307,6 +307,7 @@ add_library(mocktail_runtime STATIC src/runtime/runtime_config_store.cc src/runtime/runtime_config_file.cc src/runtime/runtime_paths.cc + src/runtime/running_instance.cc src/runtime/session_log.cc src/runtime/single_instance_lock.cc src/runtime/startup_state_machine.cc diff --git a/include/runtime/running_instance.h b/include/runtime/running_instance.h new file mode 100644 index 00000000..2b568324 --- /dev/null +++ b/include/runtime/running_instance.h @@ -0,0 +1,66 @@ +#ifndef MOCKTAIL_RUNTIME_RUNNING_INSTANCE_H_ +#define MOCKTAIL_RUNTIME_RUNNING_INSTANCE_H_ + +#include + +#include +#include +#include +#include +#include +#include + +#include "runtime/environment.h" + +namespace mocktail { +namespace runtime { + +inline constexpr char kInstanceLockVariable[] = "MOCKTAIL_INSTANCE_LOCK"; +inline constexpr char kRestartProgramVariable[] = "MOCKTAIL_RESTART_PROGRAM"; + +// A Flatpak settings window runs in a sandbox of its own and cannot reach +// the game. +bool CanRestartFromSettings(const Environment& environment); + +std::optional FindFlockHolder(std::string_view proc_locks, + dev_t device, ino_t inode); + +struct RunningInstance { + bool running = false; + // 0 when Nightcap runs but this process cannot see it. + pid_t pid = 0; +}; + +RunningInstance FindRunningInstance(const std::filesystem::path& lock_file); + +// SIGTERM reaches Nightcap as SDL_EVENT_QUIT, the same as closing its window. +bool StopRunningInstance(const std::filesystem::path& lock_file, + std::chrono::milliseconds timeout, + std::string* error); + +struct LaunchCommand { + std::vector arguments; + // NAME=value entries. + std::vector environment; +}; + +// The arguments in /proc//cmdline. Nightcap zeroes a join link after +// reading it, since the link holds a one-time login ticket, so the zeroed +// argument is dropped along with a --launch-uri or --launch-request-json +// left without it. +std::vector ParseProcArguments(std::string_view cmdline); +std::vector ParseProcEnvironment(std::string_view variables); + +// Nothing when /proc cannot be read, as for another user's process. +std::optional ReadLaunchCommand(pid_t pid); + +// Starts `program` in a session of its own, so it keeps running after this +// process exits. Empty arguments run it with just its path; an empty +// environment passes this process's own. +bool StartDetached(const std::filesystem::path& program, std::string* error, + const LaunchCommand& command = {}); + +} // namespace runtime +} // namespace mocktail + +#endif diff --git a/include/runtime/single_instance_lock.h b/include/runtime/single_instance_lock.h index 4d3dc29d..29bd18a3 100644 --- a/include/runtime/single_instance_lock.h +++ b/include/runtime/single_instance_lock.h @@ -15,6 +15,12 @@ namespace runtime { // while every process started from one installed binary shares one owner. std::string CurrentExecutableInstanceKey(); +std::filesystem::path CurrentUserInstanceLockPath(); + +// The program that starts this installation: the AppImage file when running +// from one, otherwise this executable. +std::filesystem::path CurrentExecutableIdentityPath(); + // Holds a non-blocking advisory lock for one Mocktail game process. The // descriptor remains open for the lifetime of this object, so the kernel also // releases the lock after a crash or forced process termination. diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index c9ecde19..0d470e66 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -9,6 +9,7 @@ #include #include +#include "runtime/runtime_config_editor.h" #include "runtime/runtime_config_store.h" namespace mocktail { @@ -23,6 +24,8 @@ enum class SettingKind { kFrameRate, kNumber, kText, + // A text row that holds an absolute file path, with a file picker. + kFile, // A drop-down of the setting's choices and the audio devices found. kDevice, // A row that opens the editor for fflags.json. @@ -47,11 +50,20 @@ struct Setting { std::string_view default_value; // Other spellings the loader accepts, each with the choice it means. std::vector> aliases; + // A kNumber row's range. It must stay inside what the loader accepts. + double minimum = 0; + double maximum = 1048576; + double step = 256; }; struct SettingsGroup { std::string_view title; std::vector settings; + std::string_view expander; + std::string_view expander_subtitle; + // With this, the first setting, a kToggle, is the expander row's own + // switch, and the other settings apply only while it is on. + bool expander_switch = false; }; struct SettingsPage { @@ -140,6 +152,11 @@ class SettingsDocument { bool SetText(std::string_view key, std::string_view value, std::string* error); + // Mouse, keyboard and touch compare with what the file's device turns on. + bool IsChanged(const Setting& setting) const; + + bool Reset(const Setting& setting, std::string* error); + // Whether a change saved from this document can be taken back. bool CanUndo() const { return undo_.has_value(); } @@ -157,7 +174,11 @@ class SettingsDocument { const std::filesystem::path& config_file() const { return config_file_; } private: - bool Save(std::string edited, std::string edit_error, std::string* error); + // Otherwise an empty `value` is written as an empty text. + runtime::RuntimeConfigEdit Edit(std::string_view key, std::string_view value, + bool empty_unsets) const; + bool Save(runtime::RuntimeConfigEdit edit, std::string* error); + std::string DefaultValue(const Setting& setting) const; std::filesystem::path config_file_; std::string text_; @@ -165,6 +186,9 @@ class SettingsDocument { std::size_t saved_changes_ = 0; }; +std::size_t ChangedCount(const SettingsPage& page, + const SettingsDocument& document); + // A place in a text. Both numbers start at 1, and the column counts // characters, not bytes. struct TextPosition { diff --git a/src/main.cc b/src/main.cc index c5295fe8..0302d3e3 100644 --- a/src/main.cc +++ b/src/main.cc @@ -47,6 +47,7 @@ #include "runtime/runtime_config_file.h" #include "runtime/runtime_paths.h" #include "runtime/session_log.h" +#include "runtime/running_instance.h" #include "runtime/settings_launcher.h" #include "runtime/single_instance_lock.h" #include "runtime/support_bundle.h" @@ -265,6 +266,13 @@ int main(int argc, char* argv[]) { // The window reports a broken config itself, so it starts before the // config is loaded. const mocktail::runtime::ProcessEnvironment settings_environment; + // The window finds and restarts a running Nightcap through these. + if (mocktail::runtime::CanRestartFromSettings(settings_environment)) { + setenv(mocktail::runtime::kInstanceLockVariable, + mocktail::runtime::CurrentUserInstanceLockPath().c_str(), 1); + setenv(mocktail::runtime::kRestartProgramVariable, + mocktail::runtime::CurrentExecutableIdentityPath().c_str(), 1); + } std::string settings_error; mocktail::runtime::ExecSettingsWindow(settings_environment, &settings_error); diff --git a/src/runtime/running_instance.cc b/src/runtime/running_instance.cc new file mode 100644 index 00000000..af98f330 --- /dev/null +++ b/src/runtime/running_instance.cc @@ -0,0 +1,239 @@ +#include "runtime/running_instance.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace mocktail { +namespace runtime { +namespace { + +std::string ReadProcFile(const std::string& path, bool* read) { + std::ifstream input(path, std::ios::binary); + std::ostringstream text; + text << input.rdbuf(); + if (read != nullptr) { + *read = input.is_open() && !input.bad(); + } + return text.str(); +} + +std::string ReadProcLocks() { return ReadProcFile("/proc/locks", nullptr); } + +// Every entry of a NUL-separated list, empty ones included. +std::vector SplitNulList(std::string_view list) { + std::vector entries; + std::size_t start = 0; + while (start < list.size()) { + const std::size_t end = list.find('\0', start); + const std::size_t stop = end == std::string_view::npos ? list.size() : end; + entries.emplace_back(list.substr(start, stop - start)); + start = stop + 1; + } + return entries; +} + +} // namespace + +std::vector ParseProcArguments(std::string_view cmdline) { + const std::vector entries = SplitNulList(cmdline); + std::vector arguments; + for (std::size_t index = 0; index < entries.size(); ++index) { + const std::string& entry = entries[index]; + if (entry.empty()) { + continue; + } + const bool takes_link = + entry == "--launch-uri" || entry == "--launch-request-json"; + if (takes_link && + (index + 1 >= entries.size() || entries[index + 1].empty())) { + continue; + } + arguments.push_back(entry); + } + return arguments; +} + +std::vector ParseProcEnvironment(std::string_view variables) { + std::vector entries; + for (std::string& entry : SplitNulList(variables)) { + if (!entry.empty()) { + entries.push_back(std::move(entry)); + } + } + return entries; +} + +std::optional ReadLaunchCommand(pid_t pid) { + if (pid <= 0) { + return std::nullopt; + } + const std::string directory = "/proc/" + std::to_string(pid); + bool read_arguments = false; + bool read_environment = false; + const std::string cmdline = + ReadProcFile(directory + "/cmdline", &read_arguments); + const std::string variables = + ReadProcFile(directory + "/environ", &read_environment); + if (!read_arguments || !read_environment || cmdline.empty()) { + return std::nullopt; + } + return LaunchCommand{ParseProcArguments(cmdline), + ParseProcEnvironment(variables)}; +} + +bool CanRestartFromSettings(const Environment& environment) { + return !environment.HasNonEmpty("FLATPAK_ID"); +} + +std::optional FindFlockHolder(std::string_view proc_locks, + dev_t device, ino_t inode) { + std::istringstream lines{std::string(proc_locks)}; + std::string line; + while (std::getline(lines, line)) { + // ": FLOCK ADVISORY WRITE :: " + // with the numbers of the device in hex. Waiters have "->" after ":". + std::istringstream fields(line); + std::string index, type, advisory, mode, pid_text, file; + fields >> index >> type >> advisory >> mode >> pid_text >> file; + if (type != "FLOCK" || mode != "WRITE") { + continue; + } + unsigned int major_number = 0; + unsigned int minor_number = 0; + unsigned long long inode_number = 0; + long pid = -1; + if (std::sscanf(file.c_str(), "%x:%x:%llu", &major_number, &minor_number, + &inode_number) != 3 || + std::sscanf(pid_text.c_str(), "%ld", &pid) != 1 || pid < 0) { + continue; + } + if (makedev(major_number, minor_number) == device && + inode_number == inode) { + return static_cast(pid); + } + } + return std::nullopt; +} + +RunningInstance FindRunningInstance(const std::filesystem::path& lock_file) { + struct stat status{}; + if (stat(lock_file.c_str(), &status) != 0) { + return {}; + } + const std::optional holder = + FindFlockHolder(ReadProcLocks(), status.st_dev, status.st_ino); + if (!holder.has_value()) { + return {}; + } + return {true, *holder}; +} + +bool StopRunningInstance(const std::filesystem::path& lock_file, + std::chrono::milliseconds timeout, + std::string* error) { + const RunningInstance instance = FindRunningInstance(lock_file); + if (!instance.running) { + return true; + } + if (instance.pid <= 0) { + *error = "Nightcap is running where the settings window cannot reach it"; + return false; + } + if (kill(instance.pid, SIGTERM) != 0 && errno != ESRCH) { + *error = "cannot close Nightcap: " + std::string(std::strerror(errno)); + return false; + } + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (FindRunningInstance(lock_file).running) { + if (std::chrono::steady_clock::now() >= deadline) { + *error = "Nightcap did not close within " + + std::to_string(timeout.count() / 1000) + " seconds"; + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return true; +} + +bool StartDetached(const std::filesystem::path& program, std::string* error, + const LaunchCommand& command) { + const auto fail = [&](int error_number) { + *error = "cannot start " + program.string() + ": " + + std::strerror(error_number); + return false; + }; + // Built before fork: the caller may have other threads, so the child must + // not allocate. + std::string path = program.string(); + std::vector argument_text = command.arguments; + if (argument_text.empty()) { + argument_text.push_back(path); + } + std::vector arguments; + for (std::string& argument : argument_text) { + arguments.push_back(argument.data()); + } + arguments.push_back(nullptr); + std::vector environment_text = command.environment; + std::vector environment; + for (std::string& variable : environment_text) { + environment.push_back(variable.data()); + } + environment.push_back(nullptr); + char* const* envp = command.environment.empty() ? environ : environment.data(); + // The grandchild reports a failed exec through this pipe. A successful exec + // closes it, so the parent reads nothing. + int report[2]; + if (pipe2(report, O_CLOEXEC) != 0) { + return fail(errno); + } + const pid_t child = fork(); + if (child < 0) { + const int fork_error = errno; + close(report[0]); + close(report[1]); + return fail(fork_error); + } + if (child == 0) { + close(report[0]); + setsid(); + const pid_t grandchild = fork(); + if (grandchild == 0) { + execve(path.c_str(), arguments.data(), envp); + const int exec_error = errno; + (void)write(report[1], &exec_error, sizeof(exec_error)); + _exit(127); + } + if (grandchild < 0) { + const int fork_error = errno; + (void)write(report[1], &fork_error, sizeof(fork_error)); + } + _exit(0); + } + close(report[1]); + while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) { + } + int start_error = 0; + ssize_t length = -1; + do { + length = read(report[0], &start_error, sizeof(start_error)); + } while (length < 0 && errno == EINTR); + close(report[0]); + return length == static_cast(sizeof(start_error)) + ? fail(start_error) + : true; +} + +} // namespace runtime +} // namespace mocktail diff --git a/src/runtime/single_instance_lock.cc b/src/runtime/single_instance_lock.cc index 3ea35942..d1f6240d 100644 --- a/src/runtime/single_instance_lock.cc +++ b/src/runtime/single_instance_lock.cc @@ -108,6 +108,14 @@ bool IsPositiveDecimal(std::string_view value) { } // namespace +std::filesystem::path CurrentUserInstanceLockPath() { + return CurrentUserLockPath(); +} + +std::filesystem::path CurrentExecutableIdentityPath() { + return ExecutableIdentityPath(); +} + std::string CurrentExecutableInstanceKey() { static const std::string instance_key = []() { // FNV-1a is used only for a compact deterministic namespace. The endpoint diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 967d0b6b..e3ac8638 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -1,14 +1,17 @@ #include #include +#include #include #include +#include #include #include #include #include "mocktail/platform/sdl_window_icon_data.h" #include "runtime/environment.h" +#include "runtime/running_instance.h" #include "runtime/runtime_paths.h" #include "ui/settings_model.h" @@ -28,6 +31,25 @@ using mocktail::ui::SettingsPage; constexpr char kApplicationId[] = "io.github.CoderDayton.nightcap"; +constexpr char kWindowStyle[] = R"( +.navigation-sidebar > row { min-height: 44px; margin: 1px 6px; padding: 0 14px; } +.row-reset { opacity: 0; transition: opacity 150ms ease-in-out; } +row:hover .row-reset, row:focus-within .row-reset { opacity: 1; } +.count-badge { + min-width: 18px; min-height: 18px; padding: 0 6px; border-radius: 9px; + font-size: 0.75em; font-weight: 700; + background-color: alpha(@accent_bg_color, 0.25); color: @accent_color; +} +.changed-chip { + min-height: 26px; padding: 0 10px; border-radius: 13px; font-weight: 700; + background-color: alpha(@accent_bg_color, 0.2); color: @accent_color; +} +.notice-bar { + min-height: 54px; padding-right: 6px; background-color: @window_bg_color; + box-shadow: inset 0 1px alpha(@window_fg_color, 0.1); +} +)"; + struct SettingsWindow; struct Row { @@ -36,9 +58,15 @@ struct Row { GtkWidget* widget = nullptr; // The custom frames-per-second row. Only a kFrameRate row has one. GtkWidget* custom = nullptr; - // The button that puts a text back to its default. Only a kText row has - // one. + // The button that puts the row back to its default. Shown only while the + // row is changed. GtkWidget* reset = nullptr; + // The button that opens a file picker. Only a kFile row has one. + GtkWidget* pick = nullptr; + // Whether `widget` is an expander row whose own switch is this toggle. + bool expander_switch = false; + // The row's page, in SettingsPages() order. + std::size_t page = 0; // A value the file holds that the row does not list. It is shown as one // more choice so opening the window never changes it. std::string unlisted; @@ -67,11 +95,29 @@ struct SettingsWindow { std::vector input_devices; // The card under the settings that says when changes take effect. GtkRevealer* notice = nullptr; + GtkLabel* notice_label = nullptr; + // The button on that card that restarts a running Nightcap. + GtkWidget* restart = nullptr; + // Both empty when the window was not started by `mocktail --settings`. + std::filesystem::path instance_lock; + std::filesystem::path restart_program; + // The restart question is asked once per window. + bool asked_restart = false; + bool restarting = false; + // A change saved while the restart ran may not reach the new Nightcap. + bool saved_while_restarting = false; + // Set once the window is destroyed, after which its widgets are gone. + bool closed = false; // The button on that card that takes the last saved change back. GtkWidget* undo = nullptr; // Set while rows are filled from the file, so that filling one is not // taken for a change by the user. bool refreshing = false; + // Each page's count of changed rows in the sidebar, in SettingsPages() + // order. + std::vector badges; + GtkWidget* changed_chip = nullptr; + GtkListBox* sidebar = nullptr; }; std::string Text(std::string_view text) { return std::string(text); } @@ -138,8 +184,14 @@ void RefreshRow(Row* row, bool keep_edit) { const std::string value = row->window->document.Value(setting.key); switch (setting.kind) { case SettingKind::kToggle: - adw_switch_row_set_active(ADW_SWITCH_ROW(row->widget), - mocktail::ui::ToggleState(setting, value)); + if (row->expander_switch) { + adw_expander_row_set_enable_expansion( + ADW_EXPANDER_ROW(row->widget), + mocktail::ui::ToggleState(setting, value)); + } else { + adw_switch_row_set_active(ADW_SWITCH_ROW(row->widget), + mocktail::ui::ToggleState(setting, value)); + } break; case SettingKind::kChoice: { const std::size_t index = ChoiceIndex(setting, value); @@ -173,7 +225,8 @@ void RefreshRow(Row* row, bool keep_edit) { Text(value.empty() ? setting.default_value : value).c_str(), nullptr)); break; - case SettingKind::kText: { + case SettingKind::kText: + case SettingKind::kFile: { const bool is_set = row->window->document.IsSet(setting.key); const std::string text = is_set ? value : Text(setting.default_value); const std::string current = @@ -185,7 +238,6 @@ void RefreshRow(Row* row, bool keep_edit) { } row->shown = text; } - gtk_widget_set_sensitive(row->reset, is_set); break; } case SettingKind::kDevice: @@ -197,6 +249,34 @@ void RefreshRow(Row* row, bool keep_edit) { } } +// Shows the reset button of each row that differs from its default, and the +// counts of those rows. +void ShowChanged(SettingsWindow* window) { + std::vector counts(window->badges.size(), 0); + for (Row& row : window->rows) { + const bool changed = window->document.IsChanged(*row.setting); + if (changed && row.page < counts.size()) { + ++counts[row.page]; + } + if (row.reset != nullptr) { + gtk_widget_set_visible(row.reset, changed); + } + } + std::size_t total = 0; + for (std::size_t index = 0; index < window->badges.size(); ++index) { + const std::size_t count = counts[index]; + total += count; + gtk_label_set_text(GTK_LABEL(window->badges[index]), + std::to_string(count).c_str()); + gtk_widget_set_visible(window->badges[index], count > 0); + } + if (window->changed_chip != nullptr) { + gtk_button_set_label(GTK_BUTTON(window->changed_chip), + (std::to_string(total) + " changed").c_str()); + gtk_widget_set_visible(window->changed_chip, total > 0); + } +} + // Shows every row as the file has it. Text typed into a row and not saved // yet stays, except in `saved_row`, whose text was just saved. void RefreshRows(SettingsWindow* window, const Row* saved_row = nullptr) { @@ -205,6 +285,7 @@ void RefreshRows(SettingsWindow* window, const Row* saved_row = nullptr) { RefreshRow(&row, &row != saved_row); } window->refreshing = false; + ShowChanged(window); } void ShowError(SettingsWindow* window, const std::string& error) { @@ -214,6 +295,126 @@ void ShowError(SettingsWindow* window, const std::string& error) { adw_toast_overlay_add_toast(window->toasts, toast); } +constexpr char kSavedText[] = + "Saved. Changes apply the next time Nightcap starts."; +constexpr char kRestartToApplyText[] = + "Saved. Restart Nightcap to apply the changes."; + +struct RestartJob { + std::filesystem::path instance_lock; + std::filesystem::path restart_program; + std::string error; +}; + +void RunRestart(GTask* task, gpointer, gpointer task_data, GCancellable*) { + auto* job = static_cast(task_data); + // Read before Nightcap closes, so the new one starts with the same + // arguments and environment as the one it replaces. + const std::optional command = + mocktail::runtime::ReadLaunchCommand( + mocktail::runtime::FindRunningInstance(job->instance_lock).pid); + const bool restarted = + mocktail::runtime::StopRunningInstance( + job->instance_lock, std::chrono::seconds(15), &job->error) && + mocktail::runtime::StartDetached( + job->restart_program, &job->error, + command.value_or(mocktail::runtime::LaunchCommand{})); + g_task_return_boolean(task, restarted); +} + +void OnRestartDone(GObject*, GAsyncResult* result, gpointer user_data) { + auto* window = static_cast(user_data); + GTask* task = G_TASK(result); + const auto* job = static_cast(g_task_get_task_data(task)); + window->restarting = false; + const bool restarted = g_task_propagate_boolean(task, nullptr); + // Closing the window while the restart ran must not end the process + // between closing Nightcap and starting it again. + g_application_release(g_application_get_default()); + if (window->closed) { + return; + } + if (restarted && !window->saved_while_restarting) { + gtk_label_set_text(window->notice_label, "Saved. Nightcap restarted."); + gtk_widget_set_visible(window->restart, FALSE); + return; + } + gtk_label_set_text(window->notice_label, kRestartToApplyText); + gtk_widget_set_sensitive(window->restart, TRUE); + if (!restarted) { + ShowError(window, job->error); + } +} + +void StartRestart(SettingsWindow* window) { + if (window->restarting) { + return; + } + window->restarting = true; + window->saved_while_restarting = false; + gtk_widget_set_sensitive(window->restart, FALSE); + gtk_label_set_text(window->notice_label, "Restarting Nightcap…"); + g_application_hold(g_application_get_default()); + auto* job = new RestartJob{window->instance_lock, window->restart_program, + std::string()}; + GTask* task = g_task_new(nullptr, nullptr, OnRestartDone, window); + g_task_set_task_data(task, job, [](gpointer data) { + delete static_cast(data); + }); + g_task_run_in_thread(task, RunRestart); + g_object_unref(task); +} + +void OnRestartClicked(GtkButton*, gpointer user_data) { + StartRestart(static_cast(user_data)); +} + +void OnRestartResponse(AdwAlertDialog*, const char* response, + gpointer user_data) { + if (g_strcmp0(response, "restart") == 0) { + StartRestart(static_cast(user_data)); + } +} + +void AskRestart(SettingsWindow* window) { + AdwDialog* question = adw_alert_dialog_new( + "Restart Nightcap?", + "Nightcap is running, so the changes apply after it restarts. " + "Restarting leaves the game you are in."); + adw_alert_dialog_add_responses(ADW_ALERT_DIALOG(question), "later", + "Later", "restart", "Restart Now", nullptr); + adw_alert_dialog_set_response_appearance(ADW_ALERT_DIALOG(question), + "restart", + ADW_RESPONSE_SUGGESTED); + adw_alert_dialog_set_default_response(ADW_ALERT_DIALOG(question), "restart"); + adw_alert_dialog_set_close_response(ADW_ALERT_DIALOG(question), "later"); + g_signal_connect(question, "response", G_CALLBACK(OnRestartResponse), + window); + adw_dialog_present(question, window->widget); +} + +// Shows the saved card, and while Nightcap runs, offers to restart it. +void OnChangeSaved(SettingsWindow* window) { + gtk_revealer_set_reveal_child(window->notice, TRUE); + if (window->restarting) { + window->saved_while_restarting = true; + return; + } + // An empty lock path finds nothing running. + const mocktail::runtime::RunningInstance instance = + mocktail::runtime::FindRunningInstance(window->instance_lock); + const bool can_restart = instance.running && instance.pid > 0 && + !window->restart_program.empty(); + gtk_label_set_text(window->notice_label, + instance.running ? kRestartToApplyText : kSavedText); + gtk_widget_set_visible(window->restart, can_restart); + gtk_widget_set_sensitive(window->restart, TRUE); + if (can_restart && !window->asked_restart) { + window->asked_restart = true; + AskRestart(window); + } +} + // Shows how saving `row` went, then shows every row as the file now has it. // `before` is the document's count of saved changes from before the save. void ShowSaved(Row* row, std::size_t before, bool saved, @@ -222,7 +423,7 @@ void ShowSaved(Row* row, std::size_t before, bool saved, if (!saved) { ShowError(window, error); } else if (window->document.saved_changes() != before) { - gtk_revealer_set_reveal_child(window->notice, TRUE); + OnChangeSaved(window); } gtk_widget_set_visible(window->undo, window->document.CanUndo()); // On failure the rows go back to what the file holds. A text that was @@ -262,14 +463,23 @@ void OnTextApplied(AdwEntryRow* entry, gpointer user_data) { SettingsWindow* window = row->window; std::string error; const std::size_t before = window->document.saved_changes(); - const bool saved = window->document.SetText( - row->setting->key, gtk_editable_get_text(GTK_EDITABLE(entry)), &error); + const std::string_view text = gtk_editable_get_text(GTK_EDITABLE(entry)); + // A field with no default left empty means unset, not an empty text the + // loader would refuse. + const bool saved = + text.empty() && row->setting->default_value.empty() + ? window->document.Set(row->setting->key, text, &error) + : window->document.SetText(row->setting->key, text, &error); ShowSaved(row, before, saved, error); } -void OnTextReset(GtkButton*, gpointer user_data) { - // An empty value unsets the key, which brings the default back. - Apply(static_cast(user_data), std::string()); +void OnReset(GtkButton*, gpointer user_data) { + Row* row = static_cast(user_data); + SettingsWindow* window = row->window; + std::string error; + const std::size_t before = window->document.saved_changes(); + const bool saved = window->document.Reset(*row->setting, &error); + ShowSaved(row, before, saved, error); } void OnToggleChanged(GObject* object, GParamSpec*, gpointer user_data) { @@ -277,6 +487,50 @@ void OnToggleChanged(GObject* object, GParamSpec*, gpointer user_data) { adw_switch_row_get_active(ADW_SWITCH_ROW(object)) ? "true" : "false"); } +void OnExpanderToggled(GObject* object, GParamSpec*, gpointer user_data) { + Apply(static_cast(user_data), + adw_expander_row_get_enable_expansion(ADW_EXPANDER_ROW(object)) + ? "true" + : "false"); +} + +void OnFilePicked(GObject* source, GAsyncResult* result, gpointer user_data) { + Row* row = static_cast(user_data); + GError* error = nullptr; + GFile* file = + gtk_file_dialog_open_finish(GTK_FILE_DIALOG(source), result, &error); + if (file == nullptr) { + if (!g_error_matches(error, GTK_DIALOG_ERROR, + GTK_DIALOG_ERROR_DISMISSED)) { + ShowError(row->window, error->message); + } + g_error_free(error); + return; + } + if (char* path = g_file_get_path(file)) { + gtk_editable_set_text(GTK_EDITABLE(row->widget), path); + g_free(path); + OnTextApplied(ADW_ENTRY_ROW(row->widget), row); + } + g_object_unref(file); +} + +void OnPickFile(GtkButton*, gpointer user_data) { + Row* row = static_cast(user_data); + GtkFileDialog* dialog = gtk_file_dialog_new(); + gtk_file_dialog_set_title(dialog, Text(row->setting->title).c_str()); + const std::filesystem::path current = + gtk_editable_get_text(GTK_EDITABLE(row->widget)); + if (current.is_absolute()) { + GFile* initial = g_file_new_for_path(current.c_str()); + gtk_file_dialog_set_initial_file(dialog, initial); + g_object_unref(initial); + } + gtk_file_dialog_open(dialog, GTK_WINDOW(row->window->widget), nullptr, + OnFilePicked, row); + g_object_unref(dialog); +} + void OnChoiceChanged(GObject* object, GParamSpec*, gpointer user_data) { Row* row = static_cast(user_data); const guint selected = adw_combo_row_get_selected(ADW_COMBO_ROW(object)); @@ -342,8 +596,10 @@ void SaveFFlags(FFlagsEditor* editor) { // Undo cannot take back a save of fflags.json. editor->window->document.ForgetUndo(); gtk_widget_set_visible(editor->window->undo, FALSE); - gtk_revealer_set_reveal_child(editor->window->notice, TRUE); + SettingsWindow* window = editor->window; + // The dialog owns `editor` and may free it on close. adw_dialog_force_close(editor->dialog); + OnChangeSaved(window); return; } // The dialog stays open with the text, so nothing typed is lost. @@ -526,11 +782,37 @@ void SetRowText(GtkWidget* widget, std::string_view title, } } -void AddRow(SettingsWindow* window, AdwPreferencesGroup* group, - const Setting& setting) { +void AddToContainer(GtkWidget* container, GtkWidget* widget) { + if (ADW_IS_EXPANDER_ROW(container)) { + adw_expander_row_add_row(ADW_EXPANDER_ROW(container), widget); + } else { + adw_preferences_group_add(ADW_PREFERENCES_GROUP(container), widget); + } +} + +GtkWidget* NewResetButton(Row* row) { + GtkWidget* button = gtk_button_new_from_icon_name("edit-undo-symbolic"); + gtk_widget_set_tooltip_text(button, "Reset to default"); + gtk_widget_set_valign(button, GTK_ALIGN_CENTER); + gtk_widget_add_css_class(button, "flat"); + gtk_widget_add_css_class(button, "row-reset"); + gtk_widget_set_visible(button, FALSE); + g_signal_connect(button, "clicked", G_CALLBACK(OnReset), row); + return button; +} + +void AddRow(SettingsWindow* window, GtkWidget* container, + const Setting& setting, bool expander_switch = false) { Row& row = window->rows.emplace_back(); row.window = window; row.setting = &setting; + if (expander_switch) { + row.widget = container; + row.expander_switch = true; + g_signal_connect(container, "notify::enable-expansion", + G_CALLBACK(OnExpanderToggled), &row); + return; + } switch (setting.kind) { case SettingKind::kToggle: row.widget = adw_switch_row_new(); @@ -561,20 +843,26 @@ void AddRow(SettingsWindow* window, AdwPreferencesGroup* group, G_CALLBACK(OnFrameRateChanged), &row); break; case SettingKind::kNumber: - row.widget = adw_spin_row_new_with_range(0, 1048576, 256); + row.widget = adw_spin_row_new_with_range(setting.minimum, + setting.maximum, setting.step); g_signal_connect(row.widget, "notify::value", G_CALLBACK(OnNumberChanged), &row); break; case SettingKind::kText: + case SettingKind::kFile: row.widget = adw_entry_row_new(); adw_entry_row_set_show_apply_button(ADW_ENTRY_ROW(row.widget), TRUE); - row.reset = gtk_button_new_from_icon_name("edit-undo-symbolic"); - gtk_widget_set_tooltip_text(row.reset, "Reset to default"); - gtk_widget_set_valign(row.reset, GTK_ALIGN_CENTER); - gtk_widget_add_css_class(row.reset, "flat"); + row.reset = NewResetButton(&row); adw_entry_row_add_suffix(ADW_ENTRY_ROW(row.widget), row.reset); g_signal_connect(row.widget, "apply", G_CALLBACK(OnTextApplied), &row); - g_signal_connect(row.reset, "clicked", G_CALLBACK(OnTextReset), &row); + if (setting.kind == SettingKind::kFile) { + row.pick = gtk_button_new_from_icon_name("document-open-symbolic"); + gtk_widget_set_tooltip_text(row.pick, "Choose a file"); + gtk_widget_set_valign(row.pick, GTK_ALIGN_CENTER); + gtk_widget_add_css_class(row.pick, "flat"); + adw_entry_row_add_suffix(ADW_ENTRY_ROW(row.widget), row.pick); + g_signal_connect(row.pick, "clicked", G_CALLBACK(OnPickFile), &row); + } break; case SettingKind::kEditor: row.widget = adw_action_row_new(); @@ -595,10 +883,20 @@ void AddRow(SettingsWindow* window, AdwPreferencesGroup* group, G_CALLBACK(OnFolderActivated), window); break; } + if (row.reset == nullptr && setting.kind != SettingKind::kEditor && + setting.kind != SettingKind::kFolder) { + row.reset = NewResetButton(&row); + adw_action_row_add_suffix(ADW_ACTION_ROW(row.widget), row.reset); + // Before the row's own control, so the control stays at the edge. + if (GtkWidget* suffixes = gtk_widget_get_parent(row.reset); + GTK_IS_BOX(suffixes)) { + gtk_box_reorder_child_after(GTK_BOX(suffixes), row.reset, nullptr); + } + } SetRowText(row.widget, setting.title, setting.subtitle); - adw_preferences_group_add(group, row.widget); + AddToContainer(container, row.widget); if (row.custom != nullptr) { - adw_preferences_group_add(group, row.custom); + AddToContainer(container, row.custom); } } @@ -621,7 +919,7 @@ void SetSidebarHeader(GtkListBoxRow* row, GtkListBoxRow* before, gpointer) { gtk_label_set_xalign(GTK_LABEL(label), 0); gtk_widget_add_css_class(label, "caption-heading"); gtk_widget_add_css_class(label, "dim-label"); - gtk_widget_set_margin_start(label, 12); + gtk_widget_set_margin_start(label, 14); gtk_widget_set_margin_top(label, before == nullptr ? 6 : 18); gtk_widget_set_margin_bottom(label, 6); gtk_list_box_row_set_header(row, label); @@ -665,9 +963,12 @@ GtkWidget* BuildSidebarTitle() { return box; } +void OnChangedChipClicked(GtkButton*, gpointer user_data); + GtkWidget* BuildSidebar(SettingsWindow* window) { GtkWidget* list = gtk_list_box_new(); gtk_widget_add_css_class(list, "navigation-sidebar"); + window->sidebar = GTK_LIST_BOX(list); gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_BROWSE); gtk_list_box_set_header_func(GTK_LIST_BOX(list), SetSidebarHeader, nullptr, nullptr); @@ -677,7 +978,15 @@ GtkWidget* BuildSidebar(SettingsWindow* window) { gtk_image_new_from_icon_name(Text(page.icon_name).c_str())); GtkWidget* label = gtk_label_new(Text(page.title).c_str()); gtk_label_set_xalign(GTK_LABEL(label), 0); + gtk_widget_set_hexpand(label, TRUE); gtk_box_append(GTK_BOX(box), label); + GtkWidget* badge = gtk_label_new(""); + gtk_widget_add_css_class(badge, "count-badge"); + gtk_widget_set_valign(badge, GTK_ALIGN_CENTER); + gtk_widget_set_tooltip_text(badge, "Settings changed from the default"); + gtk_widget_set_visible(badge, FALSE); + gtk_box_append(GTK_BOX(box), badge); + window->badges.push_back(badge); gtk_list_box_append(GTK_LIST_BOX(list), box); } g_signal_connect(list, "row-selected", G_CALLBACK(OnPageSelected), window); @@ -695,6 +1004,15 @@ GtkWidget* BuildSidebar(SettingsWindow* window) { GtkWidget* title = BuildSidebarTitle(); gtk_widget_set_margin_start(title, 4); adw_header_bar_pack_start(ADW_HEADER_BAR(header), title); + window->changed_chip = gtk_button_new(); + gtk_widget_add_css_class(window->changed_chip, "changed-chip"); + gtk_widget_set_valign(window->changed_chip, GTK_ALIGN_CENTER); + gtk_widget_set_tooltip_text(window->changed_chip, + "Go to the first changed setting"); + gtk_widget_set_visible(window->changed_chip, FALSE); + g_signal_connect(window->changed_chip, "clicked", + G_CALLBACK(OnChangedChipClicked), window); + adw_header_bar_pack_end(ADW_HEADER_BAR(header), window->changed_chip); GtkWidget* toolbar = adw_toolbar_view_new(); adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(toolbar), header); adw_toolbar_view_set_content(ADW_TOOLBAR_VIEW(toolbar), scrolled); @@ -707,8 +1025,8 @@ GtkWidget* BuildSidebar(SettingsWindow* window) { GtkWidget* BuildNotice(SettingsWindow* window) { GtkWidget* icon = gtk_image_new_from_icon_name("dialog-information-symbolic"); - GtkWidget* label = gtk_label_new( - "Saved. Changes apply the next time Nightcap starts."); + GtkWidget* label = gtk_label_new(kSavedText); + window->notice_label = GTK_LABEL(label); gtk_label_set_xalign(GTK_LABEL(label), 0); gtk_label_set_wrap(GTK_LABEL(label), TRUE); gtk_widget_set_hexpand(label, TRUE); @@ -720,41 +1038,81 @@ GtkWidget* BuildNotice(SettingsWindow* window) { gtk_widget_set_margin_end(window->undo, 8); gtk_widget_add_css_class(window->undo, "flat"); g_signal_connect(window->undo, "clicked", G_CALLBACK(OnUndo), window); + window->restart = gtk_button_new_with_label("Restart Now"); + gtk_widget_set_valign(window->restart, GTK_ALIGN_CENTER); + gtk_widget_set_margin_end(window->restart, 8); + gtk_widget_add_css_class(window->restart, "suggested-action"); + gtk_widget_set_visible(window->restart, FALSE); + g_signal_connect(window->restart, "clicked", G_CALLBACK(OnRestartClicked), + window); gtk_widget_set_margin_top(label, 12); gtk_widget_set_margin_bottom(label, 12); - // The stock "card" style gives the rounded shape the rows above have. - GtkWidget* card = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); - gtk_widget_add_css_class(card, "card"); - gtk_box_append(GTK_BOX(card), icon); - gtk_box_append(GTK_BOX(card), label); - gtk_box_append(GTK_BOX(card), window->undo); - gtk_widget_set_margin_start(card, 12); - gtk_widget_set_margin_end(card, 12); - gtk_widget_set_margin_bottom(card, 12); + GtkWidget* bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + gtk_widget_add_css_class(bar, "notice-bar"); + gtk_box_append(GTK_BOX(bar), icon); + gtk_box_append(GTK_BOX(bar), label); + gtk_box_append(GTK_BOX(bar), window->undo); + gtk_box_append(GTK_BOX(bar), window->restart); GtkWidget* revealer = gtk_revealer_new(); window->notice = GTK_REVEALER(revealer); gtk_revealer_set_transition_type(window->notice, GTK_REVEALER_TRANSITION_TYPE_SLIDE_UP); - gtk_revealer_set_child(window->notice, card); + gtk_revealer_set_child(window->notice, bar); return revealer; } GtkWidget* BuildPages(SettingsWindow* window) { GtkWidget* stack = gtk_stack_new(); window->stack = GTK_STACK(stack); - for (const SettingsPage& page : mocktail::ui::SettingsPages()) { - GtkWidget* page_widget = adw_preferences_page_new(); + const auto& pages = mocktail::ui::SettingsPages(); + for (std::size_t page_index = 0; page_index < pages.size(); ++page_index) { + const SettingsPage& page = pages[page_index]; + const std::size_t first_row = window->rows.size(); + // A clamp of our own instead of AdwPreferencesPage, whose fixed 600 px + // clamp leaves wide empty gutters. + GtkWidget* groups = gtk_box_new(GTK_ORIENTATION_VERTICAL, 24); + gtk_widget_set_margin_top(groups, 24); + gtk_widget_set_margin_bottom(groups, 24); + gtk_widget_set_margin_start(groups, 14); + gtk_widget_set_margin_end(groups, 14); + GtkWidget* clamp = adw_clamp_new(); + adw_clamp_set_maximum_size(ADW_CLAMP(clamp), 720); + adw_clamp_set_tightening_threshold(ADW_CLAMP(clamp), 720); + adw_clamp_set_child(ADW_CLAMP(clamp), groups); + GtkWidget* page_widget = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(page_widget), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(page_widget), clamp); for (const SettingsGroup& group : page.groups) { GtkWidget* group_widget = adw_preferences_group_new(); adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(group_widget), Text(group.title).c_str()); - for (const Setting& setting : group.settings) { - AddRow(window, ADW_PREFERENCES_GROUP(group_widget), setting); + GtkWidget* container = group_widget; + std::size_t first = 0; + if (!group.expander.empty()) { + container = adw_expander_row_new(); + SetRowText(container, group.expander, {}); + adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(container), + Text(group.expander_subtitle).c_str()); + if (group.expander_switch && !group.settings.empty()) { + adw_expander_row_set_show_enable_switch(ADW_EXPANDER_ROW(container), + TRUE); + AddRow(window, container, group.settings.front(), true); + first = 1; + } + adw_preferences_group_add(ADW_PREFERENCES_GROUP(group_widget), + container); } - adw_preferences_page_add(ADW_PREFERENCES_PAGE(page_widget), - ADW_PREFERENCES_GROUP(group_widget)); + for (std::size_t index = first; index < group.settings.size(); + ++index) { + AddRow(window, container, group.settings[index]); + } + gtk_box_append(GTK_BOX(groups), group_widget); + } + for (std::size_t index = first_row; index < window->rows.size(); ++index) { + window->rows[index].page = page_index; } gtk_stack_add_named(GTK_STACK(stack), page_widget, Text(page.name).c_str()); @@ -762,13 +1120,36 @@ GtkWidget* BuildPages(SettingsWindow* window) { return stack; } +void OnChangedChipClicked(GtkButton*, gpointer user_data) { + auto* window = static_cast(user_data); + for (Row& row : window->rows) { + if (!window->document.IsChanged(*row.setting)) { + continue; + } + gtk_list_box_select_row( + window->sidebar, + gtk_list_box_get_row_at_index(window->sidebar, + static_cast(row.page))); + adw_navigation_split_view_set_show_content(window->split, TRUE); + // A row inside a closed expander cannot take focus. + for (GtkWidget* parent = gtk_widget_get_parent(row.widget); + parent != nullptr; parent = gtk_widget_get_parent(parent)) { + if (ADW_IS_EXPANDER_ROW(parent)) { + adw_expander_row_set_expanded(ADW_EXPANDER_ROW(parent), TRUE); + } + } + gtk_widget_grab_focus(row.widget); + return; + } +} + // The sidebar of pages beside the settings of the chosen one. GtkWidget* BuildSplitView(SettingsWindow* window, AdwApplicationWindow* window_widget) { GtkWidget* split = adw_navigation_split_view_new(); window->split = ADW_NAVIGATION_SPLIT_VIEW(split); - adw_navigation_split_view_set_min_sidebar_width(window->split, 200); - adw_navigation_split_view_set_max_sidebar_width(window->split, 240); + adw_navigation_split_view_set_min_sidebar_width(window->split, 224); + adw_navigation_split_view_set_max_sidebar_width(window->split, 260); GtkWidget* toasts = adw_toast_overlay_new(); window->toasts = ADW_TOAST_OVERLAY(toasts); @@ -826,7 +1207,8 @@ gboolean OnCloseRequest(GtkWindow*, gpointer user_data) { auto* window = static_cast(user_data); const Row* edited = nullptr; for (const Row& row : window->rows) { - if (row.setting->kind == SettingKind::kText && + if ((row.setting->kind == SettingKind::kText || + row.setting->kind == SettingKind::kFile) && row.shown != gtk_editable_get_text(GTK_EDITABLE(row.widget))) { edited = &row; break; @@ -854,6 +1236,10 @@ gboolean OnCloseRequest(GtkWindow*, gpointer user_data) { return TRUE; } +void OnWindowDestroy(GtkWidget*, gpointer user_data) { + static_cast(user_data)->closed = true; +} + void OnActivate(GtkApplication* application, gpointer user_data) { auto* window = static_cast(user_data); if (GtkWindow* existing = gtk_application_get_active_window(application)) { @@ -861,6 +1247,13 @@ void OnActivate(GtkApplication* application, gpointer user_data) { return; } + GtkCssProvider* style = gtk_css_provider_new(); + gtk_css_provider_load_from_string(style, kWindowStyle); + gtk_style_context_add_provider_for_display( + gdk_display_get_default(), GTK_STYLE_PROVIDER(style), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref(style); + GtkWidget* window_widget = adw_application_window_new(application); window->widget = window_widget; gtk_window_set_title(GTK_WINDOW(window_widget), "Nightcap Settings"); @@ -869,6 +1262,8 @@ void OnActivate(GtkApplication* application, gpointer user_data) { gtk_window_set_icon_name(GTK_WINDOW(window_widget), kApplicationId); g_signal_connect(window_widget, "close-request", G_CALLBACK(OnCloseRequest), window); + g_signal_connect(window_widget, "destroy", G_CALLBACK(OnWindowDestroy), + window); GtkWidget* content = nullptr; std::string error; @@ -899,6 +1294,16 @@ int main(int argc, char* argv[]) { mocktail::runtime::ProcessEnvironment()); SettingsWindow window(paths.config_file(), paths.config_root() / "fflags.json"); + const auto absolute_path = [](const char* name) { + const char* value = std::getenv(name); + return value != nullptr && std::filesystem::path(value).is_absolute() + ? std::filesystem::path(value) + : std::filesystem::path(); + }; + window.instance_lock = + absolute_path(mocktail::runtime::kInstanceLockVariable); + window.restart_program = + absolute_path(mocktail::runtime::kRestartProgramVariable); // The application ID matches the desktop entry so the window gets // Nightcap's icon. Nightcap itself does not own the name on the session diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 0c02934c..933046f9 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -24,45 +24,172 @@ namespace { constexpr std::string_view kTouchKey = "device.touch"; // Where a file with no device key holds it. constexpr std::string_view kTouchKeyWithoutDevice = "input.touch_enabled"; +constexpr std::string_view kMouseKey = "device.mouse"; +constexpr std::string_view kKeyboardKey = "device.keyboard"; +constexpr std::string_view kDeviceKey = "device"; +constexpr std::string_view kDeviceTypeKey = "device.type"; +// The older way to pick a PC or a phone. The loader refuses it beside a +// device. +constexpr std::string_view kPlayabilityKey = + "compatibility.desktop_playability"; +constexpr std::string_view kProxyKey = "network.proxy"; +constexpr std::string_view kProxyHostKey = "network.proxy_host"; +constexpr std::string_view kProxyPortKey = "network.proxy_port"; -// Whether Nightcap turns touch input on for the config `text`. -bool TouchInput(std::string_view text) { - for (const std::string_view key : {kTouchKey, kTouchKeyWithoutDevice}) { - const runtime::RuntimeConfigValue value = - runtime::GetRuntimeConfigValue(text, key); - if (value.found) { - return value.value == "true"; +runtime::RuntimeConfigValue Get(std::string_view text, std::string_view key) { + return runtime::GetRuntimeConfigValue(text, key); +} + +// The preset the file picks, as the file spells it. Empty when it picks none. +std::string DevicePreset(std::string_view text) { + for (const std::string_view key : {kDeviceKey, kDeviceTypeKey}) { + if (runtime::RuntimeConfigValue device = Get(text, key); device.found) { + return std::move(device.value); } } - // Unset, the device decides. - runtime::RuntimeConfigValue device = - runtime::GetRuntimeConfigValue(text, "device"); - if (!device.found) { - device = runtime::GetRuntimeConfigValue(text, "device.type"); + if (const runtime::RuntimeConfigValue playability = + Get(text, kPlayabilityKey); + playability.found) { + const bool desktop = playability.value != "0" && + playability.value != "off" && + playability.value != "false"; + return desktop ? "pc-windows-11" : "mobile-pixel-7"; } + return {}; +} + +// What the file's device turns on for the input `key`. +bool PresetInput(std::string_view text, std::string_view key) { + const std::string preset = DevicePreset(text); const runtime::DeviceProfile* profile = runtime::FindDeviceProfile( - device.found ? std::string_view(device.value) - : runtime::kDefaultDeviceProfileName); - return profile != nullptr && profile->touch_enabled; + preset.empty() ? runtime::kDefaultDeviceProfileName + : std::string_view(preset)); + if (profile == nullptr) { + return false; + } + return key == kTouchKey ? profile->touch_enabled + : key == kMouseKey ? profile->mouse_enabled + : profile->keyboard_enabled; +} + +bool DeviceInput(std::string_view text, std::string_view key) { + if (const runtime::RuntimeConfigValue value = Get(text, key); value.found) { + return value.value == "true"; + } + if (key == kTouchKey) { + if (const runtime::RuntimeConfigValue value = + Get(text, kTouchKeyWithoutDevice); + value.found) { + return value.value == "true"; + } + } + return PresetInput(text, key); +} + +// Runs `step` on the text of `edit`, unless `edit` already failed. +template +runtime::RuntimeConfigEdit Then(runtime::RuntimeConfigEdit edit, Step step) { + return edit ? step(edit.text) : edit; +} + +runtime::RuntimeConfigEdit Unchanged(std::string_view text) { + return {std::string(text), {}}; +} + +runtime::RuntimeConfigEdit UnsetIfSet(std::string_view text, + std::string_view key) { + return Get(text, key).found ? runtime::UnsetRuntimeConfigValue(text, key) + : Unchanged(text); +} + +// `text` with the device in block form: a type, the touch switch moved in +// from input.touch_enabled, and no desktop_playability. +runtime::RuntimeConfigEdit ToDeviceBlock(std::string_view text) { + if (Get(text, kDeviceTypeKey).found) { + return Unchanged(text); + } + const std::string preset = DevicePreset(text); + const runtime::RuntimeConfigValue touch = Get(text, kTouchKeyWithoutDevice); + runtime::RuntimeConfigEdit edit = runtime::SetRuntimeConfigString( + text, kDeviceTypeKey, + preset.empty() ? runtime::kDefaultDeviceProfileName + : std::string_view(preset)); + if (touch.found) { + edit = Then(std::move(edit), [&touch](std::string_view moved) { + return Then(runtime::SetRuntimeConfigString(moved, kTouchKey, touch.value), + [](std::string_view current) { + return runtime::UnsetRuntimeConfigValue( + current, kTouchKeyWithoutDevice); + }); + }); + } + return Then(std::move(edit), [](std::string_view current) { + return UnsetIfSet(current, kPlayabilityKey); + }); +} + +// A new preset brings its own touch, mouse and keyboard, so the switches set +// for the old one are dropped. +runtime::RuntimeConfigEdit SetDevicePreset(std::string_view text, + std::string_view value) { + const std::string_view key = + Get(text, kDeviceTypeKey).found ? kDeviceTypeKey : kDeviceKey; + runtime::RuntimeConfigEdit edit = Unchanged(text); + for (const std::string_view dropped : + {kPlayabilityKey, kTouchKeyWithoutDevice, kTouchKey, kMouseKey, + kKeyboardKey}) { + edit = Then(std::move(edit), [dropped](std::string_view current) { + return UnsetIfSet(current, dropped); + }); + } + return Then(std::move(edit), [key, value](std::string_view current) { + return runtime::SetRuntimeConfigString(current, key, value); + }); +} + +std::string ProxyValue(std::string_view text) { + const runtime::RuntimeConfigValue host = Get(text, kProxyHostKey); + const runtime::RuntimeConfigValue port = Get(text, kProxyPortKey); + return host.found && port.found ? host.value + ":" + port.value + : std::string(); +} + +// `text` with the fixed proxy set from "host:port", or unset when empty. +runtime::RuntimeConfigEdit SetProxy(std::string_view text, + std::string_view value) { + if (value.empty()) { + return Then(UnsetIfSet(text, kProxyHostKey), + [](std::string_view current) { + return UnsetIfSet(current, kProxyPortKey); + }); + } + const std::size_t colon = value.rfind(':'); + if (colon == std::string_view::npos || colon == 0 || + colon + 1 == value.size()) { + return {{}, "Write the proxy as host:port, like 127.0.0.1:8080"}; + } + return Then(runtime::SetRuntimeConfigString(text, kProxyHostKey, + value.substr(0, colon)), + [value, colon](std::string_view current) { + return runtime::SetRuntimeConfigString( + current, kProxyPortKey, value.substr(colon + 1)); + }); +} + +bool IsDeviceDetail(std::string_view key) { + return key.substr(0, 7) == "device." && key != kDeviceTypeKey && + key != kTouchKey; } // `text` with touch input set to `value`, which is "true" or "false". runtime::RuntimeConfigEdit SetTouchInput(std::string_view text, std::string_view value) { - const runtime::RuntimeConfigValue preset = - runtime::GetRuntimeConfigValue(text, "device"); - if (preset.found) { - // A device given as one name has no place for the switch. It becomes - // the block form of the same device. - runtime::RuntimeConfigEdit block = - runtime::SetRuntimeConfigString(text, "device.type", preset.value); - if (!block) { - return block; - } - return runtime::SetRuntimeConfigString(block.text, kTouchKey, value); - } - if (runtime::GetRuntimeConfigValue(text, "device.type").found) { - return runtime::SetRuntimeConfigString(text, kTouchKey, value); + // A device given as one name has no place for the switch. It becomes the + // block form of the same device. + if (Get(text, kDeviceKey).found || Get(text, kDeviceTypeKey).found) { + return Then(ToDeviceBlock(text), [value](std::string_view block) { + return runtime::SetRuntimeConfigString(block, kTouchKey, value); + }); } return runtime::SetRuntimeConfigString(text, kTouchKeyWithoutDevice, value); } @@ -310,6 +437,17 @@ const std::vector& SettingsPages() { "roblox", {}}, }}, + {"Window", + { + {"window.width", "Width", + "Starting width, in desktop units.", SettingKind::kNumber, + {}, "1280", {}, 1, 16384, 1}, + {"window.height", "Height", + "Starting height, in desktop units.", SettingKind::kNumber, + {}, "720", {}, 1, 16384, 1}, + {"window.title", "Title", "", SettingKind::kText, {}, + "Roblox", {}}, + }}, }}, {"input", "Input", @@ -337,6 +475,30 @@ const std::vector& SettingsPages() { "false", {}}, }}, + {"Device", + { + {kDeviceKey, + "Device", + "What Roblox is told it runs on. Phone and console get " + "their own menus and controls.", + SettingKind::kChoice, + { + {"pc-windows-11", "PC"}, + {"mobile-pixel-7", "Phone"}, + {"console-ps5", "Console"}, + }, + "pc-windows-11", + { + {"pc", "pc-windows-11"}, + {"mobile", "mobile-pixel-7"}, + {"console", "console-ps5"}, + }}, + {kMouseKey, "Mouse", "Off hides the mouse from Roblox.", + SettingKind::kToggle, {}, "true", {}}, + {kKeyboardKey, "Keyboard", + "Off hides the keyboard from Roblox.", SettingKind::kToggle, + {}, "true", {}}, + }}, }}, {"performance", "Performance", @@ -410,6 +572,30 @@ const std::vector& SettingsPages() { {}}, }}, }}, + {"network", + "Network", + "network-wired-symbolic", + "System", + { + {"Proxy", + { + {"network.use_system_proxy", + "Use the system proxy", + "Follow the desktop's HTTP or SOCKS5 proxy. Leave the fixed " + "proxy empty while this is on.", + SettingKind::kToggle, + {}, + "false", + {}}, + {kProxyKey, "Fixed proxy (host:port)", "", SettingKind::kText, + {}, "", {}}, + }}, + {"Certificates", + { + {"network.ca_bundle", "CA bundle (PEM)", "", + SettingKind::kFile, {}, "", {}}, + }}, + }}, {"discord", "Discord", "user-available-symbolic", @@ -430,6 +616,8 @@ const std::vector& SettingsPages() { {"integrations.discord_rpc.show_elapsed_time", "Show elapsed time", "", SettingKind::kToggle, {}, "true", {}}, + {"integrations.discord_rpc.application_id", + "Application ID", "", SettingKind::kText, {}, "", {}}, }}, {"Join button", { @@ -481,6 +669,35 @@ const std::vector& SettingsPages() { {}}, }}, }}, + {"fleasion", + "Fleasion", + "application-x-addon-symbolic", + "Integrations", + { + {"Fleasion", + { + {"integrations.fleasion.enabled", "Use Fleasion", "", + SettingKind::kToggle, {}, "false", {}}, + {"integrations.fleasion.proxy_mode", + "Routing", + "Match the mode set in Fleasion.", + SettingKind::kChoice, + { + {"env", "Proxy"}, + {"hosts", "Hosts file"}, + }, + "env", + {}}, + {"integrations.fleasion.proxy_port", "Proxy port", + "Fleasion's local proxy port, used by Proxy routing.", + SettingKind::kNumber, {}, "58443", {}, 1, 65535, 1}, + {"integrations.fleasion.ca_certificate", "CA certificate", + "", SettingKind::kFile, {}, "", {}}, + }, + "Use Fleasion", + "Trust Fleasion's certificate without editing Roblox files.", + true}, + }}, {"advanced", "Advanced", "applications-engineering-symbolic", @@ -507,6 +724,57 @@ const std::vector& SettingsPages() { {}, "true", {}}, + {"updates.source", + "Download source", + "Both start with APKPure today.", + SettingKind::kChoice, + { + {"apk-pure", "APKPure"}, + {"auto", "Automatic"}, + }, + "apk-pure", + {}}, + {"updates.launch_after_update", + "Start Nightcap after a scripted update", + "Used by scripts/auto_update_roblox.sh.", + SettingKind::kToggle, + {}, + "false", + {}}, + }}, + {"Device identity", + { + {"device.platform", "Platform", "", SettingKind::kText, {}, + "", {}}, + {"device.name", "Device name", "", SettingKind::kText, {}, + "", {}}, + {"device.manufacturer", "Manufacturer", "", + SettingKind::kText, {}, "", {}}, + {"device.model", "Model", "", SettingKind::kText, {}, "", + {}}, + {"device.brand", "Brand", "", SettingKind::kText, {}, "", + {}}, + {"device.code", "Device code", "", SettingKind::kText, {}, + "", {}}, + {"device.sku", "SKU", "", SettingKind::kText, {}, "", {}}, + {"device.soc_model", "Chip", "", SettingKind::kText, {}, "", + {}}, + }, + "Custom device identity", + "What the device reports about itself. Empty fields use the " + "device preset's values."}, + {"Runtime", + { + {"runtime.headless", + "Start without a window", + "For testing. The game cannot be seen or played while this " + "is on.", + SettingKind::kToggle, + {}, + "false", + {}}, + {"runtime.roblox_library", "Roblox library (libroblox.so)", + "", SettingKind::kFile, {}, "", {}}, }}, {"Files", { @@ -660,49 +928,142 @@ bool SettingsDocument::Load(std::string* error) { } std::string SettingsDocument::Value(std::string_view key) const { - if (key == kTouchKey) { - return TouchInput(text_) ? "true" : "false"; + if (key == kTouchKey || key == kMouseKey || key == kKeyboardKey) { + return DeviceInput(text_, key) ? "true" : "false"; + } + if (key == kDeviceKey) { + return DevicePreset(text_); } - return runtime::GetRuntimeConfigValue(text_, key).value; + if (key == kProxyKey) { + return ProxyValue(text_); + } + return Get(text_, key).value; } bool SettingsDocument::IsSet(std::string_view key) const { - return runtime::GetRuntimeConfigValue(text_, key).found; + return Get(text_, key == kProxyKey ? kProxyHostKey : key).found; } bool SettingsDocument::Set(std::string_view key, std::string_view value, std::string* error) { - runtime::RuntimeConfigEdit edit = - key == kTouchKey ? SetTouchInput(text_, value) - : value.empty() ? runtime::UnsetRuntimeConfigValue(text_, key) - : runtime::SetRuntimeConfigString(text_, key, value); - return Save(std::move(edit.text), std::move(edit.error), error); + return Save(Edit(key, value, true), error); } bool SettingsDocument::SetText(std::string_view key, std::string_view value, std::string* error) { - runtime::RuntimeConfigEdit edit = - runtime::SetRuntimeConfigString(text_, key, value); - return Save(std::move(edit.text), std::move(edit.error), error); + return Save(Edit(key, value, false), error); +} + +std::string SettingsDocument::DefaultValue(const Setting& setting) const { + if (setting.key == kTouchKey || setting.key == kMouseKey || + setting.key == kKeyboardKey) { + return PresetInput(text_, setting.key) ? "true" : "false"; + } + return std::string(setting.default_value); +} + +bool SettingsDocument::IsChanged(const Setting& setting) const { + const std::string fallback = DefaultValue(setting); + std::string value = Value(setting.key); + if (value.empty()) { + value = fallback; + } + switch (setting.kind) { + case SettingKind::kToggle: + return (value == "true") != (fallback == "true"); + case SettingKind::kChoice: + case SettingKind::kDevice: + return ChoiceIndex(setting, value) != ChoiceIndex(setting, fallback); + case SettingKind::kFrameRate: { + const FrameRate now = ParseFrameRate(value); + const FrameRate then = ParseFrameRate(fallback); + return now.mode != then.mode || + (now.mode == FrameRateMode::kCustom && now.custom != then.custom); + } + case SettingKind::kNumber: + return std::strtod(value.c_str(), nullptr) != + std::strtod(fallback.c_str(), nullptr); + case SettingKind::kText: + case SettingKind::kFile: + return value != fallback; + case SettingKind::kEditor: + case SettingKind::kFolder: + return false; + } + return false; +} + +bool SettingsDocument::Reset(const Setting& setting, std::string* error) { + // The device's own inputs come back once the switch is out of the file. + if (setting.key == kTouchKey || setting.key == kMouseKey || + setting.key == kKeyboardKey) { + runtime::RuntimeConfigEdit edit = UnsetIfSet(text_, setting.key); + if (setting.key == kTouchKey) { + edit = Then(std::move(edit), [](std::string_view current) { + return UnsetIfSet(current, kTouchKeyWithoutDevice); + }); + } + return Save(std::move(edit), error); + } + // An empty device would not load, so it is set to the default preset. + if (setting.key == kDeviceKey) { + return Set(setting.key, setting.default_value, error); + } + // An empty value unsets the key, which brings the default back. + return Set(setting.key, "", error); +} + +std::size_t ChangedCount(const SettingsPage& page, + const SettingsDocument& document) { + std::size_t count = 0; + for (const SettingsGroup& group : page.groups) { + for (const Setting& setting : group.settings) { + count += document.IsChanged(setting) ? 1 : 0; + } + } + return count; +} + +runtime::RuntimeConfigEdit SettingsDocument::Edit(std::string_view key, + std::string_view value, + bool empty_unsets) const { + if (key == kTouchKey) { + return SetTouchInput(text_, value); + } + if (key == kDeviceKey) { + return SetDevicePreset(text_, value); + } + if (key == kProxyKey) { + return SetProxy(text_, value); + } + if (value.empty() && empty_unsets) { + return runtime::UnsetRuntimeConfigValue(text_, key); + } + if (IsDeviceDetail(key)) { + return Then(ToDeviceBlock(text_), [key, value](std::string_view block) { + return runtime::SetRuntimeConfigString(block, key, value); + }); + } + return runtime::SetRuntimeConfigString(text_, key, value); } bool SettingsDocument::Undo(std::string* error) { if (!undo_.has_value()) { return true; } - std::string previous = std::move(*undo_); - const bool saved = Save(std::move(previous), std::string(), error); + const bool saved = Save({std::move(*undo_), {}}, error); // An undo is not itself a change to take back. undo_.reset(); return saved; } -bool SettingsDocument::Save(std::string edited, std::string edit_error, +bool SettingsDocument::Save(runtime::RuntimeConfigEdit edit, std::string* error) { - if (!edit_error.empty()) { - *error = std::move(edit_error); + if (!edit) { + *error = std::move(edit.error); return false; } + std::string edited = std::move(edit.text); if (edited == text_) { return true; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index adf28b9a..38a10b4b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,6 +54,7 @@ endif() add_executable(settings_model_test settings_model_test.cc) target_link_libraries(settings_model_test PRIVATE Mocktail::SettingsModel + Mocktail::Update GTest::gtest_main ) @@ -253,6 +254,12 @@ target_link_libraries(asset_overlay_names_test PRIVATE GTest::gtest_main ) +add_executable(running_instance_test running_instance_test.cc) +target_link_libraries(running_instance_test PRIVATE + Mocktail::Runtime + GTest::gtest_main +) + add_executable(single_instance_lock_test single_instance_lock_test.cc) target_link_libraries(single_instance_lock_test PRIVATE Mocktail::Runtime @@ -1097,6 +1104,7 @@ gtest_discover_tests(game_mode_link_namespace_test PROPERTIES gtest_discover_tests(memory_limit_test) gtest_discover_tests(runtime_paths_test) gtest_discover_tests(asset_overlay_names_test) +gtest_discover_tests(running_instance_test) gtest_discover_tests(single_instance_lock_test) gtest_discover_tests(supported_launch_policy_test) gtest_discover_tests(process_launch_policy_test) diff --git a/tests/running_instance_test.cc b/tests/running_instance_test.cc new file mode 100644 index 00000000..4fe782ae --- /dev/null +++ b/tests/running_instance_test.cc @@ -0,0 +1,267 @@ +#include "runtime/running_instance.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mocktail { +namespace runtime { +namespace { + +using std::chrono::milliseconds; +using namespace std::string_literals; +using Strings = std::vector; + +constexpr char kLocks[] = + "1: POSIX ADVISORY WRITE 900 fd:01:4242 0 EOF\n" + "2: FLOCK ADVISORY WRITE 1234 00:1f:4242 0 EOF\n" + "2: -> FLOCK ADVISORY WRITE 1300 00:1f:4242 0 EOF\n" + "3: FLOCK ADVISORY WRITE 777 00:1f:99 0 EOF\n"; + +class FlatpakEnvironment final : public Environment { + public: + explicit FlatpakEnvironment(std::optional id) + : id_(std::move(id)) {} + + std::optional Get(std::string_view name) const override { + return name == "FLATPAK_ID" ? id_ : std::nullopt; + } + + private: + std::optional id_; +}; + +TEST(CanRestartFromSettingsTest, IsOffInAFlatpak) { + EXPECT_FALSE(CanRestartFromSettings( + FlatpakEnvironment("io.github.CoderDayton.nightcap"))); + EXPECT_TRUE(CanRestartFromSettings(FlatpakEnvironment(std::nullopt))); + EXPECT_TRUE(CanRestartFromSettings(FlatpakEnvironment(""))); +} + +TEST(ParseProcArgumentsTest, DropsAWipedJoinLink) { + EXPECT_EQ(ParseProcArguments("nightcap\0--windowed\0\0\0\0\0"s), + (Strings{"nightcap", "--windowed"})); +} + +TEST(ParseProcArgumentsTest, DropsTheFlagOfAWipedJoinLink) { + EXPECT_EQ( + ParseProcArguments("nightcap\0--launch-uri\0\0\0\0\0--headless\0"s), + (Strings{"nightcap", "--headless"})); + EXPECT_EQ(ParseProcArguments("nightcap\0--launch-request-json\0\0\0\0"s), + (Strings{"nightcap"})); +} + +TEST(ParseProcArgumentsTest, KeepsEveryOtherArgument) { + EXPECT_EQ(ParseProcArguments("nightcap\0--profile\0dev\0"s), + (Strings{"nightcap", "--profile", "dev"})); + EXPECT_EQ(ParseProcArguments(""), Strings{}); +} + +TEST(ParseProcEnvironmentTest, ListsEachVariable) { + EXPECT_EQ(ParseProcEnvironment("A=1\0B=two words\0"s), + (Strings{"A=1", "B=two words"})); + EXPECT_EQ(ParseProcEnvironment(""), Strings{}); +} + +TEST(FindFlockHolderTest, NamesTheHolderOfTheFile) { + EXPECT_EQ(FindFlockHolder(kLocks, makedev(0, 0x1f), 4242), 1234); +} + +TEST(FindFlockHolderTest, SkipsWaitersOtherFilesAndPosixLocks) { + EXPECT_EQ(FindFlockHolder(kLocks, makedev(0, 0x1f), 5), std::nullopt); + EXPECT_EQ(FindFlockHolder(kLocks, makedev(0xfd, 1), 4242), std::nullopt); + EXPECT_EQ(FindFlockHolder("", makedev(0, 0x1f), 4242), std::nullopt); +} + +TEST(FindFlockHolderTest, ReportsAHolderInAnotherPidNamespaceAsZero) { + EXPECT_EQ(FindFlockHolder("1: FLOCK ADVISORY WRITE 0 00:1f:4242 0 EOF\n", + makedev(0, 0x1f), 4242), + 0); +} + +class RunningInstanceTest : public ::testing::Test { + protected: + void SetUp() override { + directory_ = std::filesystem::temp_directory_path() / + ("running_instance_test." + std::to_string(getpid())); + std::filesystem::create_directories(directory_); + lock_ = directory_ / "instance.lock"; + std::ofstream(lock_).close(); + } + + void TearDown() override { + if (holder_ > 0) { + kill(holder_, SIGKILL); + waitpid(holder_, nullptr, 0); + } + std::filesystem::remove_all(directory_); + } + + // Forks a process that holds the lock. With `ignore_term` it stays on + // SIGTERM; otherwise it exits on it after a short delay. + void StartHolder(bool ignore_term) { + int ready[2]; + ASSERT_EQ(pipe(ready), 0); + holder_ = fork(); + ASSERT_GE(holder_, 0); + if (holder_ == 0) { + close(ready[0]); + if (ignore_term) { + signal(SIGTERM, SIG_IGN); + } else { + signal(SIGTERM, [](int) { + usleep(200 * 1000); + _exit(0); + }); + } + const int descriptor = open(lock_.c_str(), O_RDWR); + if (descriptor < 0 || flock(descriptor, LOCK_EX) != 0) { + _exit(1); + } + (void)write(ready[1], "x", 1); + for (;;) { + pause(); + } + } + close(ready[1]); + char byte = 0; + ASSERT_EQ(read(ready[0], &byte, 1), 1); + close(ready[0]); + } + + std::filesystem::path directory_; + std::filesystem::path lock_; + pid_t holder_ = -1; +}; + +TEST_F(RunningInstanceTest, NothingRunsWhileTheLockIsFree) { + EXPECT_FALSE(FindRunningInstance(lock_).running); + EXPECT_FALSE(FindRunningInstance(directory_ / "missing.lock").running); + EXPECT_FALSE(FindRunningInstance({}).running); +} + +TEST_F(RunningInstanceTest, FindsTheProcessHoldingTheLock) { + StartHolder(false); + const RunningInstance instance = FindRunningInstance(lock_); + EXPECT_TRUE(instance.running); + EXPECT_EQ(instance.pid, holder_); +} + +TEST_F(RunningInstanceTest, StopWaitsUntilTheHolderExits) { + StartHolder(false); + std::string error; + EXPECT_TRUE(StopRunningInstance(lock_, milliseconds(5000), &error)) << error; + EXPECT_FALSE(FindRunningInstance(lock_).running); + int status = 0; + EXPECT_EQ(waitpid(holder_, &status, 0), holder_); + holder_ = -1; +} + +TEST_F(RunningInstanceTest, StopGivesUpOnAHolderThatStays) { + StartHolder(true); + std::string error; + EXPECT_FALSE(StopRunningInstance(lock_, milliseconds(300), &error)); + EXPECT_FALSE(error.empty()); + EXPECT_TRUE(FindRunningInstance(lock_).running); +} + +TEST_F(RunningInstanceTest, StopSucceedsWhenNothingRuns) { + std::string error; + EXPECT_TRUE(StopRunningInstance(lock_, milliseconds(300), &error)) << error; +} + +TEST_F(RunningInstanceTest, StartDetachedRunsTheProgramInItsOwnSession) { + const std::filesystem::path marker = directory_ / "marker"; + const std::filesystem::path program = directory_ / "program.sh"; + std::ofstream(program) << "#!/bin/sh\nps -o sid= -p $$ > '" << marker.string() + << ".tmp' && mv '" << marker.string() << ".tmp' '" + << marker.string() << "'\n"; + std::filesystem::permissions(program, std::filesystem::perms::owner_all); + + std::string error; + ASSERT_TRUE(StartDetached(program, &error)) << error; + for (int attempt = 0; attempt < 100 && !std::filesystem::exists(marker); + ++attempt) { + std::this_thread::sleep_for(milliseconds(20)); + } + ASSERT_TRUE(std::filesystem::exists(marker)); + std::ifstream input(marker); + pid_t session = 0; + input >> session; + EXPECT_NE(session, getsid(0)); +} + +TEST_F(RunningInstanceTest, ReadsHowAProcessWasStarted) { + holder_ = fork(); + ASSERT_GE(holder_, 0); + if (holder_ == 0) { + char* const arguments[] = {const_cast("sleep"), + const_cast("30"), nullptr}; + char* const environment[] = {const_cast("NIGHTCAP_TEST=1"), + nullptr}; + execve("/bin/sleep", arguments, environment); + _exit(127); + } + std::optional command; + for (int attempt = 0; attempt < 100; ++attempt) { + command = ReadLaunchCommand(holder_); + if (command.has_value() && !command->arguments.empty() && + command->arguments.front() == "sleep") { + break; + } + std::this_thread::sleep_for(milliseconds(20)); + } + ASSERT_TRUE(command.has_value()); + EXPECT_EQ(command->arguments, (Strings{"sleep", "30"})); + EXPECT_EQ(command->environment, (Strings{"NIGHTCAP_TEST=1"})); + EXPECT_FALSE(ReadLaunchCommand(-1).has_value()); +} + +TEST_F(RunningInstanceTest, StartDetachedPassesArgumentsAndEnvironment) { + const std::filesystem::path marker = directory_ / "marker"; + const std::filesystem::path program = directory_ / "program.sh"; + std::ofstream(program) << "#!/bin/sh\nprintf '%s|%s|%s' \"$0\" \"$1\" \"$X\" > '" + << marker.string() << ".tmp' && mv '" + << marker.string() << ".tmp' '" << marker.string() + << "'\n"; + std::filesystem::permissions(program, std::filesystem::perms::owner_all); + + LaunchCommand command; + command.arguments = {"renamed", "first"}; + command.environment = {"X=yes", "PATH=/usr/bin:/bin"}; + std::string error; + ASSERT_TRUE(StartDetached(program, &error, command)) << error; + for (int attempt = 0; attempt < 100 && !std::filesystem::exists(marker); + ++attempt) { + std::this_thread::sleep_for(milliseconds(20)); + } + ASSERT_TRUE(std::filesystem::exists(marker)); + std::string text; + std::getline(std::ifstream(marker), text); + // A script sees its own path as $0, whatever the first argument says. + EXPECT_EQ(text, program.string() + "|first|yes"); +} + +TEST_F(RunningInstanceTest, StartDetachedReportsAProgramThatCannotRun) { + std::string error; + EXPECT_FALSE(StartDetached(directory_ / "missing", &error)); + EXPECT_NE(error.find("missing"), std::string::npos) << error; +} + +} // namespace +} // namespace runtime +} // namespace mocktail diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index cdea9593..07b929f4 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -14,6 +14,7 @@ #include "runtime/environment.h" #include "runtime/runtime_config_file.h" #include "services/client_settings_service.h" +#include "update/update_config.h" namespace mocktail { namespace ui { @@ -41,6 +42,18 @@ const Setting& Find(std::string_view key) { return missing; } +// A text each text row accepts. +std::string TextSample(std::string_view key) { + if (key == "runtime.roblox_library") return "/opt/roblox/libroblox.so"; + if (key == "network.ca_bundle") return "/etc/ssl/custom.pem"; + if (key == "integrations.fleasion.ca_certificate") return "/etc/ssl/ca.crt"; + if (key == "integrations.discord_rpc.application_id") { + return "123456789012345678"; + } + if (key == "network.proxy") return "127.0.0.1:8080"; + return "Custom: {place_name} #1"; +} + class SettingsModelTest : public testing::Test { protected: void SetUp() override { @@ -103,7 +116,56 @@ TEST_F(SettingsModelTest, PagesListEverySettingOnce) { } EXPECT_EQ(titles, (std::vector{ "Graphics", "Input", "Performance", "Audio", - "Discord", "Advanced"})); + "Network", "Discord", "Fleasion", "Advanced"})); +} + +TEST_F(SettingsModelTest, EveryConfigKeyHasARow) { + // Every key the loader and the updater accept, and the row that sets it. + std::vector> keys = { + {"device", "device"}, + {"device.type", "device"}, + {"compatibility.desktop_playability", "device"}, + {"input.touch_enabled", "device.touch"}, + {"network.proxy_host", "network.proxy"}, + {"network.proxy_port", "network.proxy"}, + }; + for (const std::string_view key : { + "device.platform", "device.name", "device.manufacturer", + "device.model", "device.brand", "device.code", "device.sku", + "device.soc_model", "device.touch", "device.mouse", + "device.keyboard", "runtime.headless", "runtime.roblox_library", + "appearance.theme", "graphics.backend", "graphics.frame_rate_limit", + "graphics.vsync", "performance.multithreaded_rendering", + "performance.physics_worker_mode", "performance.memory_limit_mb", + "performance.gamemode", "audio.output_device", "audio.input_device", + "window.width", "window.height", "window.title", "window.high_dpi", + "input.raw_mouse", "network.use_system_proxy", "network.ca_bundle", + "integrations.fleasion.enabled", "integrations.fleasion.proxy_mode", + "integrations.fleasion.proxy_port", + "integrations.fleasion.ca_certificate", + "integrations.discord_rpc.enabled", + "integrations.discord_rpc.show_place_name", + "integrations.discord_rpc.show_elapsed_time", + "integrations.discord_rpc.application_id", + "integrations.discord_rpc.join.enabled", + "integrations.discord_rpc.join.public_servers_only", + "integrations.discord_rpc.join.button_label", + "integrations.discord_rpc.text.browsing", + "integrations.discord_rpc.text.joining", + "integrations.discord_rpc.text.playing", + "integrations.discord_rpc.text.state", + "integrations.discord_rpc.text.unknown_place", + "integrations.discord_rpc.text.title", + "integrations.discord_rpc.images.large", + "integrations.discord_rpc.images.large_text", + "integrations.discord_rpc.images.small", + "integrations.discord_rpc.images.small_text", "updates.automatic", + "updates.source", "updates.launch_after_update"}) { + keys.emplace_back(key, key); + } + for (const auto& [key, row] : keys) { + EXPECT_EQ(Find(row).key, row) << key; + } } TEST_F(SettingsModelTest, PagesOfOneSectionAreNextToEachOther) { @@ -162,24 +224,33 @@ TEST_F(SettingsModelTest, EveryValueTheWindowOffersSavesAndLoads) { if (setting.kind == SettingKind::kEditor) { continue; } - if (setting.kind == SettingKind::kText) { - for (const std::string value : - {std::string("Custom: {place_name} #1"), - std::string(setting.default_value)}) { - ASSERT_TRUE(document.SetText(setting.key, value, &error)) - << setting.key << " = " << value << ": " << error; - EXPECT_EQ(document.Value(setting.key), value) << setting.key; - const runtime::RuntimeConfigLoadResult loaded = LoadFile(); - ASSERT_TRUE(loaded) << setting.key << " = " << value << ": " - << loaded.error; - } + if (setting.kind == SettingKind::kText || + setting.kind == SettingKind::kFile) { + const std::string value = TextSample(setting.key); + ASSERT_TRUE(document.SetText(setting.key, value, &error)) + << setting.key << " = " << value << ": " << error; + EXPECT_EQ(document.Value(setting.key), value) << setting.key; + runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << setting.key << " = " << value << ": " + << loaded.error; + // A text with no default goes back to unset. + ASSERT_TRUE(setting.default_value.empty() + ? document.Set(setting.key, "", &error) + : document.SetText(setting.key, + setting.default_value, &error)) + << setting.key << ": " << error; + loaded = LoadFile(); + ASSERT_TRUE(loaded) << setting.key << ": " << loaded.error; continue; } + // Each row ends on its default, so the next rows start from a file + // Nightcap loads. std::vector values; if (setting.kind == SettingKind::kNumber) { - values = {NumberValue(4096), NumberValue(0)}; + values = {NumberValue(4096), std::string(setting.default_value)}; } else if (setting.kind == SettingKind::kToggle) { - values = {"true", "false"}; + const bool on = setting.default_value == "true"; + values = {on ? "false" : "true", on ? "true" : "false"}; } else if (setting.kind == SettingKind::kFrameRate) { for (const FrameRateMode mode : {FrameRateMode::kDisplay, FrameRateMode::kUnlimited, @@ -190,6 +261,7 @@ TEST_F(SettingsModelTest, EveryValueTheWindowOffersSavesAndLoads) { for (const SettingChoice& choice : setting.choices) { values.emplace_back(choice.value); } + values.emplace_back(setting.default_value); } for (const std::string& value : values) { ASSERT_TRUE(document.Set(setting.key, value, &error)) @@ -757,6 +829,305 @@ TEST_F(SettingsModelTest, FrameRateReadsEveryForm) { EXPECT_EQ(FrameRateValue({FrameRateMode::kCustom, 0}), "120"); } +TEST_F(SettingsModelTest, TheDevicePresetKeepsTheFileLoadable) { + const Setting& device = Find("device"); + ASSERT_EQ(device.kind, SettingKind::kChoice); + struct Case { + std::string_view file; + std::string_view shown; + // What else the file must still say once the preset is console. + std::string_view kept; + }; + for (const Case& test : { + Case{"device: pc\n", "pc", ""}, + Case{"device:\n type: mobile\n model: Tablet\n", "mobile", + "model: Tablet"}, + Case{"compatibility:\n desktop_playability: false\n", + "mobile-pixel-7", ""}, + Case{"input:\n touch_enabled: true\n", "", ""}, + Case{"device:\n type: pc\n touch: false\n mouse: false\n", "pc", + "type: console-ps5"}, + }) { + SCOPED_TRACE(std::string(test.file)); + Write(test.file); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_EQ(document.Value(device.key), test.shown); + EXPECT_NE(ChoiceIndex(device, document.Value(device.key)), kNoChoice); + + ASSERT_TRUE(document.Set(device.key, "console-ps5", &error)) + << error << "\n" << Read(); + EXPECT_EQ(document.Value(device.key), "console-ps5"); + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error << "\n" << Read(); + EXPECT_EQ(loaded.config.device_profile().name, "console-ps5") << Read(); + EXPECT_NE(Read().find(test.kept), std::string::npos) << Read(); + // The switches of the old preset are left only as comments. + for (const std::string_view key : + {" desktop_playability:", " touch_enabled:", " touch:", + " mouse:", " keyboard:"}) { + EXPECT_EQ(Read().find(key), std::string::npos) << key << "\n" << Read(); + } + EXPECT_EQ(document.Value("device.touch"), "false"); + EXPECT_EQ(document.Value("device.keyboard"), "true"); + } +} + +TEST_F(SettingsModelTest, DeviceDetailsTurnAPresetIntoADeviceBlock) { + Write("device: mobile\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_EQ(document.Value("device.model"), ""); + ASSERT_TRUE(document.SetText("device.model", "Tablet", &error)) << error; + EXPECT_EQ(document.Value("device.model"), "Tablet"); + EXPECT_EQ(document.Value("device"), "mobile"); + runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error << "\n" << Read(); + EXPECT_EQ(loaded.config.device_profile().model, "Tablet"); + EXPECT_EQ(loaded.config.device_profile().device_class, + runtime::DeviceClass::kMobile); + + ASSERT_TRUE(document.Set("device.model", "", &error)) << error; + loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error << "\n" << Read(); + EXPECT_EQ(loaded.config.device_profile().name, "mobile-pixel-7"); +} + +TEST_F(SettingsModelTest, MouseAndKeyboardSwitchesShowAndSetTheDevice) { + for (const std::string_view key : {"device.mouse", "device.keyboard"}) { + SCOPED_TRACE(std::string(key)); + std::filesystem::remove(file_); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + const Setting& setting = Find(key); + ASSERT_EQ(setting.kind, SettingKind::kToggle); + EXPECT_TRUE(ToggleState(setting, document.Value(key))); + + ASSERT_TRUE(document.Set(key, "false", &error)) << error; + EXPECT_FALSE(ToggleState(setting, document.Value(key))); + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error << "\n" << Read(); + const runtime::InputCapabilityConfig& input = + loaded.config.input_capabilities(); + EXPECT_EQ(key == "device.mouse" ? input.mouse_enabled + : input.keyboard_enabled, + false); + EXPECT_EQ(loaded.config.device_profile().name, "pc-windows-11"); + } +} + +TEST_F(SettingsModelTest, TheFixedProxyIsOneHostAndPortRow) { + const Setting& proxy = Find("network.proxy"); + ASSERT_EQ(proxy.kind, SettingKind::kText); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_FALSE(document.IsSet(proxy.key)); + + ASSERT_TRUE(document.SetText(proxy.key, "proxy.lan:3128", &error)) << error; + EXPECT_TRUE(document.IsSet(proxy.key)); + EXPECT_EQ(document.Value(proxy.key), "proxy.lan:3128"); + runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error; + ASSERT_TRUE(loaded.config.network_proxy().has_value()); + EXPECT_EQ(loaded.config.network_proxy()->host, "proxy.lan"); + EXPECT_EQ(loaded.config.network_proxy()->port, 3128); + + const std::string before = Read(); + for (const std::string_view refused : {"proxy.lan", "proxy.lan:", ":80"}) { + EXPECT_FALSE(document.SetText(proxy.key, refused, &error)) << refused; + EXPECT_FALSE(error.empty()); + EXPECT_EQ(Read(), before); + } + + ASSERT_TRUE(document.Set(proxy.key, "", &error)) << error; + EXPECT_FALSE(document.IsSet(proxy.key)); + loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_FALSE(loaded.config.network_proxy().has_value()); +} + +TEST_F(SettingsModelTest, NumberRowsKeepToTheRangeTheLoaderAccepts) { + EXPECT_EQ(Find("window.width").minimum, 1); + EXPECT_EQ(Find("window.height").minimum, 1); + EXPECT_EQ(Find("integrations.fleasion.proxy_port").minimum, 1); + EXPECT_EQ(Find("integrations.fleasion.proxy_port").maximum, 65535); + EXPECT_EQ(Find("performance.memory_limit_mb").minimum, 0); +} + +TEST_F(SettingsModelTest, TheNewRowsReachTheLoadedConfig) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + ASSERT_TRUE(document.Set("window.width", "1600", &error)) << error; + ASSERT_TRUE(document.SetText("window.title", "Nightcap", &error)) << error; + ASSERT_TRUE(document.Set("runtime.headless", "true", &error)) << error; + ASSERT_TRUE(document.Set("network.use_system_proxy", "true", &error)) + << error; + ASSERT_TRUE(document.Set("integrations.fleasion.proxy_mode", "hosts", &error)) + << error; + ASSERT_TRUE(document.SetText("integrations.discord_rpc.application_id", + "123456789012345678", &error)) + << error; + ASSERT_TRUE(document.Set("updates.source", "auto", &error)) << error; + ASSERT_TRUE(document.Set("updates.launch_after_update", "true", &error)) + << error; + + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_EQ(loaded.config.window().width, 1600); + EXPECT_EQ(loaded.config.window().title, "Nightcap"); + EXPECT_TRUE(loaded.config.headless()); + EXPECT_TRUE(loaded.config.use_system_proxy()); + EXPECT_EQ(loaded.config.fleasion_proxy_mode(), "hosts"); + EXPECT_EQ(loaded.config.discord_rpc().application_id, "123456789012345678"); + const update::UpdateConfigResult updates = update::LoadUpdateConfig(file_); + ASSERT_TRUE(updates) << updates.error; + EXPECT_EQ(updates.config.source, "auto"); + EXPECT_TRUE(updates.config.launch_after_update); +} + +// A value for `setting` other than its default, or nothing when the row +// cannot take one here. +std::optional ChangedSample(const Setting& setting) { + switch (setting.kind) { + case SettingKind::kToggle: + return ToggleState(setting, setting.default_value) ? "false" : "true"; + case SettingKind::kChoice: + case SettingKind::kDevice: + for (const SettingChoice& choice : setting.choices) { + if (!choice.value.empty() && + ChoiceIndex(setting, choice.value) != + ChoiceIndex(setting, setting.default_value)) { + return std::string(choice.value); + } + } + return std::nullopt; + case SettingKind::kFrameRate: + return "144"; + case SettingKind::kNumber: { + const double value = std::stod(std::string(setting.default_value)); + return NumberValue(value + setting.step <= setting.maximum + ? value + setting.step + : value - setting.step); + } + case SettingKind::kText: + case SettingKind::kFile: + return TextSample(setting.key); + case SettingKind::kEditor: + case SettingKind::kFolder: + return std::nullopt; + } + return std::nullopt; +} + +TEST_F(SettingsModelTest, ANewFileHasNoChangedRows) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + for (const SettingsPage& page : SettingsPages()) { + for (const SettingsGroup& group : page.groups) { + for (const Setting& setting : group.settings) { + EXPECT_FALSE(document.IsChanged(setting)) + << setting.key << " = " << document.Value(setting.key); + } + } + EXPECT_EQ(ChangedCount(page, document), 0u) << page.name; + } +} + +TEST_F(SettingsModelTest, AChangedRowIsCountedAndResetPutsItBack) { + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + for (const SettingsPage& page : SettingsPages()) { + for (const SettingsGroup& group : page.groups) { + for (const Setting& setting : group.settings) { + const std::optional value = ChangedSample(setting); + if (!value.has_value()) { + continue; + } + SCOPED_TRACE(std::string(setting.key) + " = " + *value); + const bool text = setting.kind == SettingKind::kText || + setting.kind == SettingKind::kFile; + ASSERT_TRUE(text ? document.SetText(setting.key, *value, &error) + : document.Set(setting.key, *value, &error)) + << error; + EXPECT_TRUE(document.IsChanged(setting)); + EXPECT_EQ(ChangedCount(page, document), 1u); + + ASSERT_TRUE(document.Reset(setting, &error)) << error; + EXPECT_FALSE(document.IsChanged(setting)) << document.Value(setting.key); + EXPECT_EQ(ChangedCount(page, document), 0u); + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + ASSERT_TRUE(loaded) << loaded.error << "\n" << Read(); + } + } + } +} + +TEST_F(SettingsModelTest, DeviceInputsCompareWithTheChosenDevice) { + Write("device: mobile\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_TRUE(document.IsChanged(Find("device"))); + // A phone has touch on and no mouse, so neither switch counts as changed. + EXPECT_FALSE(document.IsChanged(Find("device.touch"))); + EXPECT_FALSE(document.IsChanged(Find("device.mouse"))); + + ASSERT_TRUE(document.Set("device.mouse", "true", &error)) << error; + EXPECT_TRUE(document.IsChanged(Find("device.mouse"))); + ASSERT_TRUE(document.Reset(Find("device.mouse"), &error)) << error; + EXPECT_FALSE(document.IsChanged(Find("device.mouse"))); + EXPECT_EQ(document.Value("device"), "mobile"); +} + +TEST_F(SettingsModelTest, AnAliasOfTheDefaultIsNotAChange) { + Write("device: pc\ngraphics:\n vsync: false\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_FALSE(document.IsChanged(Find("device"))); +} + +TEST_F(SettingsModelTest, ResetTakesTheKeyOutOfTheFile) { + Write("graphics:\n vsync: off\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + for (const std::string_view key : {"window.title", "runtime.headless"}) { + SCOPED_TRACE(key); + const Setting& setting = Find(key); + ASSERT_TRUE(setting.kind == SettingKind::kText + ? document.SetText(key, "Custom", &error) + : document.Set(key, "true", &error)) + << error; + ASSERT_TRUE(document.Reset(setting, &error)) << error; + EXPECT_FALSE(document.IsSet(key)) << Read(); + } +} + +TEST_F(SettingsModelTest, ResetTakesADeviceInputSwitchOut) { + Write("device:\n type: pc\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + for (const std::string_view key : + {"device.touch", "device.mouse", "device.keyboard"}) { + SCOPED_TRACE(key); + const std::string flipped = + document.Value(key) == "true" ? "false" : "true"; + ASSERT_TRUE(document.Set(key, flipped, &error)) << error; + ASSERT_TRUE(document.Reset(Find(key), &error)) << error; + EXPECT_FALSE(document.IsSet(key)) << Read(); + EXPECT_FALSE(document.IsSet("input.touch_enabled")) << Read(); + } +} + } // namespace } // namespace ui } // namespace mocktail diff --git a/tests/single_instance_lock_test.cc b/tests/single_instance_lock_test.cc index 25ec5434..28216c10 100644 --- a/tests/single_instance_lock_test.cc +++ b/tests/single_instance_lock_test.cc @@ -156,6 +156,14 @@ TEST_F(SingleInstanceLockTest, ReleasesLockWithOwnerLifetime) { EXPECT_TRUE(replacement.acquired()) << replacement.error(); } +TEST_F(SingleInstanceLockTest, NamesTheLockAndProgramOfThisInstallation) { + const SingleInstanceLock lock = SingleInstanceLock::AcquireForCurrentUser(); + EXPECT_EQ(CurrentUserInstanceLockPath(), lock.path()); + std::error_code error; + EXPECT_EQ(CurrentExecutableIdentityPath(), + std::filesystem::weakly_canonical("/proc/self/exe", error)); +} + TEST_F(SingleInstanceLockTest, UsesStableExecutableNamespace) { const std::string first = CurrentExecutableInstanceKey(); const std::string second = CurrentExecutableInstanceKey(); From 42747841e4ea739a16fd89d9dd36909eb9e93efb Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 11:44:38 -0500 Subject: [PATCH 22/30] update(ui): polish the settings window - Grey out rows a switched-off block or the system proxy leaves without effect. - Show "Off" for a memory limit of 0. - Explain the Discord placeholders, the application ID, the CA bundle and the Roblox library under their group titles. - Merge the one-row mouse and touch groups, drop the repeated Fleasion heading, and hide the pencil on rows with a file picker. --- include/ui/settings_model.h | 12 +++++ src/ui/mocktail_settings.cc | 52 ++++++++++++++++++++- src/ui/settings_model.cc | 87 ++++++++++++++++++++++++++++++------ tests/settings_model_test.cc | 45 +++++++++++++++++++ 4 files changed, 181 insertions(+), 15 deletions(-) diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index 0d470e66..a5507b65 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -54,6 +54,8 @@ struct Setting { double minimum = 0; double maximum = 1048576; double step = 256; + // What a kNumber row shows in place of 0, when 0 turns the setting off. + std::string_view zero_label; }; struct SettingsGroup { @@ -64,6 +66,9 @@ struct SettingsGroup { // With this, the first setting, a kToggle, is the expander row's own // switch, and the other settings apply only while it is on. bool expander_switch = false; + // Shown under the title. Text rows have no subtitle, so their format is + // explained here. + std::string_view description; }; struct SettingsPage { @@ -152,6 +157,11 @@ class SettingsDocument { bool SetText(std::string_view key, std::string_view value, std::string* error); + // False while an `enabled` switch of a block `key` is in, such as + // integrations.discord_rpc.enabled, is off. The switch itself stays active. + // An empty fixed proxy is inactive while the system proxy is on. + bool IsActive(std::string_view key) const; + // Mouse, keyboard and touch compare with what the file's device turns on. bool IsChanged(const Setting& setting) const; @@ -179,6 +189,8 @@ class SettingsDocument { bool empty_unsets) const; bool Save(runtime::RuntimeConfigEdit edit, std::string* error); std::string DefaultValue(const Setting& setting) const; + // True for a key that is not a listed switch. + bool IsSwitchedOn(std::string_view key) const; std::filesystem::path config_file_; std::string text_; diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index e3ac8638..a663f53c 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -122,6 +122,20 @@ struct SettingsWindow { std::string Text(std::string_view text) { return std::string(text); } +bool HideDescendant(GtkWidget* parent, const char* name) { + for (GtkWidget* child = gtk_widget_get_first_child(parent); child != nullptr; + child = gtk_widget_get_next_sibling(child)) { + if (gtk_widget_has_css_class(child, name)) { + gtk_widget_set_visible(child, FALSE); + return true; + } + if (HideDescendant(child, name)) { + return true; + } + } + return false; +} + void SetChoices(Row* row) { GtkStringList* labels = gtk_string_list_new(nullptr); for (const auto& choice : row->setting->choices) { @@ -250,10 +264,13 @@ void RefreshRow(Row* row, bool keep_edit) { } // Shows the reset button of each row that differs from its default, and the -// counts of those rows. +// counts of those rows. Rows a switched-off block leaves without effect are +// greyed out. void ShowChanged(SettingsWindow* window) { std::vector counts(window->badges.size(), 0); for (Row& row : window->rows) { + gtk_widget_set_sensitive(row.widget, + window->document.IsActive(row.setting->key)); const bool changed = window->document.IsChanged(*row.setting); if (changed && row.page < counts.size()) { ++counts[row.page]; @@ -458,6 +475,26 @@ void OnNumberChanged(GObject* object, GParamSpec*, gpointer user_data) { adw_spin_row_get_value(ADW_SPIN_ROW(object)))); } +gboolean OnNumberOutput(AdwSpinRow* spin, gpointer user_data) { + const Row* row = static_cast(user_data); + if (adw_spin_row_get_value(spin) != 0) { + return FALSE; + } + gtk_editable_set_text(GTK_EDITABLE(spin), + Text(row->setting->zero_label).c_str()); + return TRUE; +} + +gint OnNumberInput(AdwSpinRow* spin, double* value, gpointer user_data) { + const Row* row = static_cast(user_data); + if (g_ascii_strcasecmp(gtk_editable_get_text(GTK_EDITABLE(spin)), + Text(row->setting->zero_label).c_str()) != 0) { + return FALSE; + } + *value = 0; + return TRUE; +} + void OnTextApplied(AdwEntryRow* entry, gpointer user_data) { Row* row = static_cast(user_data); SettingsWindow* window = row->window; @@ -847,6 +884,13 @@ void AddRow(SettingsWindow* window, GtkWidget* container, setting.maximum, setting.step); g_signal_connect(row.widget, "notify::value", G_CALLBACK(OnNumberChanged), &row); + if (!setting.zero_label.empty()) { + // A numeric row drops the label's letters. + adw_spin_row_set_numeric(ADW_SPIN_ROW(row.widget), FALSE); + g_signal_connect(row.widget, "output", G_CALLBACK(OnNumberOutput), + &row); + g_signal_connect(row.widget, "input", G_CALLBACK(OnNumberInput), &row); + } break; case SettingKind::kText: case SettingKind::kFile: @@ -860,6 +904,8 @@ void AddRow(SettingsWindow* window, GtkWidget* container, gtk_widget_set_tooltip_text(row.pick, "Choose a file"); gtk_widget_set_valign(row.pick, GTK_ALIGN_CENTER); gtk_widget_add_css_class(row.pick, "flat"); + // The picker stands in for the pencil. + HideDescendant(row.widget, "edit-icon"); adw_entry_row_add_suffix(ADW_ENTRY_ROW(row.widget), row.pick); g_signal_connect(row.pick, "clicked", G_CALLBACK(OnPickFile), &row); } @@ -1089,6 +1135,10 @@ GtkWidget* BuildPages(SettingsWindow* window) { GtkWidget* group_widget = adw_preferences_group_new(); adw_preferences_group_set_title(ADW_PREFERENCES_GROUP(group_widget), Text(group.title).c_str()); + adw_preferences_group_set_description( + ADW_PREFERENCES_GROUP(group_widget), + group.description.empty() ? nullptr + : Text(group.description).c_str()); GtkWidget* container = group_widget; std::size_t first = 0; if (!group.expander.empty()) { diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 933046f9..df3c2614 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -454,7 +454,7 @@ const std::vector& SettingsPages() { "input-mouse-symbolic", "Display & input", { - {"Mouse", + {"Mouse and touch", { {"input.raw_mouse", "Raw mouse input", @@ -464,9 +464,6 @@ const std::vector& SettingsPages() { {}, "false", {}}, - }}, - {"Touch", - { {kTouchKey, "Touch input", "Turn on for a touch screen or tablet.", @@ -542,11 +539,15 @@ const std::vector& SettingsPages() { { {"performance.memory_limit_mb", "Memory limit", - "Hard cap in MiB for Nightcap and Roblox. 0 turns it off.", + "Hard cap in MiB for Nightcap and Roblox.", SettingKind::kNumber, {}, "0", - {}}, + {}, + 0, + 1048576, + 256, + "Off"}, }}, }}, {"audio", @@ -581,8 +582,8 @@ const std::vector& SettingsPages() { { {"network.use_system_proxy", "Use the system proxy", - "Follow the desktop's HTTP or SOCKS5 proxy. Leave the fixed " - "proxy empty while this is on.", + "Follow the desktop's HTTP or SOCKS5 proxy in place of a " + "fixed one.", SettingKind::kToggle, {}, "false", @@ -594,7 +595,12 @@ const std::vector& SettingsPages() { { {"network.ca_bundle", "CA bundle (PEM)", "", SettingKind::kFile, {}, "", {}}, - }}, + }, + {}, + {}, + false, + "The CA certificates Roblox trusts, as one PEM file. Empty " + "uses the system's certificates."}, }}, {"discord", "Discord", @@ -618,7 +624,11 @@ const std::vector& SettingsPages() { {}}, {"integrations.discord_rpc.application_id", "Application ID", "", SettingKind::kText, {}, "", {}}, - }}, + }, + {}, + {}, + false, + "Empty Application ID uses Nightcap's own Discord app."}, {"Join button", { {"integrations.discord_rpc.join.enabled", @@ -654,7 +664,13 @@ const std::vector& SettingsPages() { {"integrations.discord_rpc.text.unknown_place", "Unknown experience", "", SettingKind::kText, {}, "Unknown experience", {}}, - }}, + }, + {}, + {}, + false, + "{place_name}, {creator_name} and {place_icon} are filled in " + "from the experience. A line whose value is missing is left " + "out."}, {"Images", { {"integrations.discord_rpc.images.large", "Large image", "", @@ -667,14 +683,20 @@ const std::vector& SettingsPages() { {"integrations.discord_rpc.images.small_text", "Small image text", "", SettingKind::kText, {}, "Roblox", {}}, - }}, + }, + {}, + {}, + false, + "An image key from the Discord app, or {place_icon} for the " + "experience's icon. The text shows on hover and can use " + "{place_name}."}, }}, {"fleasion", "Fleasion", "application-x-addon-symbolic", "Integrations", { - {"Fleasion", + {"", { {"integrations.fleasion.enabled", "Use Fleasion", "", SettingKind::kToggle, {}, "false", {}}, @@ -775,7 +797,12 @@ const std::vector& SettingsPages() { {}}, {"runtime.roblox_library", "Roblox library (libroblox.so)", "", SettingKind::kFile, {}, "", {}}, - }}, + }, + {}, + {}, + false, + "Leave the Roblox library empty to run the verified build " + "Nightcap downloads."}, {"Files", { {"config_folder", @@ -962,6 +989,38 @@ std::string SettingsDocument::DefaultValue(const Setting& setting) const { return std::string(setting.default_value); } +bool SettingsDocument::IsSwitchedOn(std::string_view key) const { + for (const SettingsPage& page : SettingsPages()) { + for (const SettingsGroup& group : page.groups) { + for (const Setting& setting : group.settings) { + if (setting.key == key && setting.kind == SettingKind::kToggle) { + return ToggleState(setting, Value(key)); + } + } + } + } + return true; +} + +bool SettingsDocument::IsActive(std::string_view key) const { + // The loader refuses a fixed proxy next to the system one. + if (key == kProxyKey && Value(kProxyKey).empty() && + IsSwitchedOn("network.use_system_proxy")) { + return false; + } + for (std::size_t dot = key.find('.'); dot != std::string_view::npos; + dot = key.find('.', dot + 1)) { + const std::string switch_key = std::string(key.substr(0, dot)) + ".enabled"; + if (switch_key == key) { + break; + } + if (!IsSwitchedOn(switch_key)) { + return false; + } + } + return true; +} + bool SettingsDocument::IsChanged(const Setting& setting) const { const std::string fallback = DefaultValue(setting); std::string value = Value(setting.key); diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index 07b929f4..dbcb005a 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -1128,6 +1128,51 @@ TEST_F(SettingsModelTest, ResetTakesADeviceInputSwitchOut) { } } +TEST_F(SettingsModelTest, ASwitchedOffBlockLeavesItsOtherRowsInactive) { + Write("integrations:\n discord_rpc:\n join:\n enabled: false\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_TRUE(document.IsActive("integrations.discord_rpc.join.enabled")); + EXPECT_FALSE(document.IsActive("integrations.discord_rpc.join.button_label")); + EXPECT_TRUE(document.IsActive("integrations.discord_rpc.text.title")); + EXPECT_TRUE(document.IsActive("graphics.vsync")); + + ASSERT_TRUE(document.Set("integrations.discord_rpc.enabled", "false", &error)) + << error; + EXPECT_TRUE(document.IsActive("integrations.discord_rpc.enabled")); + EXPECT_FALSE(document.IsActive("integrations.discord_rpc.join.enabled")); + EXPECT_FALSE(document.IsActive("integrations.discord_rpc.text.title")); +} + +TEST_F(SettingsModelTest, TheSystemProxyLeavesAnEmptyFixedProxyInactive) { + Write("network:\n use_system_proxy: true\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_FALSE(document.IsActive("network.proxy")); + + // A fixed proxy stays editable, so it can be cleared. + Write("network:\n proxy_host: 127.0.0.1\n proxy_port: 8080\n"); + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_TRUE(document.IsActive("network.proxy")); +} + +TEST(SettingsPagesTest, TextGroupsExplainTheirPlaceholders) { + for (const SettingsPage& page : SettingsPages()) { + for (const SettingsGroup& group : page.groups) { + if (group.title != "Text" && group.title != "Images") { + continue; + } + SCOPED_TRACE(group.title); + for (const std::string_view placeholder : + {"{place_name}", "{place_icon}"}) { + EXPECT_NE(group.description.find(placeholder), std::string_view::npos); + } + } + } +} + } // namespace } // namespace ui } // namespace mocktail From 852f3776be92701d69fb8fd26c68291ead9ec7ba Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 12:11:48 -0500 Subject: [PATCH 23/30] fix(ui): focus a switched-off expander from the changed count --- src/ui/mocktail_settings.cc | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index a663f53c..2465005f 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -1181,14 +1181,22 @@ void OnChangedChipClicked(GtkButton*, gpointer user_data) { gtk_list_box_get_row_at_index(window->sidebar, static_cast(row.page))); adw_navigation_split_view_set_show_content(window->split, TRUE); - // A row inside a closed expander cannot take focus. + // A row inside a closed expander cannot take focus. One whose switch is + // off cannot open, so its own row takes the focus. + GtkWidget* target = row.widget; for (GtkWidget* parent = gtk_widget_get_parent(row.widget); parent != nullptr; parent = gtk_widget_get_parent(parent)) { - if (ADW_IS_EXPANDER_ROW(parent)) { - adw_expander_row_set_expanded(ADW_EXPANDER_ROW(parent), TRUE); + if (!ADW_IS_EXPANDER_ROW(parent)) { + continue; + } + AdwExpanderRow* expander = ADW_EXPANDER_ROW(parent); + if (adw_expander_row_get_enable_expansion(expander)) { + adw_expander_row_set_expanded(expander, TRUE); + } else { + target = parent; } } - gtk_widget_grab_focus(row.widget); + gtk_widget_grab_focus(target); return; } } From 7aeded2f61832003c72b20601c3e16ef8f92df1f Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 13:07:57 -0500 Subject: [PATCH 24/30] update(ui): drop the changed counts and always show reset buttons - Remove the changed chip and the sidebar counts. - Show a row's reset button whenever it differs from its default. - Write an empty mapping when a change leaves config.yaml without keys, so resetting the last setting no longer fails to load. --- CHANGELOG.md | 3 +- include/ui/settings_model.h | 3 - src/ui/mocktail_settings.cc | 103 ++--------------------------------- src/ui/settings_model.cc | 70 ++++++++++++++++++------ tests/settings_model_test.cc | 35 ++++++++++-- 5 files changed, 89 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ec3e7d..836283df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,7 @@ Release notes on GitHub are taken from the matching section here. is open when you change something, the window offers to restart it for you, with the same options and environment it was started with. It opens on the main menu. Every `config.yaml` setting has a row, including the device, the window size, the proxy, Fleasion and the update source. A setting you changed - from its default gets a reset button that shows when you point at it. The sidebar counts changed - settings per page, and its total jumps to the first one. + from its default gets a button that resets it. - An asset overlay folder, `~/.local/share/mocktail/asset_overlay`. A file there replaces the file Roblox ships at the same path, such as a cursor, a font or a sky texture, and it stays in place when Roblox updates. The layout matches Sober's `asset_overlay` folder. `MOCKTAIL_ASSET_OVERLAY_DIR` moves it. Thanks to diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index a5507b65..c20c05ae 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -198,9 +198,6 @@ class SettingsDocument { std::size_t saved_changes_ = 0; }; -std::size_t ChangedCount(const SettingsPage& page, - const SettingsDocument& document); - // A place in a text. Both numbers start at 1, and the column counts // characters, not bytes. struct TextPosition { diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 2465005f..8548bcf9 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -33,17 +33,6 @@ constexpr char kApplicationId[] = "io.github.CoderDayton.nightcap"; constexpr char kWindowStyle[] = R"( .navigation-sidebar > row { min-height: 44px; margin: 1px 6px; padding: 0 14px; } -.row-reset { opacity: 0; transition: opacity 150ms ease-in-out; } -row:hover .row-reset, row:focus-within .row-reset { opacity: 1; } -.count-badge { - min-width: 18px; min-height: 18px; padding: 0 6px; border-radius: 9px; - font-size: 0.75em; font-weight: 700; - background-color: alpha(@accent_bg_color, 0.25); color: @accent_color; -} -.changed-chip { - min-height: 26px; padding: 0 10px; border-radius: 13px; font-weight: 700; - background-color: alpha(@accent_bg_color, 0.2); color: @accent_color; -} .notice-bar { min-height: 54px; padding-right: 6px; background-color: @window_bg_color; box-shadow: inset 0 1px alpha(@window_fg_color, 0.1); @@ -65,8 +54,6 @@ struct Row { GtkWidget* pick = nullptr; // Whether `widget` is an expander row whose own switch is this toggle. bool expander_switch = false; - // The row's page, in SettingsPages() order. - std::size_t page = 0; // A value the file holds that the row does not list. It is shown as one // more choice so opening the window never changes it. std::string unlisted; @@ -113,11 +100,6 @@ struct SettingsWindow { // Set while rows are filled from the file, so that filling one is not // taken for a change by the user. bool refreshing = false; - // Each page's count of changed rows in the sidebar, in SettingsPages() - // order. - std::vector badges; - GtkWidget* changed_chip = nullptr; - GtkListBox* sidebar = nullptr; }; std::string Text(std::string_view text) { return std::string(text); } @@ -263,35 +245,17 @@ void RefreshRow(Row* row, bool keep_edit) { } } -// Shows the reset button of each row that differs from its default, and the -// counts of those rows. Rows a switched-off block leaves without effect are -// greyed out. +// Shows the reset button of each row that differs from its default. Rows a +// switched-off block leaves without effect are greyed out. void ShowChanged(SettingsWindow* window) { - std::vector counts(window->badges.size(), 0); for (Row& row : window->rows) { gtk_widget_set_sensitive(row.widget, window->document.IsActive(row.setting->key)); - const bool changed = window->document.IsChanged(*row.setting); - if (changed && row.page < counts.size()) { - ++counts[row.page]; - } if (row.reset != nullptr) { - gtk_widget_set_visible(row.reset, changed); + gtk_widget_set_visible(row.reset, + window->document.IsChanged(*row.setting)); } } - std::size_t total = 0; - for (std::size_t index = 0; index < window->badges.size(); ++index) { - const std::size_t count = counts[index]; - total += count; - gtk_label_set_text(GTK_LABEL(window->badges[index]), - std::to_string(count).c_str()); - gtk_widget_set_visible(window->badges[index], count > 0); - } - if (window->changed_chip != nullptr) { - gtk_button_set_label(GTK_BUTTON(window->changed_chip), - (std::to_string(total) + " changed").c_str()); - gtk_widget_set_visible(window->changed_chip, total > 0); - } } // Shows every row as the file has it. Text typed into a row and not saved @@ -832,7 +796,6 @@ GtkWidget* NewResetButton(Row* row) { gtk_widget_set_tooltip_text(button, "Reset to default"); gtk_widget_set_valign(button, GTK_ALIGN_CENTER); gtk_widget_add_css_class(button, "flat"); - gtk_widget_add_css_class(button, "row-reset"); gtk_widget_set_visible(button, FALSE); g_signal_connect(button, "clicked", G_CALLBACK(OnReset), row); return button; @@ -1009,12 +972,9 @@ GtkWidget* BuildSidebarTitle() { return box; } -void OnChangedChipClicked(GtkButton*, gpointer user_data); - GtkWidget* BuildSidebar(SettingsWindow* window) { GtkWidget* list = gtk_list_box_new(); gtk_widget_add_css_class(list, "navigation-sidebar"); - window->sidebar = GTK_LIST_BOX(list); gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_BROWSE); gtk_list_box_set_header_func(GTK_LIST_BOX(list), SetSidebarHeader, nullptr, nullptr); @@ -1026,13 +986,6 @@ GtkWidget* BuildSidebar(SettingsWindow* window) { gtk_label_set_xalign(GTK_LABEL(label), 0); gtk_widget_set_hexpand(label, TRUE); gtk_box_append(GTK_BOX(box), label); - GtkWidget* badge = gtk_label_new(""); - gtk_widget_add_css_class(badge, "count-badge"); - gtk_widget_set_valign(badge, GTK_ALIGN_CENTER); - gtk_widget_set_tooltip_text(badge, "Settings changed from the default"); - gtk_widget_set_visible(badge, FALSE); - gtk_box_append(GTK_BOX(box), badge); - window->badges.push_back(badge); gtk_list_box_append(GTK_LIST_BOX(list), box); } g_signal_connect(list, "row-selected", G_CALLBACK(OnPageSelected), window); @@ -1050,15 +1003,6 @@ GtkWidget* BuildSidebar(SettingsWindow* window) { GtkWidget* title = BuildSidebarTitle(); gtk_widget_set_margin_start(title, 4); adw_header_bar_pack_start(ADW_HEADER_BAR(header), title); - window->changed_chip = gtk_button_new(); - gtk_widget_add_css_class(window->changed_chip, "changed-chip"); - gtk_widget_set_valign(window->changed_chip, GTK_ALIGN_CENTER); - gtk_widget_set_tooltip_text(window->changed_chip, - "Go to the first changed setting"); - gtk_widget_set_visible(window->changed_chip, FALSE); - g_signal_connect(window->changed_chip, "clicked", - G_CALLBACK(OnChangedChipClicked), window); - adw_header_bar_pack_end(ADW_HEADER_BAR(header), window->changed_chip); GtkWidget* toolbar = adw_toolbar_view_new(); adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(toolbar), header); adw_toolbar_view_set_content(ADW_TOOLBAR_VIEW(toolbar), scrolled); @@ -1112,10 +1056,7 @@ GtkWidget* BuildNotice(SettingsWindow* window) { GtkWidget* BuildPages(SettingsWindow* window) { GtkWidget* stack = gtk_stack_new(); window->stack = GTK_STACK(stack); - const auto& pages = mocktail::ui::SettingsPages(); - for (std::size_t page_index = 0; page_index < pages.size(); ++page_index) { - const SettingsPage& page = pages[page_index]; - const std::size_t first_row = window->rows.size(); + for (const SettingsPage& page : mocktail::ui::SettingsPages()) { // A clamp of our own instead of AdwPreferencesPage, whose fixed 600 px // clamp leaves wide empty gutters. GtkWidget* groups = gtk_box_new(GTK_ORIENTATION_VERTICAL, 24); @@ -1161,46 +1102,12 @@ GtkWidget* BuildPages(SettingsWindow* window) { } gtk_box_append(GTK_BOX(groups), group_widget); } - for (std::size_t index = first_row; index < window->rows.size(); ++index) { - window->rows[index].page = page_index; - } gtk_stack_add_named(GTK_STACK(stack), page_widget, Text(page.name).c_str()); } return stack; } -void OnChangedChipClicked(GtkButton*, gpointer user_data) { - auto* window = static_cast(user_data); - for (Row& row : window->rows) { - if (!window->document.IsChanged(*row.setting)) { - continue; - } - gtk_list_box_select_row( - window->sidebar, - gtk_list_box_get_row_at_index(window->sidebar, - static_cast(row.page))); - adw_navigation_split_view_set_show_content(window->split, TRUE); - // A row inside a closed expander cannot take focus. One whose switch is - // off cannot open, so its own row takes the focus. - GtkWidget* target = row.widget; - for (GtkWidget* parent = gtk_widget_get_parent(row.widget); - parent != nullptr; parent = gtk_widget_get_parent(parent)) { - if (!ADW_IS_EXPANDER_ROW(parent)) { - continue; - } - AdwExpanderRow* expander = ADW_EXPANDER_ROW(parent); - if (adw_expander_row_get_enable_expansion(expander)) { - adw_expander_row_set_expanded(expander, TRUE); - } else { - target = parent; - } - } - gtk_widget_grab_focus(target); - return; - } -} - // The sidebar of pages beside the settings of the chosen one. GtkWidget* BuildSplitView(SettingsWindow* window, AdwApplicationWindow* window_widget) { diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index df3c2614..78f2c921 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -59,6 +59,41 @@ std::string DevicePreset(std::string_view text) { } // What the file's device turns on for the input `key`. +bool HasYamlContent(std::string_view text) { + std::size_t start = 0; + while (start < text.size()) { + const std::size_t end = std::min(text.find('\n', start), text.size()); + const std::size_t first = text.find_first_not_of(" \t\r", start); + if (first < end && text[first] != '#') { + return true; + } + start = end + 1; + } + return false; +} + +// Keys cannot be added next to the `{}` that Save writes into an empty file. +std::string WithoutEmptyMapping(std::string_view text) { + std::string kept; + bool dropped = false; + std::size_t start = 0; + while (start < text.size()) { + const std::size_t end = std::min(text.find('\n', start), text.size()); + std::string_view line = text.substr(start, end - start); + const std::size_t first = line.find_first_not_of(" \t\r"); + const std::size_t last = line.find_last_not_of(" \t\r"); + if (first == std::string_view::npos || line[first] == '#') { + kept.append(text.substr(start, std::min(end + 1, text.size()) - start)); + } else if (!dropped && line.substr(first, last - first + 1) == "{}") { + dropped = true; + } else { + return std::string(text); + } + start = end + 1; + } + return kept; +} + bool PresetInput(std::string_view text, std::string_view key) { const std::string preset = DevicePreset(text); const runtime::DeviceProfile* profile = runtime::FindDeviceProfile( @@ -1056,7 +1091,8 @@ bool SettingsDocument::Reset(const Setting& setting, std::string* error) { // The device's own inputs come back once the switch is out of the file. if (setting.key == kTouchKey || setting.key == kMouseKey || setting.key == kKeyboardKey) { - runtime::RuntimeConfigEdit edit = UnsetIfSet(text_, setting.key); + runtime::RuntimeConfigEdit edit = + UnsetIfSet(WithoutEmptyMapping(text_), setting.key); if (setting.key == kTouchKey) { edit = Then(std::move(edit), [](std::string_view current) { return UnsetIfSet(current, kTouchKeyWithoutDevice); @@ -1072,38 +1108,28 @@ bool SettingsDocument::Reset(const Setting& setting, std::string* error) { return Set(setting.key, "", error); } -std::size_t ChangedCount(const SettingsPage& page, - const SettingsDocument& document) { - std::size_t count = 0; - for (const SettingsGroup& group : page.groups) { - for (const Setting& setting : group.settings) { - count += document.IsChanged(setting) ? 1 : 0; - } - } - return count; -} - runtime::RuntimeConfigEdit SettingsDocument::Edit(std::string_view key, std::string_view value, bool empty_unsets) const { + const std::string text = WithoutEmptyMapping(text_); if (key == kTouchKey) { - return SetTouchInput(text_, value); + return SetTouchInput(text, value); } if (key == kDeviceKey) { - return SetDevicePreset(text_, value); + return SetDevicePreset(text, value); } if (key == kProxyKey) { - return SetProxy(text_, value); + return SetProxy(text, value); } if (value.empty() && empty_unsets) { - return runtime::UnsetRuntimeConfigValue(text_, key); + return runtime::UnsetRuntimeConfigValue(text, key); } if (IsDeviceDetail(key)) { - return Then(ToDeviceBlock(text_), [key, value](std::string_view block) { + return Then(ToDeviceBlock(text), [key, value](std::string_view block) { return runtime::SetRuntimeConfigString(block, key, value); }); } - return runtime::SetRuntimeConfigString(text_, key, value); + return runtime::SetRuntimeConfigString(text, key, value); } bool SettingsDocument::Undo(std::string* error) { @@ -1123,6 +1149,14 @@ bool SettingsDocument::Save(runtime::RuntimeConfigEdit edit, return false; } std::string edited = std::move(edit.text); + // Nightcap refuses a file without keys, which taking out the last one + // leaves. An empty mapping stands for every default. + if (!HasYamlContent(edited)) { + if (!edited.empty() && edited.back() != '\n') { + edited += '\n'; + } + edited += "{}\n"; + } if (edited == text_) { return true; } diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index dbcb005a..933772ef 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -1035,11 +1035,22 @@ TEST_F(SettingsModelTest, ANewFileHasNoChangedRows) { << setting.key << " = " << document.Value(setting.key); } } - EXPECT_EQ(ChangedCount(page, document), 0u) << page.name; } } -TEST_F(SettingsModelTest, AChangedRowIsCountedAndResetPutsItBack) { +std::size_t ChangedCount(const SettingsDocument& document) { + std::size_t count = 0; + for (const SettingsPage& page : SettingsPages()) { + for (const SettingsGroup& group : page.groups) { + for (const Setting& setting : group.settings) { + count += document.IsChanged(setting) ? 1 : 0; + } + } + } + return count; +} + +TEST_F(SettingsModelTest, AChangedRowIsTheOnlyOneAndResetPutsItBack) { SettingsDocument document(file_); std::string error; ASSERT_TRUE(document.Load(&error)) << error; @@ -1057,11 +1068,11 @@ TEST_F(SettingsModelTest, AChangedRowIsCountedAndResetPutsItBack) { : document.Set(setting.key, *value, &error)) << error; EXPECT_TRUE(document.IsChanged(setting)); - EXPECT_EQ(ChangedCount(page, document), 1u); + EXPECT_EQ(ChangedCount(document), 1u); ASSERT_TRUE(document.Reset(setting, &error)) << error; EXPECT_FALSE(document.IsChanged(setting)) << document.Value(setting.key); - EXPECT_EQ(ChangedCount(page, document), 0u); + EXPECT_EQ(ChangedCount(document), 0u); const runtime::RuntimeConfigLoadResult loaded = LoadFile(); ASSERT_TRUE(loaded) << loaded.error << "\n" << Read(); } @@ -1111,6 +1122,22 @@ TEST_F(SettingsModelTest, ResetTakesTheKeyOutOfTheFile) { } } +TEST_F(SettingsModelTest, ResetCanTakeOutTheLastKey) { + Write("graphics:\n vsync: off\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + ASSERT_TRUE(document.Reset(Find("graphics.vsync"), &error)) << error; + EXPECT_FALSE(document.IsChanged(Find("graphics.vsync"))) << Read(); + const runtime::RuntimeConfigLoadResult loaded = LoadFile(); + EXPECT_TRUE(loaded) << loaded.error << "\n" << Read(); + + ASSERT_TRUE(document.Set("graphics.vsync", "off", &error)) << error; + EXPECT_TRUE(document.IsChanged(Find("graphics.vsync"))) << Read(); + const runtime::RuntimeConfigLoadResult reloaded = LoadFile(); + EXPECT_TRUE(reloaded) << reloaded.error << "\n" << Read(); +} + TEST_F(SettingsModelTest, ResetTakesADeviceInputSwitchOut) { Write("device:\n type: pc\n"); SettingsDocument document(file_); From b7a9a02001f638fc700a46313c2008d6bb10467a Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 17:39:17 -0500 Subject: [PATCH 25/30] update(ui): grey out the Fleasion port under hosts routing - Grey out the proxy port while Fleasion routes through the hosts file. - Explain the empty Fleasion CA certificate under a Connection heading. - Say that both download sources pick the same sources for now. --- include/ui/settings_model.h | 3 ++- src/ui/settings_model.cc | 12 +++++++++--- tests/settings_model_test.cc | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index c20c05ae..3fe42efc 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -159,7 +159,8 @@ class SettingsDocument { // False while an `enabled` switch of a block `key` is in, such as // integrations.discord_rpc.enabled, is off. The switch itself stays active. - // An empty fixed proxy is inactive while the system proxy is on. + // An empty fixed proxy is inactive while the system proxy is on, and the + // Fleasion port while Fleasion routes through the hosts file. bool IsActive(std::string_view key) const; // Mouse, keyboard and touch compare with what the file's device turns on. diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 78f2c921..4d6da7a3 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -731,7 +731,7 @@ const std::vector& SettingsPages() { "application-x-addon-symbolic", "Integrations", { - {"", + {"Connection", { {"integrations.fleasion.enabled", "Use Fleasion", "", SettingKind::kToggle, {}, "false", {}}, @@ -753,7 +753,9 @@ const std::vector& SettingsPages() { }, "Use Fleasion", "Trust Fleasion's certificate without editing Roblox files.", - true}, + true, + "Empty CA certificate uses Fleasion's own, in " + "~/.config/Fleasion/proxy_ca."}, }}, {"advanced", "Advanced", @@ -783,7 +785,7 @@ const std::vector& SettingsPages() { {}}, {"updates.source", "Download source", - "Both start with APKPure today.", + "Both pick the same sources for now.", SettingKind::kChoice, { {"apk-pure", "APKPure"}, @@ -1043,6 +1045,10 @@ bool SettingsDocument::IsActive(std::string_view key) const { IsSwitchedOn("network.use_system_proxy")) { return false; } + if (key == "integrations.fleasion.proxy_port" && + Value("integrations.fleasion.proxy_mode") == "hosts") { + return false; + } for (std::size_t dot = key.find('.'); dot != std::string_view::npos; dot = key.find('.', dot + 1)) { const std::string switch_key = std::string(key.substr(0, dot)) + ".enabled"; diff --git a/tests/settings_model_test.cc b/tests/settings_model_test.cc index 933772ef..a0289846 100644 --- a/tests/settings_model_test.cc +++ b/tests/settings_model_test.cc @@ -1185,6 +1185,20 @@ TEST_F(SettingsModelTest, TheSystemProxyLeavesAnEmptyFixedProxyInactive) { EXPECT_TRUE(document.IsActive("network.proxy")); } +TEST_F(SettingsModelTest, HostsRoutingLeavesTheFleasionPortInactive) { + Write("integrations:\n fleasion:\n enabled: true\n"); + SettingsDocument document(file_); + std::string error; + ASSERT_TRUE(document.Load(&error)) << error; + EXPECT_TRUE(document.IsActive("integrations.fleasion.proxy_port")); + + ASSERT_TRUE( + document.Set("integrations.fleasion.proxy_mode", "hosts", &error)) + << error; + EXPECT_FALSE(document.IsActive("integrations.fleasion.proxy_port")); + EXPECT_TRUE(document.IsActive("integrations.fleasion.ca_certificate")); +} + TEST(SettingsPagesTest, TextGroupsExplainTheirPlaceholders) { for (const SettingsPage& page : SettingsPages()) { for (const SettingsGroup& group : page.groups) { From c5dc1e288ae8111c12e8965322674232ff949bb9 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 18:26:03 -0500 Subject: [PATCH 26/30] refactor(runtime): share the helper program lookup --- CMakeLists.txt | 1 + include/runtime/helper_program.h | 24 +++++++++++ src/runtime/failure_dialog.cc | 57 ++------------------------ src/runtime/helper_program.cc | 69 ++++++++++++++++++++++++++++++++ src/runtime/settings_launcher.cc | 60 ++------------------------- 5 files changed, 101 insertions(+), 110 deletions(-) create mode 100644 include/runtime/helper_program.h create mode 100644 src/runtime/helper_program.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index 6284d1c6..4b740f1f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -357,6 +357,7 @@ target_link_libraries(mocktail_runtime PRIVATE # Keep GTK out of the process that loads Android libraries. add_library(mocktail_failure_dialog_runtime STATIC src/runtime/failure_dialog.cc + src/runtime/helper_program.cc src/runtime/settings_launcher.cc ) add_library(Mocktail::FailureDialog ALIAS mocktail_failure_dialog_runtime) diff --git a/include/runtime/helper_program.h b/include/runtime/helper_program.h new file mode 100644 index 00000000..a7f1e117 --- /dev/null +++ b/include/runtime/helper_program.h @@ -0,0 +1,24 @@ +#ifndef MOCKTAIL_RUNTIME_HELPER_PROGRAM_H_ +#define MOCKTAIL_RUNTIME_HELPER_PROGRAM_H_ + +#include +#include + +#include "runtime/environment.h" + +namespace mocktail { +namespace runtime { + +// Path of the helper program `name`, or empty when there is none to run. +// `override_variable` names it outright: it must be an absolute path to a +// regular executable file, and nothing else is tried when it is set. +// Otherwise the program is looked for under MOCKTAIL_PROJECT_ROOT/bin, then +// beside this executable and in its install directories. +std::filesystem::path FindHelperProgram(const Environment& environment, + std::string_view override_variable, + std::string_view name); + +} // namespace runtime +} // namespace mocktail + +#endif diff --git a/src/runtime/failure_dialog.cc b/src/runtime/failure_dialog.cc index 5d2caf2a..ab6bbc2d 100644 --- a/src/runtime/failure_dialog.cc +++ b/src/runtime/failure_dialog.cc @@ -13,11 +13,9 @@ #include #include -extern char** environ; +#include "runtime/helper_program.h" -#ifndef MOCKTAIL_INSTALL_LIBDIR -#define MOCKTAIL_INSTALL_LIBDIR "lib" -#endif +extern char** environ; namespace mocktail { namespace runtime { @@ -38,56 +36,9 @@ bool HasNonEmpty(const Environment& environment, std::string_view name) { return value.has_value() && !value->empty(); } -bool IsExecutableRegularFile(const std::filesystem::path& path) { - std::error_code error; - return std::filesystem::is_regular_file(path, error) && - !std::filesystem::is_symlink(path, error) && - access(path.c_str(), X_OK) == 0; -} - std::filesystem::path DialogHelper(const Environment& environment) { - const std::optional helper_override = - environment.Get("MOCKTAIL_FAILURE_DIALOG_HELPER"); - if (helper_override.has_value()) { - const std::filesystem::path helper(*helper_override); - return helper.is_absolute() && IsExecutableRegularFile(helper) - ? helper - : std::filesystem::path{}; - } - - const std::optional project_override = - environment.Get("MOCKTAIL_PROJECT_ROOT"); - if (project_override.has_value()) { - const std::filesystem::path root(*project_override); - if (root.is_absolute()) { - const std::filesystem::path helper = - root / "bin" / "mocktail_failure_dialog"; - if (IsExecutableRegularFile(helper)) { - return helper; - } - } - } - - std::error_code error; - const std::filesystem::path executable = - std::filesystem::read_symlink("/proc/self/exe", error); - if (error || executable.empty()) { - return {}; - } - const std::filesystem::path executable_dir = executable.parent_path(); - const std::vector candidates = { - executable_dir / "mocktail_failure_dialog", - executable_dir.parent_path() / MOCKTAIL_INSTALL_LIBDIR / "mocktail" / - "mocktail_failure_dialog", - executable_dir.parent_path() / "libexec" / "mocktail" / - "mocktail_failure_dialog", - }; - for (const std::filesystem::path& candidate : candidates) { - if (IsExecutableRegularFile(candidate)) { - return candidate; - } - } - return {}; + return FindHelperProgram(environment, "MOCKTAIL_FAILURE_DIALOG_HELPER", + "mocktail_failure_dialog"); } void WaitForHelper(int helper_pid) { diff --git a/src/runtime/helper_program.cc b/src/runtime/helper_program.cc new file mode 100644 index 00000000..739a3c32 --- /dev/null +++ b/src/runtime/helper_program.cc @@ -0,0 +1,69 @@ +#include "runtime/helper_program.h" + +#include + +#include +#include +#include + +#ifndef MOCKTAIL_INSTALL_LIBDIR +#define MOCKTAIL_INSTALL_LIBDIR "lib" +#endif + +namespace mocktail { +namespace runtime { +namespace { + +bool IsExecutableRegularFile(const std::filesystem::path& path) { + std::error_code error; + return std::filesystem::is_regular_file(path, error) && + !std::filesystem::is_symlink(path, error) && + access(path.c_str(), X_OK) == 0; +} + +} // namespace + +std::filesystem::path FindHelperProgram(const Environment& environment, + std::string_view override_variable, + std::string_view name) { + const std::optional named = environment.Get(override_variable); + if (named.has_value()) { + const std::filesystem::path helper(*named); + return helper.is_absolute() && IsExecutableRegularFile(helper) + ? helper + : std::filesystem::path{}; + } + + const std::optional project_root = + environment.Get("MOCKTAIL_PROJECT_ROOT"); + if (project_root.has_value()) { + const std::filesystem::path root(*project_root); + if (root.is_absolute()) { + const std::filesystem::path helper = root / "bin" / name; + if (IsExecutableRegularFile(helper)) { + return helper; + } + } + } + + std::error_code error; + const std::filesystem::path executable = + std::filesystem::read_symlink("/proc/self/exe", error); + if (error || executable.empty()) { + return {}; + } + const std::filesystem::path executable_dir = executable.parent_path(); + const std::filesystem::path install_root = executable_dir.parent_path(); + for (const std::filesystem::path& candidate : + {executable_dir / name, + install_root / MOCKTAIL_INSTALL_LIBDIR / "mocktail" / name, + install_root / "libexec" / "mocktail" / name}) { + if (IsExecutableRegularFile(candidate)) { + return candidate; + } + } + return {}; +} + +} // namespace runtime +} // namespace mocktail diff --git a/src/runtime/settings_launcher.cc b/src/runtime/settings_launcher.cc index ffc3301c..0c9b2239 100644 --- a/src/runtime/settings_launcher.cc +++ b/src/runtime/settings_launcher.cc @@ -4,70 +4,16 @@ #include #include -#include #include -#include -#ifndef MOCKTAIL_INSTALL_LIBDIR -#define MOCKTAIL_INSTALL_LIBDIR "lib" -#endif +#include "runtime/helper_program.h" namespace mocktail { namespace runtime { -namespace { - -constexpr char kProgramName[] = "mocktail_settings"; - -bool IsExecutableRegularFile(const std::filesystem::path& path) { - std::error_code error; - return std::filesystem::is_regular_file(path, error) && - !std::filesystem::is_symlink(path, error) && - access(path.c_str(), X_OK) == 0; -} - -} // namespace std::filesystem::path FindSettingsWindow(const Environment& environment) { - const std::optional named = - environment.Get("MOCKTAIL_SETTINGS_HELPER"); - if (named.has_value()) { - const std::filesystem::path helper(*named); - return helper.is_absolute() && IsExecutableRegularFile(helper) - ? helper - : std::filesystem::path{}; - } - - const std::optional project_root = - environment.Get("MOCKTAIL_PROJECT_ROOT"); - if (project_root.has_value()) { - const std::filesystem::path root(*project_root); - if (root.is_absolute()) { - const std::filesystem::path helper = root / "bin" / kProgramName; - if (IsExecutableRegularFile(helper)) { - return helper; - } - } - } - - std::error_code error; - const std::filesystem::path executable = - std::filesystem::read_symlink("/proc/self/exe", error); - if (error || executable.empty()) { - return {}; - } - const std::filesystem::path executable_dir = executable.parent_path(); - const std::vector candidates = { - executable_dir / kProgramName, - executable_dir.parent_path() / MOCKTAIL_INSTALL_LIBDIR / "mocktail" / - kProgramName, - executable_dir.parent_path() / "libexec" / "mocktail" / kProgramName, - }; - for (const std::filesystem::path& candidate : candidates) { - if (IsExecutableRegularFile(candidate)) { - return candidate; - } - } - return {}; + return FindHelperProgram(environment, "MOCKTAIL_SETTINGS_HELPER", + "mocktail_settings"); } bool ExecSettingsWindow(const Environment& environment, std::string* error) { From 3c60f62218d20420704b54ce5bd8ec1362c76a1c Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 18:26:03 -0500 Subject: [PATCH 27/30] fix(settings): harden restart and cut redundant saves and parses - Restart keeps Nightcap running when its launch cannot be read, starts the new one in the old one's folder, resets ignored signals, and waits 2 minutes. - Number rows save once stepping stops; other saves, Undo, Restart and close flush it. - Cache config values per document text and scan audio devices once. --- include/runtime/running_instance.h | 14 ++- include/ui/settings_model.h | 8 ++ src/runtime/running_instance.cc | 41 ++++++++- src/ui/mocktail_settings.cc | 134 ++++++++++++++++++++--------- src/ui/settings_model.cc | 83 +++++++++++++----- tests/running_instance_test.cc | 93 ++++++++++++++++++-- 6 files changed, 301 insertions(+), 72 deletions(-) diff --git a/include/runtime/running_instance.h b/include/runtime/running_instance.h index 2b568324..5bbf8f75 100644 --- a/include/runtime/running_instance.h +++ b/include/runtime/running_instance.h @@ -42,6 +42,8 @@ struct LaunchCommand { std::vector arguments; // NAME=value entries. std::vector environment; + // Empty keeps this process's own. + std::filesystem::path working_directory; }; // The arguments in /proc//cmdline. Nightcap zeroes a join link after @@ -56,10 +58,20 @@ std::optional ReadLaunchCommand(pid_t pid); // Starts `program` in a session of its own, so it keeps running after this // process exits. Empty arguments run it with just its path; an empty -// environment passes this process's own. +// environment passes this process's own. Signals start out with their +// default handling, whatever this process ignores or blocks. bool StartDetached(const std::filesystem::path& program, std::string* error, const LaunchCommand& command = {}); +// Closes the running Nightcap and starts `program` with its arguments, +// environment and working directory. Nothing is closed when its launch +// cannot be read, since it could not be started the same way again. A +// Nightcap that does not close within `timeout` may still close later. +bool RestartRunningInstance(const std::filesystem::path& lock_file, + const std::filesystem::path& program, + std::chrono::milliseconds timeout, + std::string* error); + } // namespace runtime } // namespace mocktail diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index 3fe42efc..b8bb1e44 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include #include @@ -193,8 +195,14 @@ class SettingsDocument { // True for a key that is not a listed switch. bool IsSwitchedOn(std::string_view key) const; + // Set text_ through this, which forgets the values read from the old text. + void Replace(std::string text); + std::filesystem::path config_file_; std::string text_; + // The values read from text_. A refresh asks for each key many times. + mutable std::map> + values_; std::optional undo_; std::size_t saved_changes_ = 0; }; diff --git a/src/runtime/running_instance.cc b/src/runtime/running_instance.cc index af98f330..2df74139 100644 --- a/src/runtime/running_instance.cc +++ b/src/runtime/running_instance.cc @@ -85,11 +85,15 @@ std::optional ReadLaunchCommand(pid_t pid) { ReadProcFile(directory + "/cmdline", &read_arguments); const std::string variables = ReadProcFile(directory + "/environ", &read_environment); - if (!read_arguments || !read_environment || cmdline.empty()) { + std::error_code cwd_error; + std::filesystem::path working_directory = + std::filesystem::read_symlink(directory + "/cwd", cwd_error); + if (!read_arguments || !read_environment || cmdline.empty() || cwd_error) { return std::nullopt; } return LaunchCommand{ParseProcArguments(cmdline), - ParseProcEnvironment(variables)}; + ParseProcEnvironment(variables), + std::move(working_directory)}; } bool CanRestartFromSettings(const Environment& environment) { @@ -158,7 +162,8 @@ bool StopRunningInstance(const std::filesystem::path& lock_file, while (FindRunningInstance(lock_file).running) { if (std::chrono::steady_clock::now() >= deadline) { *error = "Nightcap did not close within " + - std::to_string(timeout.count() / 1000) + " seconds"; + std::to_string(timeout.count() / 1000) + + " seconds. If it closes later, start it again"; return false; } std::this_thread::sleep_for(std::chrono::milliseconds(50)); @@ -192,6 +197,7 @@ bool StartDetached(const std::filesystem::path& program, std::string* error, } environment.push_back(nullptr); char* const* envp = command.environment.empty() ? environ : environment.data(); + const std::string working_directory = command.working_directory.string(); // The grandchild reports a failed exec through this pipe. A successful exec // closes it, so the parent reads nothing. int report[2]; @@ -210,7 +216,19 @@ bool StartDetached(const std::filesystem::path& program, std::string* error, setsid(); const pid_t grandchild = fork(); if (grandchild == 0) { - execve(path.c_str(), arguments.data(), envp); + // Ignored signals and the signal mask survive exec. A window started + // as a shell's background job ignores SIGINT and SIGQUIT. + struct sigaction default_action {}; + default_action.sa_handler = SIG_DFL; + for (int signal_number = 1; signal_number < NSIG; ++signal_number) { + sigaction(signal_number, &default_action, nullptr); + } + sigset_t none; + sigemptyset(&none); + sigprocmask(SIG_SETMASK, &none, nullptr); + if (working_directory.empty() || chdir(working_directory.c_str()) == 0) { + execve(path.c_str(), arguments.data(), envp); + } const int exec_error = errno; (void)write(report[1], &exec_error, sizeof(exec_error)); _exit(127); @@ -235,5 +253,20 @@ bool StartDetached(const std::filesystem::path& program, std::string* error, : true; } +bool RestartRunningInstance(const std::filesystem::path& lock_file, + const std::filesystem::path& program, + std::chrono::milliseconds timeout, + std::string* error) { + const RunningInstance instance = FindRunningInstance(lock_file); + const std::optional command = + instance.running ? ReadLaunchCommand(instance.pid) : std::nullopt; + if (instance.running && !command.has_value()) { + *error = "cannot read how Nightcap was started, so it was left running"; + return false; + } + return StopRunningInstance(lock_file, timeout, error) && + StartDetached(program, error, command.value_or(LaunchCommand{})); +} + } // namespace runtime } // namespace mocktail diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 8548bcf9..9e6a6a55 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -100,6 +100,8 @@ struct SettingsWindow { // Set while rows are filled from the file, so that filling one is not // taken for a change by the user. bool refreshing = false; + Row* stepped = nullptr; + guint stepped_timer = 0; }; std::string Text(std::string_view text) { return std::string(text); } @@ -130,26 +132,29 @@ void SetChoices(Row* row) { g_object_unref(labels); } -// The names SDL gives the audio devices, which are the names Nightcap picks -// a device by. Empty when SDL finds no audio system. -std::vector AudioDeviceNames(bool recording) { +std::vector AudioDeviceNames(SDL_AudioDeviceID* ids, int count) { std::vector names; - if (!SDL_InitSubSystem(SDL_INIT_AUDIO)) { - return names; - } - int count = 0; - SDL_AudioDeviceID* ids = recording ? SDL_GetAudioRecordingDevices(&count) - : SDL_GetAudioPlaybackDevices(&count); for (int index = 0; ids != nullptr && index < count; ++index) { if (const char* name = SDL_GetAudioDeviceName(ids[index])) { names.emplace_back(name); } } SDL_free(ids); - SDL_QuitSubSystem(SDL_INIT_AUDIO); return names; } +void FindAudioDevices(SettingsWindow* window) { + if (!SDL_InitSubSystem(SDL_INIT_AUDIO)) { + return; + } + int count = 0; + SDL_AudioDeviceID* ids = SDL_GetAudioPlaybackDevices(&count); + window->output_devices = AudioDeviceNames(ids, count); + ids = SDL_GetAudioRecordingDevices(&count); + window->input_devices = AudioDeviceNames(ids, count); + SDL_QuitSubSystem(SDL_INIT_AUDIO); +} + void RefreshDeviceRow(Row* row, const std::string& value) { const SettingsWindow& window = *row->window; std::vector devices = mocktail::ui::DeviceChoices( @@ -289,17 +294,11 @@ struct RestartJob { void RunRestart(GTask* task, gpointer, gpointer task_data, GCancellable*) { auto* job = static_cast(task_data); - // Read before Nightcap closes, so the new one starts with the same - // arguments and environment as the one it replaces. - const std::optional command = - mocktail::runtime::ReadLaunchCommand( - mocktail::runtime::FindRunningInstance(job->instance_lock).pid); - const bool restarted = - mocktail::runtime::StopRunningInstance( - job->instance_lock, std::chrono::seconds(15), &job->error) && - mocktail::runtime::StartDetached( - job->restart_program, &job->error, - command.value_or(mocktail::runtime::LaunchCommand{})); + // Long enough for Roblox to finish closing. Past it, Nightcap may still + // close on the SIGTERM it was sent, and nothing starts it again. + const bool restarted = mocktail::runtime::RestartRunningInstance( + job->instance_lock, job->restart_program, std::chrono::minutes(2), + &job->error); g_task_return_boolean(task, restarted); } @@ -327,10 +326,13 @@ void OnRestartDone(GObject*, GAsyncResult* result, gpointer user_data) { } } +void SaveStepped(SettingsWindow* window); + void StartRestart(SettingsWindow* window) { if (window->restarting) { return; } + SaveStepped(window); window->restarting = true; window->saved_while_restarting = false; gtk_widget_set_sensitive(window->restart, FALSE); @@ -412,19 +414,53 @@ void ShowSaved(Row* row, std::size_t before, bool saved, RefreshRows(window, saved ? row : nullptr); } -void Apply(Row* row, const std::string& value) { - SettingsWindow* window = row->window; - if (window->refreshing) { +std::string SteppedValue(const Row& row) { + if (row.setting->kind == SettingKind::kNumber) { + return mocktail::ui::NumberValue( + adw_spin_row_get_value(ADW_SPIN_ROW(row.widget))); + } + FrameRate rate; + rate.mode = static_cast( + adw_combo_row_get_selected(ADW_COMBO_ROW(row.widget))); + rate.custom = + static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(row.custom))); + return mocktail::ui::FrameRateValue(rate); +} + +void SaveValue(Row* row, const std::string& value); + +// Saves a stepped row now. Every other save, Undo, Restart and closing the +// window call this first, so a stepped value is never lost or saved late. +void SaveStepped(SettingsWindow* window) { + if (window->stepped == nullptr) { return; } + Row* row = window->stepped; + g_source_remove(window->stepped_timer); + window->stepped = nullptr; + window->stepped_timer = 0; + SaveValue(row, SteppedValue(*row)); +} + +void SaveValue(Row* row, const std::string& value) { + SettingsWindow* window = row->window; std::string error; const std::size_t before = window->document.saved_changes(); const bool saved = window->document.Set(row->setting->key, value, &error); ShowSaved(row, before, saved, error); } +void Apply(Row* row, const std::string& value) { + if (row->window->refreshing) { + return; + } + SaveStepped(row->window); + SaveValue(row, value); +} + void OnUndo(GtkButton*, gpointer user_data) { auto* window = static_cast(user_data); + SaveStepped(window); std::string error; if (!window->document.Undo(&error)) { ShowError(window, error); @@ -433,10 +469,32 @@ void OnUndo(GtkButton*, gpointer user_data) { RefreshRows(window); } -void OnNumberChanged(GObject* object, GParamSpec*, gpointer user_data) { - Apply(static_cast(user_data), - mocktail::ui::NumberValue( - adw_spin_row_get_value(ADW_SPIN_ROW(object)))); +// Holding + on a spin row steps it many times a second. The file is saved +// once, half a second after the last step. +void OnSteppedValueChanged(GObject*, GParamSpec*, gpointer user_data) { + Row* row = static_cast(user_data); + SettingsWindow* window = row->window; + if (window->refreshing) { + return; + } + if (window->stepped != row) { + SaveStepped(window); + } else { + g_source_remove(window->stepped_timer); + } + window->stepped = row; + window->stepped_timer = g_timeout_add( + 500, + [](gpointer data) -> gboolean { + auto* stepped_window = static_cast(data); + // The source ends when this returns, so it is not removed again. + stepped_window->stepped_timer = 0; + Row* stepped_row = stepped_window->stepped; + stepped_window->stepped = nullptr; + SaveValue(stepped_row, SteppedValue(*stepped_row)); + return G_SOURCE_REMOVE; + }, + window); } gboolean OnNumberOutput(AdwSpinRow* spin, gpointer user_data) { @@ -462,6 +520,7 @@ gint OnNumberInput(AdwSpinRow* spin, double* value, gpointer user_data) { void OnTextApplied(AdwEntryRow* entry, gpointer user_data) { Row* row = static_cast(user_data); SettingsWindow* window = row->window; + SaveStepped(window); std::string error; const std::size_t before = window->document.saved_changes(); const std::string_view text = gtk_editable_get_text(GTK_EDITABLE(entry)); @@ -477,6 +536,7 @@ void OnTextApplied(AdwEntryRow* entry, gpointer user_data) { void OnReset(GtkButton*, gpointer user_data) { Row* row = static_cast(user_data); SettingsWindow* window = row->window; + SaveStepped(window); std::string error; const std::size_t before = window->document.saved_changes(); const bool saved = window->document.Reset(*row->setting, &error); @@ -553,17 +613,13 @@ void OnDeviceChanged(GObject* object, GParamSpec*, gpointer user_data) { Apply(row, value); } -void OnFrameRateChanged(GObject*, GParamSpec*, gpointer user_data) { +void OnFrameRateModeChanged(GObject*, GParamSpec*, gpointer user_data) { Row* row = static_cast(user_data); const guint selected = adw_combo_row_get_selected(ADW_COMBO_ROW(row->widget)); if (selected > static_cast(FrameRateMode::kCustom)) { return; } - FrameRate rate; - rate.mode = static_cast(selected); - rate.custom = - static_cast(adw_spin_row_get_value(ADW_SPIN_ROW(row->custom))); - Apply(row, mocktail::ui::FrameRateValue(rate)); + Apply(row, SteppedValue(*row)); } // The dialog that edits fflags.json as text. @@ -838,15 +894,15 @@ void AddRow(SettingsWindow* window, GtkWidget* container, mocktail::ui::kDefaultCustomFrameRate); SetRowText(row.custom, "Custom limit", "Frames per second"); g_signal_connect(row.widget, "notify::selected", - G_CALLBACK(OnFrameRateChanged), &row); + G_CALLBACK(OnFrameRateModeChanged), &row); g_signal_connect(row.custom, "notify::value", - G_CALLBACK(OnFrameRateChanged), &row); + G_CALLBACK(OnSteppedValueChanged), &row); break; case SettingKind::kNumber: row.widget = adw_spin_row_new_with_range(setting.minimum, setting.maximum, setting.step); g_signal_connect(row.widget, "notify::value", - G_CALLBACK(OnNumberChanged), &row); + G_CALLBACK(OnSteppedValueChanged), &row); if (!setting.zero_label.empty()) { // A numeric row drops the label's letters. adw_spin_row_set_numeric(ADW_SPIN_ROW(row.widget), FALSE); @@ -1170,6 +1226,7 @@ void OnDiscardTextResponse(AdwAlertDialog*, const char* response, // Closing asks first while a text row holds text that is not applied. gboolean OnCloseRequest(GtkWindow*, gpointer user_data) { auto* window = static_cast(user_data); + SaveStepped(window); const Row* edited = nullptr; for (const Row& row : window->rows) { if ((row.setting->kind == SettingKind::kText || @@ -1233,8 +1290,7 @@ void OnActivate(GtkApplication* application, gpointer user_data) { GtkWidget* content = nullptr; std::string error; if (window->document.Load(&error)) { - window->output_devices = AudioDeviceNames(false); - window->input_devices = AudioDeviceNames(true); + FindAudioDevices(window); content = BuildSplitView(window, ADW_APPLICATION_WINDOW(window_widget)); RefreshRows(window); } else { diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 4d6da7a3..0fd9d7bd 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -40,15 +40,43 @@ runtime::RuntimeConfigValue Get(std::string_view text, std::string_view key) { return runtime::GetRuntimeConfigValue(text, key); } +using ValueCache = + std::map>; + +// Reads keys from one config text. With a cache, each key is parsed once. +class Reader { + public: + explicit Reader(std::string_view text, ValueCache* cache = nullptr) + : text_(text), cache_(cache) {} + + runtime::RuntimeConfigValue Get(std::string_view key) const { + if (cache_ == nullptr) { + return runtime::GetRuntimeConfigValue(text_, key); + } + auto found = cache_->find(key); + if (found == cache_->end()) { + found = cache_ + ->emplace(std::string(key), + runtime::GetRuntimeConfigValue(text_, key)) + .first; + } + return found->second; + } + + private: + std::string_view text_; + ValueCache* cache_; +}; + // The preset the file picks, as the file spells it. Empty when it picks none. -std::string DevicePreset(std::string_view text) { +std::string DevicePreset(const Reader& file) { for (const std::string_view key : {kDeviceKey, kDeviceTypeKey}) { - if (runtime::RuntimeConfigValue device = Get(text, key); device.found) { + if (runtime::RuntimeConfigValue device = file.Get(key); device.found) { return std::move(device.value); } } if (const runtime::RuntimeConfigValue playability = - Get(text, kPlayabilityKey); + file.Get(kPlayabilityKey); playability.found) { const bool desktop = playability.value != "0" && playability.value != "off" && @@ -58,7 +86,6 @@ std::string DevicePreset(std::string_view text) { return {}; } -// What the file's device turns on for the input `key`. bool HasYamlContent(std::string_view text) { std::size_t start = 0; while (start < text.size()) { @@ -94,8 +121,9 @@ std::string WithoutEmptyMapping(std::string_view text) { return kept; } -bool PresetInput(std::string_view text, std::string_view key) { - const std::string preset = DevicePreset(text); +// What the file's device turns on for the input `key`. +bool PresetInput(const Reader& file, std::string_view key) { + const std::string preset = DevicePreset(file); const runtime::DeviceProfile* profile = runtime::FindDeviceProfile( preset.empty() ? runtime::kDefaultDeviceProfileName : std::string_view(preset)); @@ -107,18 +135,18 @@ bool PresetInput(std::string_view text, std::string_view key) { : profile->keyboard_enabled; } -bool DeviceInput(std::string_view text, std::string_view key) { - if (const runtime::RuntimeConfigValue value = Get(text, key); value.found) { +bool DeviceInput(const Reader& file, std::string_view key) { + if (const runtime::RuntimeConfigValue value = file.Get(key); value.found) { return value.value == "true"; } if (key == kTouchKey) { if (const runtime::RuntimeConfigValue value = - Get(text, kTouchKeyWithoutDevice); + file.Get(kTouchKeyWithoutDevice); value.found) { return value.value == "true"; } } - return PresetInput(text, key); + return PresetInput(file, key); } // Runs `step` on the text of `edit`, unless `edit` already failed. @@ -143,7 +171,7 @@ runtime::RuntimeConfigEdit ToDeviceBlock(std::string_view text) { if (Get(text, kDeviceTypeKey).found) { return Unchanged(text); } - const std::string preset = DevicePreset(text); + const std::string preset = DevicePreset(Reader(text)); const runtime::RuntimeConfigValue touch = Get(text, kTouchKeyWithoutDevice); runtime::RuntimeConfigEdit edit = runtime::SetRuntimeConfigString( text, kDeviceTypeKey, @@ -182,9 +210,9 @@ runtime::RuntimeConfigEdit SetDevicePreset(std::string_view text, }); } -std::string ProxyValue(std::string_view text) { - const runtime::RuntimeConfigValue host = Get(text, kProxyHostKey); - const runtime::RuntimeConfigValue port = Get(text, kProxyPortKey); +std::string ProxyValue(const Reader& file) { + const runtime::RuntimeConfigValue host = file.Get(kProxyHostKey); + const runtime::RuntimeConfigValue port = file.Get(kProxyPortKey); return host.found && port.found ? host.value + ":" + port.value : std::string(); } @@ -987,25 +1015,33 @@ bool SettingsDocument::Load(std::string* error) { *error = probe.error; return false; } - text_ = std::move(read.text); + Replace(std::move(read.text)); return true; } +void SettingsDocument::Replace(std::string text) { + text_ = std::move(text); + values_.clear(); +} + std::string SettingsDocument::Value(std::string_view key) const { + const Reader file(text_, &values_); if (key == kTouchKey || key == kMouseKey || key == kKeyboardKey) { - return DeviceInput(text_, key) ? "true" : "false"; + return DeviceInput(file, key) ? "true" : "false"; } if (key == kDeviceKey) { - return DevicePreset(text_); + return DevicePreset(file); } if (key == kProxyKey) { - return ProxyValue(text_); + return ProxyValue(file); } - return Get(text_, key).value; + return file.Get(key).value; } bool SettingsDocument::IsSet(std::string_view key) const { - return Get(text_, key == kProxyKey ? kProxyHostKey : key).found; + return Reader(text_, &values_) + .Get(key == kProxyKey ? kProxyHostKey : key) + .found; } bool SettingsDocument::Set(std::string_view key, std::string_view value, @@ -1021,7 +1057,8 @@ bool SettingsDocument::SetText(std::string_view key, std::string_view value, std::string SettingsDocument::DefaultValue(const Setting& setting) const { if (setting.key == kTouchKey || setting.key == kMouseKey || setting.key == kKeyboardKey) { - return PresetInput(text_, setting.key) ? "true" : "false"; + return PresetInput(Reader(text_, &values_), setting.key) ? "true" + : "false"; } return std::string(setting.default_value); } @@ -1174,13 +1211,13 @@ bool SettingsDocument::Save(runtime::RuntimeConfigEdit edit, runtime::RuntimeConfigText read = runtime::ReadRuntimeConfigText(config_file_); if (read && read.text != text_) { - text_ = std::move(read.text); + Replace(std::move(read.text)); undo_.reset(); } return false; } undo_ = std::move(text_); - text_ = std::move(edited); + Replace(std::move(edited)); ++saved_changes_; return true; } diff --git a/tests/running_instance_test.cc b/tests/running_instance_test.cc index 4fe782ae..d6f70e3b 100644 --- a/tests/running_instance_test.cc +++ b/tests/running_instance_test.cc @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -111,15 +112,20 @@ class RunningInstanceTest : public ::testing::Test { std::filesystem::remove_all(directory_); } - // Forks a process that holds the lock. With `ignore_term` it stays on - // SIGTERM; otherwise it exits on it after a short delay. - void StartHolder(bool ignore_term) { + // Forks a process that holds the lock from inside directory_. With + // `ignore_term` it stays on SIGTERM; otherwise it exits on it after a short + // delay. With `hide_proc` its /proc files cannot be read. + void StartHolder(bool ignore_term, bool hide_proc = false) { int ready[2]; ASSERT_EQ(pipe(ready), 0); holder_ = fork(); ASSERT_GE(holder_, 0); if (holder_ == 0) { close(ready[0]); + if (chdir(directory_.c_str()) != 0 || + (hide_proc && prctl(PR_SET_DUMPABLE, 0) != 0)) { + _exit(1); + } if (ignore_term) { signal(SIGTERM, SIG_IGN); } else { @@ -209,6 +215,9 @@ TEST_F(RunningInstanceTest, ReadsHowAProcessWasStarted) { holder_ = fork(); ASSERT_GE(holder_, 0); if (holder_ == 0) { + if (chdir(directory_.c_str()) != 0) { + _exit(126); + } char* const arguments[] = {const_cast("sleep"), const_cast("30"), nullptr}; char* const environment[] = {const_cast("NIGHTCAP_TEST=1"), @@ -228,13 +237,16 @@ TEST_F(RunningInstanceTest, ReadsHowAProcessWasStarted) { ASSERT_TRUE(command.has_value()); EXPECT_EQ(command->arguments, (Strings{"sleep", "30"})); EXPECT_EQ(command->environment, (Strings{"NIGHTCAP_TEST=1"})); + EXPECT_EQ(command->working_directory, + std::filesystem::canonical(directory_)); EXPECT_FALSE(ReadLaunchCommand(-1).has_value()); } TEST_F(RunningInstanceTest, StartDetachedPassesArgumentsAndEnvironment) { const std::filesystem::path marker = directory_ / "marker"; const std::filesystem::path program = directory_ / "program.sh"; - std::ofstream(program) << "#!/bin/sh\nprintf '%s|%s|%s' \"$0\" \"$1\" \"$X\" > '" + std::ofstream(program) << "#!/bin/sh\nprintf '%s|%s|%s|%s' \"$0\" \"$1\" " + "\"$X\" \"$(pwd)\" > '" << marker.string() << ".tmp' && mv '" << marker.string() << ".tmp' '" << marker.string() << "'\n"; @@ -243,6 +255,7 @@ TEST_F(RunningInstanceTest, StartDetachedPassesArgumentsAndEnvironment) { LaunchCommand command; command.arguments = {"renamed", "first"}; command.environment = {"X=yes", "PATH=/usr/bin:/bin"}; + command.working_directory = std::filesystem::canonical(directory_); std::string error; ASSERT_TRUE(StartDetached(program, &error, command)) << error; for (int attempt = 0; attempt < 100 && !std::filesystem::exists(marker); @@ -253,7 +266,77 @@ TEST_F(RunningInstanceTest, StartDetachedPassesArgumentsAndEnvironment) { std::string text; std::getline(std::ifstream(marker), text); // A script sees its own path as $0, whatever the first argument says. - EXPECT_EQ(text, program.string() + "|first|yes"); + EXPECT_EQ(text, program.string() + "|first|yes|" + + command.working_directory.string()); +} + +TEST_F(RunningInstanceTest, RestartLeavesAnInstanceItCannotReadRunning) { + StartHolder(false, true); + std::string error; + EXPECT_FALSE(RestartRunningInstance(lock_, directory_ / "missing", + milliseconds(2000), &error)); + EXPECT_FALSE(error.empty()); + EXPECT_TRUE(FindRunningInstance(lock_).running); +} + +TEST_F(RunningInstanceTest, RestartStartsTheProgramWhereTheOldOneRan) { + StartHolder(false); + const std::filesystem::path marker = directory_ / "marker"; + const std::filesystem::path program = directory_ / "program.sh"; + std::ofstream(program) << "#!/bin/sh\npwd > '" << marker.string() + << ".tmp' && mv '" << marker.string() << ".tmp' '" + << marker.string() << "'\n"; + std::filesystem::permissions(program, std::filesystem::perms::owner_all); + + std::string error; + ASSERT_TRUE(RestartRunningInstance(lock_, program, milliseconds(5000), + &error)) + << error; + EXPECT_EQ(waitpid(holder_, nullptr, 0), holder_); + holder_ = -1; + for (int attempt = 0; attempt < 100 && !std::filesystem::exists(marker); + ++attempt) { + std::this_thread::sleep_for(milliseconds(20)); + } + ASSERT_TRUE(std::filesystem::exists(marker)); + std::string text; + std::getline(std::ifstream(marker), text); + EXPECT_EQ(text, std::filesystem::canonical(directory_).string()); +} + +TEST_F(RunningInstanceTest, StartDetachedDoesNotPassOnIgnoredSignals) { + const std::filesystem::path marker = directory_ / "marker"; + const std::filesystem::path program = directory_ / "program.sh"; + std::ofstream(program) << "#!/bin/sh\ngrep SigIgn /proc/$$/status > '" + << marker.string() << ".tmp' && mv '" + << marker.string() << ".tmp' '" << marker.string() + << "'\n"; + std::filesystem::permissions(program, std::filesystem::perms::owner_all); + + // A shell runs a background job with SIGINT and SIGQUIT ignored. + struct sigaction ignore {}; + ignore.sa_handler = SIG_IGN; + struct sigaction old_int {}; + struct sigaction old_quit {}; + sigaction(SIGINT, &ignore, &old_int); + sigaction(SIGQUIT, &ignore, &old_quit); + std::string error; + const bool started = StartDetached(program, &error); + sigaction(SIGINT, &old_int, nullptr); + sigaction(SIGQUIT, &old_quit, nullptr); + ASSERT_TRUE(started) << error; + for (int attempt = 0; attempt < 100 && !std::filesystem::exists(marker); + ++attempt) { + std::this_thread::sleep_for(milliseconds(20)); + } + ASSERT_TRUE(std::filesystem::exists(marker)); + std::string line; + std::getline(std::ifstream(marker), line); + const unsigned long long ignored = + std::stoull(line.substr(line.find_first_not_of("SigIgn:\t ")), nullptr, + 16); + EXPECT_EQ(ignored & ((1ULL << (SIGINT - 1)) | (1ULL << (SIGQUIT - 1))), 0u) + << line; } TEST_F(RunningInstanceTest, StartDetachedReportsAProgramThatCannotRun) { From 325644cc1f0a87210d8709de16a0f027897943a5 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 20:20:12 -0500 Subject: [PATCH 28/30] fix(ui): give the loading and failure windows Nightcap's app id --- src/ui/mocktail_failure_dialog.cc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/mocktail_failure_dialog.cc b/src/ui/mocktail_failure_dialog.cc index 37566e0a..72ab68ac 100644 --- a/src/ui/mocktail_failure_dialog.cc +++ b/src/ui/mocktail_failure_dialog.cc @@ -155,6 +155,9 @@ void InstallProgressStyle(GdkDisplay* display) { bool InitializeUi(UiStyle style) { g_set_application_name("Nightcap"); + // GTK names a window's app after the program. Without this, docks cannot + // tie the window to Nightcap's desktop entry and give it an icon of its own. + g_set_prgname("io.github.CoderDayton.nightcap"); if (!gtk_init_check()) { return false; } From 0c405241b7ace45eb2e8b810c89b60a955074cb5 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 20:40:46 -0500 Subject: [PATCH 29/30] fix(settings): close review gaps in restart and stepped saves - Restart closes the exact instance it read, reports an unreachable one accurately, and still starts when the old folder is gone. - Save a stepped value without refreshing rows; route every file change through EditDocument and cancel the timer on destroy. - Share the app id constant, return cached config values by reference, and fix Release-only warnings. --- CMakeLists.txt | 1 + include/mocktail/platform/application_id.h | 12 +++ .../platform/sdl_application_metadata.h | 4 +- include/ui/settings_model.h | 8 +- src/runtime/running_instance.cc | 54 +++++++++--- src/ui/mocktail_failure_dialog.cc | 6 +- src/ui/mocktail_settings.cc | 85 +++++++++++-------- src/ui/settings_model.cc | 26 +++--- tests/running_instance_test.cc | 24 +++++- 9 files changed, 148 insertions(+), 72 deletions(-) create mode 100644 include/mocktail/platform/application_id.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b740f1f..1b9db1b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -370,6 +370,7 @@ mocktail_apply_compile_options(mocktail_failure_dialog_runtime) add_executable(mocktail_failure_dialog src/ui/mocktail_failure_dialog.cc ) +target_include_directories(mocktail_failure_dialog PRIVATE include) target_link_libraries(mocktail_failure_dialog PRIVATE PkgConfig::LIBADWAITA) mocktail_apply_compile_options(mocktail_failure_dialog) diff --git a/include/mocktail/platform/application_id.h b/include/mocktail/platform/application_id.h new file mode 100644 index 00000000..2e0d3013 --- /dev/null +++ b/include/mocktail/platform/application_id.h @@ -0,0 +1,12 @@ +#ifndef MOCKTAIL_PLATFORM_APPLICATION_ID_H_ +#define MOCKTAIL_PLATFORM_APPLICATION_ID_H_ + +namespace mocktail { +namespace platform { + +inline constexpr char kApplicationId[] = "io.github.CoderDayton.nightcap"; + +} +} + +#endif diff --git a/include/mocktail/platform/sdl_application_metadata.h b/include/mocktail/platform/sdl_application_metadata.h index 71c993a7..4b44bf43 100644 --- a/include/mocktail/platform/sdl_application_metadata.h +++ b/include/mocktail/platform/sdl_application_metadata.h @@ -2,14 +2,14 @@ #ifndef MOCKTAIL_PLATFORM_SDL_APPLICATION_METADATA_H_ #define MOCKTAIL_PLATFORM_SDL_APPLICATION_METADATA_H_ +#include "mocktail/platform/application_id.h" #include "mocktail/status.h" namespace mocktail { namespace platform { inline constexpr char kMocktailApplicationName[] = "Nightcap"; -inline constexpr char kMocktailApplicationIdentifier[] = - "io.github.CoderDayton.nightcap"; +inline constexpr const char* kMocktailApplicationIdentifier = kApplicationId; // Configures the stable compositor identity before SDL initializes. The // identifier matches the installed desktop file and icon name so Wayland and diff --git a/include/ui/settings_model.h b/include/ui/settings_model.h index b8bb1e44..bc86e610 100644 --- a/include/ui/settings_model.h +++ b/include/ui/settings_model.h @@ -57,20 +57,20 @@ struct Setting { double maximum = 1048576; double step = 256; // What a kNumber row shows in place of 0, when 0 turns the setting off. - std::string_view zero_label; + std::string_view zero_label = {}; }; struct SettingsGroup { std::string_view title; std::vector settings; - std::string_view expander; - std::string_view expander_subtitle; + std::string_view expander = {}; + std::string_view expander_subtitle = {}; // With this, the first setting, a kToggle, is the expander row's own // switch, and the other settings apply only while it is on. bool expander_switch = false; // Shown under the title. Text rows have no subtitle, so their format is // explained here. - std::string_view description; + std::string_view description = {}; }; struct SettingsPage { diff --git a/src/runtime/running_instance.cc b/src/runtime/running_instance.cc index 2df74139..ba64b522 100644 --- a/src/runtime/running_instance.cc +++ b/src/runtime/running_instance.cc @@ -30,6 +30,14 @@ std::string ReadProcFile(const std::string& path, bool* read) { std::string ReadProcLocks() { return ReadProcFile("/proc/locks", nullptr); } +// Runs between fork and exec, so it makes only the system call. A report that +// cannot be written leaves the parent reading a successful start. +void ReportStartError(int pipe, int error_number) { + while (write(pipe, &error_number, sizeof(error_number)) < 0 && + errno == EINTR) { + } +} + // Every entry of a NUL-separated list, empty ones included. std::vector SplitNulList(std::string_view list) { std::vector entries; @@ -143,15 +151,19 @@ RunningInstance FindRunningInstance(const std::filesystem::path& lock_file) { return {true, *holder}; } -bool StopRunningInstance(const std::filesystem::path& lock_file, - std::chrono::milliseconds timeout, - std::string* error) { - const RunningInstance instance = FindRunningInstance(lock_file); +namespace { + +constexpr char kUnreachableError[] = + "Nightcap is running where the settings window cannot reach it"; + +bool StopInstance(const std::filesystem::path& lock_file, + const RunningInstance& instance, + std::chrono::milliseconds timeout, std::string* error) { if (!instance.running) { return true; } if (instance.pid <= 0) { - *error = "Nightcap is running where the settings window cannot reach it"; + *error = kUnreachableError; return false; } if (kill(instance.pid, SIGTERM) != 0 && errno != ESRCH) { @@ -171,6 +183,21 @@ bool StopRunningInstance(const std::filesystem::path& lock_file, return true; } +void EnterFolderIfItExists(const std::string& folder) { + if (!folder.empty() && chdir(folder.c_str()) != 0) { + return; + } +} + +} + +bool StopRunningInstance(const std::filesystem::path& lock_file, + std::chrono::milliseconds timeout, + std::string* error) { + return StopInstance(lock_file, FindRunningInstance(lock_file), timeout, + error); +} + bool StartDetached(const std::filesystem::path& program, std::string* error, const LaunchCommand& command) { const auto fail = [&](int error_number) { @@ -226,16 +253,13 @@ bool StartDetached(const std::filesystem::path& program, std::string* error, sigset_t none; sigemptyset(&none); sigprocmask(SIG_SETMASK, &none, nullptr); - if (working_directory.empty() || chdir(working_directory.c_str()) == 0) { - execve(path.c_str(), arguments.data(), envp); - } - const int exec_error = errno; - (void)write(report[1], &exec_error, sizeof(exec_error)); + EnterFolderIfItExists(working_directory); + execve(path.c_str(), arguments.data(), envp); + ReportStartError(report[1], errno); _exit(127); } if (grandchild < 0) { - const int fork_error = errno; - (void)write(report[1], &fork_error, sizeof(fork_error)); + ReportStartError(report[1], errno); } _exit(0); } @@ -258,13 +282,17 @@ bool RestartRunningInstance(const std::filesystem::path& lock_file, std::chrono::milliseconds timeout, std::string* error) { const RunningInstance instance = FindRunningInstance(lock_file); + if (instance.running && instance.pid <= 0) { + *error = kUnreachableError; + return false; + } const std::optional command = instance.running ? ReadLaunchCommand(instance.pid) : std::nullopt; if (instance.running && !command.has_value()) { *error = "cannot read how Nightcap was started, so it was left running"; return false; } - return StopRunningInstance(lock_file, timeout, error) && + return StopInstance(lock_file, instance, timeout, error) && StartDetached(program, error, command.value_or(LaunchCommand{})); } diff --git a/src/ui/mocktail_failure_dialog.cc b/src/ui/mocktail_failure_dialog.cc index 72ab68ac..1dbd1afb 100644 --- a/src/ui/mocktail_failure_dialog.cc +++ b/src/ui/mocktail_failure_dialog.cc @@ -9,6 +9,8 @@ #include #include +#include "mocktail/platform/application_id.h" + namespace { constexpr char kMessagePacket = 'M'; @@ -157,7 +159,7 @@ bool InitializeUi(UiStyle style) { g_set_application_name("Nightcap"); // GTK names a window's app after the program. Without this, docks cannot // tie the window to Nightcap's desktop entry and give it an icon of its own. - g_set_prgname("io.github.CoderDayton.nightcap"); + g_set_prgname(mocktail::platform::kApplicationId); if (!gtk_init_check()) { return false; } @@ -173,7 +175,7 @@ bool InitializeUi(UiStyle style) { if (display == nullptr) { return false; } - gtk_window_set_default_icon_name("io.github.CoderDayton.nightcap"); + gtk_window_set_default_icon_name(mocktail::platform::kApplicationId); adw_style_manager_set_color_scheme(adw_style_manager_get_default(), ADW_COLOR_SCHEME_FORCE_DARK); adw_style_manager_set_color_scheme(adw_style_manager_get_for_display(display), diff --git a/src/ui/mocktail_settings.cc b/src/ui/mocktail_settings.cc index 9e6a6a55..2c54161f 100644 --- a/src/ui/mocktail_settings.cc +++ b/src/ui/mocktail_settings.cc @@ -7,8 +7,10 @@ #include #include #include +#include #include +#include "mocktail/platform/application_id.h" #include "mocktail/platform/sdl_window_icon_data.h" #include "runtime/environment.h" #include "runtime/running_instance.h" @@ -29,7 +31,7 @@ using mocktail::ui::SettingsDocument; using mocktail::ui::SettingsGroup; using mocktail::ui::SettingsPage; -constexpr char kApplicationId[] = "io.github.CoderDayton.nightcap"; +using mocktail::platform::kApplicationId; constexpr char kWindowStyle[] = R"( .navigation-sidebar > row { min-height: 44px; margin: 1px 6px; padding: 0 14px; } @@ -427,42 +429,50 @@ std::string SteppedValue(const Row& row) { return mocktail::ui::FrameRateValue(rate); } -void SaveValue(Row* row, const std::string& value); - -// Saves a stepped row now. Every other save, Undo, Restart and closing the -// window call this first, so a stepped value is never lost or saved late. void SaveStepped(SettingsWindow* window) { - if (window->stepped == nullptr) { + if (window->stepped_timer != 0) { + g_source_remove(window->stepped_timer); + window->stepped_timer = 0; + } + Row* row = std::exchange(window->stepped, nullptr); + if (row == nullptr || window->closed) { return; } - Row* row = window->stepped; - g_source_remove(window->stepped_timer); - window->stepped = nullptr; - window->stepped_timer = 0; - SaveValue(row, SteppedValue(*row)); -} - -void SaveValue(Row* row, const std::string& value) { - SettingsWindow* window = row->window; std::string error; const std::size_t before = window->document.saved_changes(); - const bool saved = window->document.Set(row->setting->key, value, &error); - ShowSaved(row, before, saved, error); + if (!window->document.Set(row->setting->key, SteppedValue(*row), &error)) { + ShowError(window, error); + RefreshRows(window); + return; + } + if (window->document.saved_changes() != before) { + OnChangeSaved(window); + } + gtk_widget_set_visible(window->undo, window->document.CanUndo()); + ShowChanged(window); +} + +SettingsDocument& EditDocument(SettingsWindow* window) { + SaveStepped(window); + return window->document; } void Apply(Row* row, const std::string& value) { - if (row->window->refreshing) { + SettingsWindow* window = row->window; + if (window->refreshing) { return; } - SaveStepped(row->window); - SaveValue(row, value); + SettingsDocument& document = EditDocument(window); + std::string error; + const std::size_t before = document.saved_changes(); + const bool saved = document.Set(row->setting->key, value, &error); + ShowSaved(row, before, saved, error); } void OnUndo(GtkButton*, gpointer user_data) { auto* window = static_cast(user_data); - SaveStepped(window); std::string error; - if (!window->document.Undo(&error)) { + if (!EditDocument(window).Undo(&error)) { ShowError(window, error); } gtk_widget_set_visible(window->undo, FALSE); @@ -489,9 +499,7 @@ void OnSteppedValueChanged(GObject*, GParamSpec*, gpointer user_data) { auto* stepped_window = static_cast(data); // The source ends when this returns, so it is not removed again. stepped_window->stepped_timer = 0; - Row* stepped_row = stepped_window->stepped; - stepped_window->stepped = nullptr; - SaveValue(stepped_row, SteppedValue(*stepped_row)); + SaveStepped(stepped_window); return G_SOURCE_REMOVE; }, window); @@ -519,27 +527,24 @@ gint OnNumberInput(AdwSpinRow* spin, double* value, gpointer user_data) { void OnTextApplied(AdwEntryRow* entry, gpointer user_data) { Row* row = static_cast(user_data); - SettingsWindow* window = row->window; - SaveStepped(window); + SettingsDocument& document = EditDocument(row->window); std::string error; - const std::size_t before = window->document.saved_changes(); + const std::size_t before = document.saved_changes(); const std::string_view text = gtk_editable_get_text(GTK_EDITABLE(entry)); // A field with no default left empty means unset, not an empty text the // loader would refuse. - const bool saved = - text.empty() && row->setting->default_value.empty() - ? window->document.Set(row->setting->key, text, &error) - : window->document.SetText(row->setting->key, text, &error); + const bool saved = text.empty() && row->setting->default_value.empty() + ? document.Set(row->setting->key, text, &error) + : document.SetText(row->setting->key, text, &error); ShowSaved(row, before, saved, error); } void OnReset(GtkButton*, gpointer user_data) { Row* row = static_cast(user_data); - SettingsWindow* window = row->window; - SaveStepped(window); + SettingsDocument& document = EditDocument(row->window); std::string error; - const std::size_t before = window->document.saved_changes(); - const bool saved = window->document.Reset(*row->setting, &error); + const std::size_t before = document.saved_changes(); + const bool saved = document.Reset(*row->setting, &error); ShowSaved(row, before, saved, error); } @@ -1259,7 +1264,13 @@ gboolean OnCloseRequest(GtkWindow*, gpointer user_data) { } void OnWindowDestroy(GtkWidget*, gpointer user_data) { - static_cast(user_data)->closed = true; + auto* window = static_cast(user_data); + window->closed = true; + if (window->stepped_timer != 0) { + g_source_remove(window->stepped_timer); + window->stepped_timer = 0; + } + window->stepped = nullptr; } void OnActivate(GtkApplication* application, gpointer user_data) { diff --git a/src/ui/settings_model.cc b/src/ui/settings_model.cc index 0fd9d7bd..97604523 100644 --- a/src/ui/settings_model.cc +++ b/src/ui/settings_model.cc @@ -43,16 +43,14 @@ runtime::RuntimeConfigValue Get(std::string_view text, std::string_view key) { using ValueCache = std::map>; -// Reads keys from one config text. With a cache, each key is parsed once. class Reader { public: explicit Reader(std::string_view text, ValueCache* cache = nullptr) - : text_(text), cache_(cache) {} + : text_(text), cache_(cache != nullptr ? cache : &own_) {} + Reader(const Reader&) = delete; + Reader& operator=(const Reader&) = delete; - runtime::RuntimeConfigValue Get(std::string_view key) const { - if (cache_ == nullptr) { - return runtime::GetRuntimeConfigValue(text_, key); - } + const runtime::RuntimeConfigValue& Get(std::string_view key) const { auto found = cache_->find(key); if (found == cache_->end()) { found = cache_ @@ -65,17 +63,19 @@ class Reader { private: std::string_view text_; + mutable ValueCache own_; ValueCache* cache_; }; // The preset the file picks, as the file spells it. Empty when it picks none. std::string DevicePreset(const Reader& file) { for (const std::string_view key : {kDeviceKey, kDeviceTypeKey}) { - if (runtime::RuntimeConfigValue device = file.Get(key); device.found) { - return std::move(device.value); + if (const runtime::RuntimeConfigValue& device = file.Get(key); + device.found) { + return device.value; } } - if (const runtime::RuntimeConfigValue playability = + if (const runtime::RuntimeConfigValue& playability = file.Get(kPlayabilityKey); playability.found) { const bool desktop = playability.value != "0" && @@ -136,11 +136,11 @@ bool PresetInput(const Reader& file, std::string_view key) { } bool DeviceInput(const Reader& file, std::string_view key) { - if (const runtime::RuntimeConfigValue value = file.Get(key); value.found) { + if (const runtime::RuntimeConfigValue& value = file.Get(key); value.found) { return value.value == "true"; } if (key == kTouchKey) { - if (const runtime::RuntimeConfigValue value = + if (const runtime::RuntimeConfigValue& value = file.Get(kTouchKeyWithoutDevice); value.found) { return value.value == "true"; @@ -211,8 +211,8 @@ runtime::RuntimeConfigEdit SetDevicePreset(std::string_view text, } std::string ProxyValue(const Reader& file) { - const runtime::RuntimeConfigValue host = file.Get(kProxyHostKey); - const runtime::RuntimeConfigValue port = file.Get(kProxyPortKey); + const runtime::RuntimeConfigValue& host = file.Get(kProxyHostKey); + const runtime::RuntimeConfigValue& port = file.Get(kProxyPortKey); return host.found && port.found ? host.value + ":" + port.value : std::string(); } diff --git a/tests/running_instance_test.cc b/tests/running_instance_test.cc index d6f70e3b..89b5a797 100644 --- a/tests/running_instance_test.cc +++ b/tests/running_instance_test.cc @@ -138,7 +138,9 @@ class RunningInstanceTest : public ::testing::Test { if (descriptor < 0 || flock(descriptor, LOCK_EX) != 0) { _exit(1); } - (void)write(ready[1], "x", 1); + if (write(ready[1], "x", 1) != 1) { + _exit(1); + } for (;;) { pause(); } @@ -270,8 +272,28 @@ TEST_F(RunningInstanceTest, StartDetachedPassesArgumentsAndEnvironment) { command.working_directory.string()); } +TEST_F(RunningInstanceTest, StartDetachedRunsWhereItCanWhenTheFolderIsGone) { + const std::filesystem::path marker = directory_ / "marker"; + const std::filesystem::path program = directory_ / "program.sh"; + std::ofstream(program) << "#!/bin/sh\n: > '" << marker.string() << "'\n"; + std::filesystem::permissions(program, std::filesystem::perms::owner_all); + LaunchCommand command; + command.working_directory = directory_ / "gone"; + + std::string error; + ASSERT_TRUE(StartDetached(program, &error, command)) << error; + for (int attempt = 0; attempt < 100 && !std::filesystem::exists(marker); + ++attempt) { + std::this_thread::sleep_for(milliseconds(20)); + } + EXPECT_TRUE(std::filesystem::exists(marker)); +} + TEST_F(RunningInstanceTest, RestartLeavesAnInstanceItCannotReadRunning) { StartHolder(false, true); + if (ReadLaunchCommand(holder_).has_value()) { + GTEST_SKIP() << "this user can read every process, as root can"; + } std::string error; EXPECT_FALSE(RestartRunningInstance(lock_, directory_ / "missing", milliseconds(2000), &error)); From f5b2925daa63522b5f18446c5b88dbfe018bfc23 Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 16 Oct 2026 20:40:46 -0500 Subject: [PATCH 30/30] docs: cover the restart offer, shortcuts and the dock icon fix --- CHANGELOG.md | 1 + README.md | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 836283df..bc9d6d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Release notes on GitHub are taken from the matching section here. - Joining a game from a website link while already in a game no longer freezes Nightcap. It leaves the current game and joins the new one, and leaving that game returns to the home screen. +- The loading window no longer gets a dock icon of its own. It now shows under Nightcap's icon. ### Changed diff --git a/README.md b/README.md index 8560a50f..5254d084 100644 --- a/README.md +++ b/README.md @@ -159,11 +159,15 @@ mocktail --settings ``` With the Flatpak, run -`flatpak run io.github.CoderDayton.nightcap --settings`. +`flatpak run io.github.CoderDayton.nightcap --settings`. To open it with a +key, bind that command to a keyboard shortcut in your desktop's settings. If +Settings is missing from the menu right after you install, log out and back +in. The window edits `~/.config/mocktail/config.yaml`. It keeps your comments and layout, saves each change at once, and does not save a value Nightcap -could not start with. Changes apply the next time Nightcap starts. +could not start with. Changes apply the next time Nightcap starts. If +Nightcap is open, the window offers to restart it, except in the Flatpak. You can also edit the file by hand. Useful options: